diff --git a/.github/ISSUE_TEMPLATE/integrations.yml b/.github/ISSUE_TEMPLATE/integrations.yml index 586e8b4..815e901 100644 --- a/.github/ISSUE_TEMPLATE/integrations.yml +++ b/.github/ISSUE_TEMPLATE/integrations.yml @@ -12,8 +12,8 @@ body: id: version attributes: label: InstantClone version - description: Top-right of the dashboard, or the `v0.1.16` in the .exe name. - placeholder: "v0.1.16" + description: Top-right of the dashboard, or the `v0.1.17` in the .exe name. + placeholder: "v0.1.17" validations: required: true diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 09eaa6a..75af006 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -2,8 +2,8 @@ name: release # Trigger: pushing a tag like v0.1.0 or v0.2.3. # What it does: builds the release binary, computes its SHA-256, and -# creates a GitHub Release with the .exe + a checksum file attached. -# Auto-generated release notes come from the commit log between tags. +# creates a GitHub Release with the .exe + a checksum file attached. The +# release's notes are written by hand (see "publish release"). on: push: tags: ["v*"] @@ -33,6 +33,18 @@ jobs: runs-on: windows-2022 steps: - uses: actions/checkout@v7 + # The tag must be the app's own version (Cargo.toml). A build that + # says it is another version than its release would see that release + # as newer than itself, and offer it as an update over and over. + - name: tag matches the app version + shell: bash + run: | + version=$(sed -n 's/^version = "\(.*\)"/\1/p' Cargo.toml | head -n 1) + if [ "$GITHUB_REF_NAME" != "v$version" ]; then + echo "::error::Tag $GITHUB_REF_NAME doesn't match the app version $version in Cargo.toml. Bump Cargo.toml, or tag v$version." + exit 1 + fi + echo "Tag $GITHUB_REF_NAME matches the app version." - uses: dtolnay/rust-toolchain@stable with: components: clippy, rustfmt diff --git a/CHANGELOG.md b/CHANGELOG.md index f02b3bd..9876983 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,345 @@ All notable changes will land here. Format loosely follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), versions follow [SemVer](https://semver.org/spec/v2.0.0.html). +## [0.1.17] - Alerts you design, goals and timers, events in the dock, and folders for integrations + +### Alerts you design + +- **Alerts you design.** Every alert plays a design made in the Overlay + Studio: layers (its picture, its title, its words, shapes, anything) that + wait hidden, come in with their own animations when the alert plays, and + leave when it ends. One browser source still plays them all: the alerts + page shows the alert's design for as long as it lasts, with its sound. + Each alert can play its own, or one design can play them all. A new + design starts from a ready one, the picture beside the title and words + on a dark card, to change as you like. +- **Seven ready-made designs, built from layers.** Island, Signal, Banner, + Bubble, Minimal, Paper and Cinema each come as their parts: the card + (and its tail, tape, tag or gold lines), the picture, the title and the + words, each a named layer with its own way in, to move, restyle or + delete. Their accent and words colours are the design's (Theme tab), so + one change recolours it all. A ready-made design is saved to your + overlays the first time it is edited, copied or picked, as a file like + any other. +- **Your look from before is a design.** The first time you open + Integrations or the Alerts page, the look your alerts had becomes "My + alerts", in its theme's layers, with its place, size, font, colors and + sound, and every alert plays it, coming in as its card did. A look with + custom CSS keeps its card, as the CSS was written for it, and so does + one with the picture on top. An alerts source with `?at=top` in its + address (as 0.1.15 set it up) still shows them at the top. The Alerts + look window is gone: alerts look like their design. +- **Saved overlays catch up by themselves.** Each overlay says which + version of the Studio made it, and the dashboard makes again those an + older one made (never while you're live), so a fix to how layers draw + reaches them without saving each one. Alert designs that were one Alert + card ("My alerts", ready-made ones saved before) become layers on the + way. +- **The viewer's words on stream.** An alert can show a message under its + words (**Viewer's message** in the editor, `{{alert.message}}` in a + design). Every ready-made design has a Message layer over its card, up + to three lines, there only when the alert brings one. +- **The Alerts page is your designs.** Overlay → Alerts is laid out like + Delay and Custom: your alert designs, then the ready-made ones, each + saying how many alerts play it. The chosen one plays the same sample + alert the Studio does, and beside it are **Edit in Studio**, **Use for + every alert** (alerts added later play it too), **Duplicate**, and + **Delete** (for a ready-made one you changed, **Reset**). Across the top + is the one browser source that plays every alert: in OBS or not, **Add + to OBS**, **Send a test**, and its address. With no alerts yet, **Set up + Twitch alerts** is right there. **Use for every alert** asks first when + some alerts have a design of their own. +- **An alert picks its design in Integrations.** An alert's "Show on + stream" step has a **Design** chip beside Accent, Sound, Picture and On + screen for: the designs as tiles, one click to pick (and **Use on every + alert here**); the builder's step has it too. What it says, its accent, + picture, sound and how long it stays are the alert's own, as before: its + accent recolours what the design paints in its accent. The editor's + live preview shows the design up close. +- **A design has its own sound**, in the Studio's Theme tab: a built-in + one or your own upload, heard with **Play**. Alerts whose sound is "The + design's sound" play it. The ready-made designs come with their theme's, + and the look from before keeps the sound it had. +- **The Studio knows what it's editing.** Only a delay overlay gets the + delay's states. An alert design gets **Play the alert**; a goal's overlay + gets -1, +1 and **Count up and down**, so its bar fills and its "+1" pops; + anything else says it looks the same whatever the delay does. The Add + list leads with what fits, and an alert's own parts (its picture, its + title, its words) are one click each. +- **New motions for every layer:** wipe on, shutter, grow from a dot, + stamp and boing, the themes' own ways in. Words can also decode from + scrambled letters or type themselves in. +- **Layers made for cards.** A Shape takes its own corners, a border and a + drop shadow. A picture can fill its box (a circle or a rounded square, + whatever size the GIF is), with corners and a border too. Words can keep + to a width, so a long name shrinks to fit the card instead of running + off it, or wrap onto up to three lines, and lose their shadow on a light + card. Any layer can have a name + of its own in the layer list. An alert design's layers don't offer the + delay's states: they wait hidden and show while an alert plays. An + alert with no picture moves the words into its place (each layer's + **With no picture, move left**), any layer can show only when the alert + has a message, and a text layer has the alert's title, words and + message one click away. +- **The Alert card layer draws the seven themes as whole cards**, the ones + alerts had before (the ready-made designs above are their look-alikes in + layers). One click in the Studio's Add list; each card is its own + build, with its own shape, font, sound, way in, life while up and way + out: + - **Island:** a black capsule like a phone's live island. It grows out + of a dot, keeps a small equalizer going, and shrinks back into it. + - **Signal:** an esports HUD. A scan line draws the panel, corner + brackets snap out, the words decode from scrambled letters, and it + glitches away. + - **Banner:** a broadcast lower third. It wipes on, with the words + coming up from under the line. + - **Bubble:** a sticker speech bubble. It boings out with confetti, + each word pops, and it bobs while up. + - **Minimal:** just type. A line draws, the words ink in letter by + letter, and the line is the time left. + - **Paper:** a scrapbook clipping on torn paper. It slaps down, the + tape sticks, the title stamps on, a marker highlights each word, and + it peels off. + - **Cinema:** a film's title card. It opens like a shutter, gold lines + draw out, the words fade up in an italic serif under a little film + grain, and it shutters closed. +- **A shared look becomes a design of your own.** Using the look of a + recipe with alerts makes it a design your alerts play; a recipe never + brings a design's name along. + +### Goals, timers, and everything on stream in one tab + +- **The Overlay tab holds everything viewers see, by what it shows.** Four + pages: **Delay** (the overlays that tell viewers you're on a delay, the + built-in ones and yours), **Alerts**, **Goals and timers**, and + **Custom** (anything else: a starting-soon screen, your socials, your own + HTML, and the .html files you drop in the overlays folder). Each overlay + is on one page: a goal's or a timer's own overlay is on its card, an + overlay stays on the page it was made on, and the Studio's menu moves one + between Delay and Custom. Alerts are still integrations (an alert can go + to chat or Discord with no overlay at all). +- **Laid out like the Studio.** The Delay and Custom pages list their + overlays on the left (yours before the built-in ones), the chosen one's + preview in the middle, and on the right **Add to OBS** (one click puts it + in the scene on air), **Edit in Studio**, its browser source link, and + duplicate and delete. The **Alerts** page is laid out the same way, with + alert designs (see "The Alerts page is your designs"). So is **Goals and + timers**: every goal and timer on the + left, each still showing its count and bar or its running clock, its own + overlay live in the middle (press +1 and watch the bar fill), and on the + right the big number with what moves it, **Add to OBS**, **Edit its + look** and its link. Every page's preview looks the same: the overlay at + its real size on a 1920 × 1080 canvas, over the same see-through + backdrop, under one bar saying what it plays (the delay state, a sample + alert, or live numbers) with its controls at the end. Each page fits on + one screen and grows with the window: the list shows more, and the + preview is the largest that fits. +- **Restore built-ins restores only those.** It used to delete every + overlay you had made, goals' and timers' too, to put the built-in ones + back. Now it resets the built-in delay overlays and brings back any you + deleted, and leaves everything else alone. +- **A goal's or a timer's overlay stays on its card.** Renaming it in the + Studio used to give it a new file, which its card no longer found; it + keeps its file now, and **Save as new** makes a copy of your own, on the + Custom page. +- **Goals.** A goal counts subs (new, resubs and gifted), follows, bits, or + only what you and your integrations add, toward a target. It can start + from 0 with every stream or keep counting (InstantClone restarting + mid-stream isn't a new stream). Set where it stands by hand + ("Road to 1,000" starting at 874), and nudge it with +1 and -1. +- **Timers, and subathons.** A timer counts down from a length you pick. + Subs, every 100 bits and follows can each add time (tier 2 and 3 subs add + 2 and 5 times as much, if you like), with an optional limit. Start, pause, + add or take a minute, and reset from the dashboard. A timer that ran out + stays over until you start it again; one that ran out while InstantClone + was closed ends quietly, instead of "ending" as it starts. +- **On stream in one click.** "Put it on stream" makes the goal's or the + timer's own overlay, then **Add to OBS** puts it in the scene on air, or + **Copy link** gives you the browser source address. The bar fills + smoothly, plays a small pop when the goal is reached, and a timer shows + the time someone just added beside the clock. +- **Every part is a layer.** That overlay is a normal one in the Studio, + and each part of it is its own layer to move, restyle or delete: a goal's + bar, its name and its numbers; a timer's name and its clock. Any overlay + can show them: the new **Goal bar** and **Timer** widgets, and any **Text** + layer saying `{{goal.subs}}`, `{{goal.subs.target}}`, `.left`, + `.percent`, `.name` or `{{timer.subathon}}` (the Studio lists them as + chips under the text, and a clock in words ticks too). The bar has its own + track colour, see-through and roundness; every layer has the per-state + colour, gradient, glow, opacity and animations, and text layers a weight, + spacing and one of 15 fonts. The Studio's **Theme** tab also takes CSS of + your own, last in the page, for anything the controls don't reach. + Alerts are designs made in the same Studio (see "Alerts you design"). +- **Overlays update in OBS as you save.** An overlay open in OBS (or in the + gallery's preview) reloads itself when the Studio saves it, so there is no + more refreshing the browser source by hand. +- **The Studio, redone in the dashboard's own style.** Drawn icons + throughout (layers, the Add tab, undo and redo, alignment), layers named + by what they show ("33 / 50", "Sub goal bar") instead of their raw text, + the Add tab as a grid of tiles, sliders with their value beside them, + labelled animation settings, and sections that fold away and stay folded. + The settings panel no longer jumps to the top when you tick something. +- **Same look in every delay state.** One switch on any layer: its colour, + gradient, glow and animations are set once instead of per delay state. + On by default for goal bars, timers and their words, which are on stream + whatever the delay does. +- **Gradients that move three ways.** Flow, Wave (back and forth) or Shine + (a light sweeping across), with their own speed and angle, and a glow + that follows the gradient's colours as they move. A gradient's motion is + no longer cut off by the widget's own animations, and theme colours + (@live and the rest) now work for a goal bar's track. +- **Goal and timer overlays that look finished.** Made on a dark glass card, + each part arriving in turn when the scene opens, with a shine passing + over the bar. A goal shows "+1" rising from the end of the bar as it + grows, and a timer's last minute turns orange with a beat each second. +- **Integrations can use them.** A new **Goal or timer** step adds to a goal + or drives a timer (the amount can come from chat, like `{arg1}`). New + triggers **A goal is reached** and **A timer runs out**. Messages can say + `{goal.subs}`, `{goal.subs.target}`, `.left` and `.percent`, and + `{timer.subathon}` as a clock. Tests never change your goals and timers. +- **Light on the stream PC.** A running timer is sent once, as the moment + it ends: the overlay counts down by itself, waking once a second, and an + overlay without a goal bar or timer does no extra work. Where each goal + and timer stands is saved with your other integration data, so a restart + mid-subathon loses nothing. +- **Leaner overlays.** A saved overlay is up to a third smaller: a layer + with one look writes one style rule instead of one per delay state, and + the script every overlay carries goes without its comments. Widgets no + longer each hold a layer of graphics memory for as long as OBS shows + them, and the buffer bar fills the way the goal bar does, by sliding. +- **Widgets, refreshed.** The graph meter has a soft fill under its line, + the divider fades out at its ends, the pulsing dot sends out a ring like + a live light, and a destination dot that drops beats in the theme's error + colour. A lower-third left blank says "ON A DELAY" instead of showing an + empty strip, and a new goal bar starts with the gradient and shine. +- **Studio fixes.** A widget's selection box no longer sits where its + entrance animation started, and a widget that has played its entrance + follows the mouse while you drag it. A new widget never lands exactly on + another one, a dark layer's icon stays readable in Layers, and the + settings panel shows what to do when nothing is selected. +- **An overlay file someone shares can't run code in the dashboard.** Its + theme colours and numbers (positions, timings) were put into the + Studio's settings as they were written, so a crafted file could break + out of them when opened. They're escaped now, as its names already were. + +### Twitch alerts and integrations + +- **Twitch alerts show what viewers wrote.** A resub shows the viewer's + message and a cheer what they wrote, without the Cheer100s (the new + `{words}`). Each alert is listed in the dock's Events too. Twitch alerts + added before this catch up by themselves, once. +- **Only from an amount, and bigger alerts for bigger ones.** A cheer, a + raid, a gift or a resub can run only from an amount up (**Only from**: + 500 bits, 10 viewers...). **Bigger ones**, on an alert in the editor, + plays another alert from an amount up, with its own design, sound and + words: up to three, each kept as an If step on the amount, so the + builder shows them as they are. Its preview and tests read that amount. +- **Two new catalog entries.** **Hype Twitch alerts** is Twitch alerts + with bigger ones built in: cheers from 1,000 and 10,000 bits, raids from + 50 viewers and gift bombs from 10 and 50 subs play the Banner or Cinema + design, louder and longer. It's meant instead of Twitch alerts: with + both on, the dashboard says each event would play twice. **Stream log in + the dock** lists your stream's own moments in the dock's Events, in + groups: the stream starting and ending, crashes and recoveries, + platforms dropping and coming back, delay changes, highlights and + chapters. A dock line's group is a chip in the editor. +- **An older Twitch login asks before a command fails.** A login from + before chat steps could moderate (timeouts, bans, shoutouts, + announcements) is asked to log in again as soon as Twitch says what it + allows: the Integrations tab and Connections have **Log in again**. +- **Chat steps run Twitch commands.** One that starts with + `/timeout {user} 10m`, `/ban`, `/unban`, `/shoutout`, `/announce` or + `/me` runs it. Twitch chat stopped running commands sent as messages, so + InstantClone asks Twitch's API instead. What a viewer types only fills it + in, never starts one, and a test only says what it would do. A ban or + timeout of anyone but the viewer themself (`{user}`) or a name written + into the step only saves on a command just mods can use, as a viewer + could pick who it is (`{target}`, `{arg1}`, a cheer's words, a reply); + one saved before this release stays off until it does. An anonymous + gift or cheer has no account to name. A Twitch login from before this + logs in again once, which the Integrations tab and Connections ask for. +- **A Twitch login that just ran out renews itself.** A chat command, + channel points or stream title step that Twitch refused because the + login ran out a moment ago (after the PC slept, say) gets a fresh one + and runs again, instead of asking you to log out and in. +- **A step that sets your stream's title and category.** "Stream title and + category" changes one, the other or both on Twitch, so mods can have + `!settitle` and `!setgame`, a channel points reward can rename the + stream, or a category can set itself when you go live. A category can be + typed loosely: "valo" finds VALORANT, and `{stream.category}` gives later + steps the name Twitch has for it. Your Twitch login already allows it. +- **A follow counts once a day per person.** Someone following, unfollowing + and following again set off the follow alert every time. Now the repeats + are ignored for 24 hours, and the end-of-stream report doesn't count them + either. +- **An integration holds up to 256 steps,** up from 64. A chat game with + every path spelled out (a slot machine with a pay table, streaks and a + jackpot) fills 64 by itself. +- **Folders for integrations.** Drag an integration onto a folder (folded + ones too), into another folder's group, onto "Not in a folder", or onto + the "new folder" place that opens while you drag, which makes one and + lets you name it. **New folder** makes an empty one to drag into, and + **Move to folder** in an integration's menu does the same without + dragging. The tab lists them by folder, A to Z, each with how many are + on; a folder folds (it remembers), renames in place (everything in it + moves along) and can be removed, which keeps what was in it. Moving, + folding and new folders slide and fade into place. Search finds a + folder's name too. Shared recipes leave folders behind. +- **The builder shows alerts as they look.** A "Show on stream" step has + the editor's live preview, design tiles, accent swatches, sounds with + Play and volume, pictures and On screen for, instead of typed names. Its + card says its design and sound, and an alert's bigger ones read as + "Below 1000 bits" and "From 1000 bits" instead of Then and Otherwise. +- **Adding an integration opens it**, to see it or finish it, with **Back + to the catalog** taking it away again. An "Added · Undo" never stays up + while you edit what it would take away. + +### The OBS tab and the dock + +- **Events in the dock.** A new **Events** widget (and an Events preset) + lists what your integrations put there, newest first, with the time: + show one group at a time (Follows, Subs, Bits, Raids...), **Replay** an + alert on stream, or **Skip alert** to end the one on now, its sound + too, and bring on the next. Twitch alerts list every follow, sub, cheer + and raid; a new **Dock event** step lists anything else, in a group you + name. The list lasts until InstantClone restarts. +- **The dock's destinations show their platform.** Each row has its + platform's mark (Twitch, YouTube, Kick...) with the state's dot on its + corner, and the icon view shows the marks instead of letters. +- **AV1 and HEVC from OBS.** The InstantClone service in OBS took H.264 + only, so OBS switched an AV1 encoder back to H.264. It now offers HEVC and + AV1 too, and an entry from before updates itself when OBS closes. YouTube + plays both and Restream plays HEVC; a destination that can't play what OBS + sends (Kick, or Twitch without Enhanced Broadcasting) is named in the + warning, with what to change. +- **An overlay that isn't there only says so.** Its "not found" named the + overlays folder, and with it your Windows user name, to any page that + asked. + +### Look and feel + +- **A compact delay panel.** The button beside Help in the top bar folds + the delay panel into one row, for when you're working on overlays or + integrations and want the room. What runs the stream stays in that row: + its state, the delay (with the arming bar along its edge), the delay + field, your profiles and the main button, with Disarm and Cut after + sliding in beside it while a delay runs (in the full panel too), and the + main button keeps one width so a + state change never slides the row. Folding and unfolding is one motion: + every part of the panel slides to its new place and really resizes on the + way (the delay field, the main button, the delay figure's text), together + with the panel's height, so nothing stretches, blurs or jumps. The + dashboard remembers which size you left it in. Every tab's card now + reaches the same bottom edge, so switching tabs never moves it. +- **The dashboard stays smooth next to OBS and a game.** It counts its own + frames, and while the browser can't keep 30 a second its looping + animations (pulsing dots, sweeps, the light behind the delay) rest; they + come back when frames are fast again. That light now moves in small + steps instead of asking for a frame at every screen refresh. And with + many integrations the page is much cheaper to draw: each card's status + badge carried a blur that showed nothing, and the Integrations tab was + redrawn every two seconds whether anything had changed or not. + ## [0.1.16] - Delays up to 1 hour, Twitch events and alerts, smart lights, chat games and points, and two InstantClones side by side ### Delays up to 1 hour diff --git a/Cargo.lock b/Cargo.lock index 869194d..51dd821 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -219,7 +219,7 @@ dependencies = [ [[package]] name = "instantclone" -version = "0.1.16" +version = "0.1.17" dependencies = [ "bytes", "flate2", diff --git a/Cargo.toml b/Cargo.toml index de31242..c752937 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "instantclone" -version = "0.1.16" +version = "0.1.17" edition = "2021" authors = ["s1moscs"] license = "GPL-3.0-only" diff --git a/src/compat.rs b/src/compat.rs index e8e14aa..fdfb259 100644 --- a/src/compat.rs +++ b/src/compat.rs @@ -26,6 +26,8 @@ pub struct StreamParams { /// Mean IDR spacing in ms, 0 until enough keyframes have been seen. pub keyframe_interval_ms: u32, pub codec: VideoCodec, + /// Enhanced Broadcasting is on: Twitch picks its own tracks' codec. + pub multitrack: bool, } /// Warn above this measured IDR spacing. Every ingest we support asks for @@ -127,25 +129,35 @@ fn resolution_issue(params: &StreamParams, live: &[&Destination]) -> Option Option { - if !matches!( - params.codec, - VideoCodec::Hevc | VideoCodec::Av1 | VideoCodec::Vp9 - ) { + if matches!(params.codec, VideoCodec::Avc | VideoCodec::Unknown) { return None; } - if !live.iter().any(|d| d.platform == "kick") { - return None; + let mut names: Vec<&'static str> = Vec::new(); + for d in live { + let negotiated = d.platform == "twitch" && params.multitrack; + let label = platform_label(&d.platform); + if d.platform != "custom" + && !negotiated + && !d.plays(params.codec) + && !names.contains(&label) + { + names.push(label); + } } + let verb = match names.len() { + 0 => return None, + 1 => "needs", + _ => "need", + }; Some(format!( - "Video codec is {}. Kick needs H.264. {CODEC_FIX}", - params.codec.label() + "Video codec is {}. {} {verb} H.264. {CODEC_FIX}", + params.codec.label(), + join_names(&names) )) } @@ -217,6 +229,7 @@ mod tests { height: 1080, keyframe_interval_ms: 2_000, codec: VideoCodec::Avc, + multitrack: false, }; change(&mut params); params @@ -235,7 +248,7 @@ mod tests { #[test] fn stays_silent_when_nothing_is_wrong_or_nobody_minds() { let kick = [dest("kick", true)]; - let cases: [(&str, StreamParams, &[Destination]); 13] = [ + let cases: [(&str, StreamParams, &[Destination]); 16] = [ ( "a healthy stream", healthy(), @@ -301,6 +314,21 @@ mod tests { stream(|p| p.codec = VideoCodec::Unknown), &kick, ), + ( + "AV1 to YouTube only", + stream(|p| p.codec = VideoCodec::Av1), + &[dest("youtube", true)], + ), + ( + "HEVC that Twitch picked under Enhanced Broadcasting", + stream(|p| (p.codec, p.multitrack) = (VideoCodec::Hevc, true)), + &[dest("twitch", true)], + ), + ( + "AV1 to a custom server", + stream(|p| p.codec = VideoCodec::Av1), + &[dest("custom", true)], + ), ]; for (case, params, dests) in cases { assert_eq!(compat_warning(¶ms, dests), None, "{case}"); @@ -370,6 +398,24 @@ mod tests { } } + /// AV1 from OBS reaches only the platforms that play it: the rest are + /// named, Twitch too when it isn't choosing through Enhanced Broadcasting. + #[test] + fn a_codec_some_platforms_cannot_play_names_them() { + let av1 = stream(|p| p.codec = VideoCodec::Av1); + let dests = [ + dest("youtube", true), + dest("twitch", true), + dest("kick", true), + ]; + assert_eq!( + compat_warning(&av1, &dests), + Some(format!( + "Video codec is AV1. Twitch and Kick need H.264. {CODEC_FIX}" + )) + ); + } + /// Multiple problems collapse into ONE line - the clutter guard. #[test] fn multiple_issues_join_into_a_single_line() { diff --git a/src/config.rs b/src/config.rs index 09fac2c..067dce5 100644 --- a/src/config.rs +++ b/src/config.rs @@ -178,6 +178,9 @@ pub struct Settings { pub phone: PhoneConnection, /// Where the alerts page puts its cards and how big they are. pub alerts_look: crate::integrations::alerts::Look, + /// Goals and timers overlays draw (see `crate::integrations::goals`). + pub goals: Vec, + pub timers: Vec, /// Whether the old single `discord_webhook_url` has been turned into a /// Discord connection plus a "Stream alerts" integration. False only in /// configs written before integrations existed. @@ -803,6 +806,14 @@ impl Settings { let look = self.alerts_look.to_json().to_json(); writeln!(f, "alerts.look={}", one_line(&look))?; } + for g in &self.goals { + let kept = g.to_json().to_json(); + writeln!(f, "goal.{}={}", one_line(&g.id), one_line(&kept))?; + } + for t in &self.timers { + let kept = t.to_json().to_json(); + writeln!(f, "timer.{}={}", one_line(&t.id), one_line(&kept))?; + } for v in &self.user_values { let kept = json::obj([ ("label", json::str(&v.label)), @@ -831,6 +842,30 @@ impl Settings { /// whole config: the other integrations and settings still load. /// One `integration_value.=` line. A broken or repeated /// one is skipped, like a broken integration. + /// A `goal.=` line. One that doesn't read, or repeats an + /// id, is left out. + fn load_goal(&mut self, value: &str) { + use crate::integrations::goals::{Goal, MAX_GOALS}; + let read = json::parse(value).ok().and_then(|v| Goal::from_json(&v)); + let Some(goal) = read else { + return; + }; + if self.goals.len() < MAX_GOALS && !self.goals.iter().any(|g| g.id == goal.id) { + self.goals.push(goal); + } + } + + fn load_timer(&mut self, value: &str) { + use crate::integrations::goals::{Timer, MAX_TIMERS}; + let read = json::parse(value).ok().and_then(|v| Timer::from_json(&v)); + let Some(timer) = read else { + return; + }; + if self.timers.len() < MAX_TIMERS && !self.timers.iter().any(|t| t.id == timer.id) { + self.timers.push(timer); + } + } + fn load_user_value(&mut self, name: &str, value: &str) { use crate::integrations::model::{UserValue, MAX_USER_VALUES}; let Ok(mut parsed) = json::parse(value) else { @@ -1000,6 +1035,8 @@ impl Settings { discord_channels: Vec::new(), phone: PhoneConnection::default(), alerts_look: Default::default(), + goals: Vec::new(), + timers: Vec::new(), // A fresh install has no old webhook to migrate; `load` sets // this after its one-time pass either way. integrations_migrated: false, @@ -1057,9 +1094,12 @@ impl Settings { } s.migrate_discord_webhook(); // `!setdelay` copies from up to 0.1.15 (a 10 minute limit, and - // "Delay set to 0s"). The file catches up on the next save. + // "Delay set to 0s"), and Twitch alerts from before 0.1.17 (the + // viewer's words, the dock's events). The file catches up on the + // next save. for integration in &mut s.integrations { crate::integrations::presets::upgrade_setdelay(integration); + crate::integrations::presets::upgrade_twitch_alerts(integration); } // Clamp / sanitize on load - hand-edited values can otherwise // hit divide-by-zero (buffer_mb=0 → `% capacity` in DiskRing) or @@ -1520,6 +1560,8 @@ impl Settings { self.alerts_look = crate::integrations::alerts::Look::from_json(&v); } } + k if k.starts_with("goal.") => self.load_goal(value), + k if k.starts_with("timer.") => self.load_timer(value), "connection.phone" => { if let Ok(v) = json::parse(value) { self.phone = PhoneConnection { @@ -2500,7 +2542,7 @@ pub fn parse_form(body: &str) -> std::collections::HashMap { out } -fn url_decode(s: &str) -> String { +pub(crate) fn url_decode(s: &str) -> String { let bytes = s.as_bytes(); let mut out = Vec::with_capacity(bytes.len()); let mut i = 0; @@ -4040,8 +4082,26 @@ Name@x" radius: Some(0), sound: "media:ding.mp3".into(), css: ".card .text { letter-spacing: 1px }".into(), + design: "my-alerts".into(), ..Default::default() }, + goals: vec![crate::integrations::goals::Goal { + id: "subs".into(), + name: "Sub goal = 50".into(), + counts: crate::integrations::goals::Counts::Subs, + target: 50, + each_stream: true, + }], + timers: vec![crate::integrations::goals::Timer { + id: "subathon".into(), + name: "Subathon".into(), + start_s: 3600, + per_sub_s: 300, + per_100_bits_s: 60, + per_follow_s: 0, + tiers: false, + max_s: 86400, + }], integrations_migrated: true, twitch_client_id: "abcdefghij0123456789klmnopqrst".into(), twitch_shared_chat: true, diff --git a/src/controller.rs b/src/controller.rs index 58b7549..c1a29b1 100644 --- a/src/controller.rs +++ b/src/controller.rs @@ -1192,6 +1192,7 @@ impl Controller { height: dims as u32, keyframe_interval_ms: self.keyframe_interval_ms(), codec: self.video_codec(), + multitrack: self.multitrack_video(), } } diff --git a/src/integrations/alerts.rs b/src/integrations/alerts.rs index a705a19..ab5515e 100644 --- a/src/integrations/alerts.rs +++ b/src/integrations/alerts.rs @@ -72,6 +72,8 @@ pub const ANIMATIONS: &[&str] = &["slide", "pop", "fade", "zoom", "bounce", "dro pub struct Card { pub title: String, pub text: String, + /// The viewer's own words (a cheer's, a resub's), or empty. + pub message: String, pub seconds: u64, /// `#rrggbb`, or empty for the theme's accent. pub color: String, @@ -82,6 +84,9 @@ pub struct Card { pub volume: u8, /// An upload (`media:...`) or a web link to a picture or GIF, or empty. pub image: String, + /// The alert design (an overlay made in the Studio) that shows it, by + /// its file name; empty for the alerts page's theme. + pub design: String, } impl Card { @@ -90,11 +95,13 @@ impl Card { Card { title: title.to_string(), text: text.to_string(), + message: String::new(), seconds, color: String::new(), sound: String::new(), volume: DEFAULT_VOLUME, image: String::new(), + design: String::new(), } } @@ -104,6 +111,7 @@ impl Card { Card { title: one_line(&self.title, MAX_TITLE), text: one_line(&self.text, MAX_TEXT), + message: one_line(&self.message, MAX_TEXT), seconds: self.seconds.clamp(MIN_SECONDS, MAX_SECONDS), color: color_or_empty(&self.color), sound: if is_sound(&self.sound) { @@ -117,6 +125,11 @@ impl Card { } else { String::new() }, + design: if is_design(&self.design) { + self.design + } else { + String::new() + }, } } } @@ -142,6 +155,23 @@ pub fn page_html() -> String { } /// `#rrggbb`. +/// An overlay's file name, as the alerts page asks for it: letters, +/// numbers, `-` and `_` only. +pub fn is_design(text: &str) -> bool { + !text.is_empty() + && text.len() <= 64 + && text + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_') +} + +/// One of the ready-made designs (`alert-island`...), which every install +/// can make from its theme, unlike one a streamer made. +pub fn is_ready_design(name: &str) -> bool { + name.strip_prefix("alert-") + .is_some_and(|theme| THEMES.contains(&theme)) +} + pub fn is_color(text: &str) -> bool { text.len() == 7 && text.starts_with('#') && text[1..].bytes().all(|b| b.is_ascii_hexdigit()) } @@ -209,11 +239,15 @@ pub struct Look { pub animation: String, /// Where an alert's picture goes: `left`, `top`, or empty. pub picture: String, - /// The sound alerts set to "Theme sound" play: one of `SOUNDS`, an - /// upload, a link, or empty for the theme's own. + /// The sound alerts set to "The design's sound" play when their design + /// has no theme of its own: one of `SOUNDS`, an upload, a link, or + /// empty for the theme's own. pub sound: String, /// Extra CSS, put in the page as it is. pub css: String, + /// The design (an overlay made in the Studio) an alert that names none + /// plays, by its file name; empty for the theme's card. + pub design: String, } impl Default for Look { @@ -233,6 +267,7 @@ impl Default for Look { picture: String::new(), sound: String::new(), css: String::new(), + design: String::new(), } } } @@ -269,13 +304,17 @@ impl Look { radius: number("radius", 48.0).map(|n| n as u8), animation: pick("animation", ANIMATIONS, d.animation), picture: pick("picture", &["left", "top"], d.picture), - // "Theme sound" here would point back at itself. + // "The design's sound" here would point back at itself. sound: if sound != THEME_SOUND && is_sound(sound) { sound.to_string() } else { String::new() }, css: v.str_or("css", "").chars().take(MAX_CSS).collect(), + design: Some(v.str_or("design", "")) + .filter(|d| is_design(d)) + .unwrap_or_default() + .to_string(), } } @@ -296,6 +335,7 @@ impl Look { ("picture", json::str(&self.picture)), ("sound", json::str(&self.sound)), ("css", json::str(&self.css)), + ("design", json::str(&self.design)), ]) } } @@ -319,6 +359,8 @@ pub struct Board { struct Inner { last_id: u64, alerts: VecDeque, + /// Bumped by the dock's Skip: the page ends the alert it is playing. + skips: u64, } /// Counts one open alerts page for as long as it lives. @@ -362,6 +404,11 @@ impl Board { inner.alerts.push_back(alert); } + /// End the alert the pages are playing now (the next one comes on). + pub fn skip(&self) { + self.inner.lock().skips += 1; + } + /// An alerts page connected; it counts until the guard is dropped. pub fn page_opened(&self, in_obs: bool) -> PageOpen<'_> { let mut pages = self.pages.lock(); @@ -393,6 +440,7 @@ impl Board { ("page", json::str(page_id())), ("look", look.to_json()), ("alerts", alerts), + ("skips", Value::Num(inner.skips as f64)), ]) .to_json() } @@ -404,15 +452,19 @@ fn alert_json(a: &Alert) -> Value { ("id", Value::Num(a.id as f64)), ("title", json::str(&c.title)), ("text", json::str(&c.text)), + ("message", json::str(&c.message)), ("ms", Value::Num((c.seconds * 1000) as f64)), ("color", json::str(&c.color)), ("sound", json::str(&c.sound)), ("volume", Value::Num(f64::from(c.volume))), ("image", json::str(&c.image)), + ("design", json::str(&c.design)), ]) } -fn one_line(text: &str, max: usize) -> String { +/// `text` on one line (control characters as spaces), at most `max` +/// characters, trimmed. +pub(super) fn one_line(text: &str, max: usize) -> String { let flat: String = text .chars() .map(|c| if c.is_control() { ' ' } else { c }) @@ -469,17 +521,21 @@ mod tests { good.color = "#FF00AA".to_string(); good.sound = "media:ding.mp3".to_string(); good.image = "media:party.gif".to_string(); + good.design = "follow_alert-2".to_string(); board.show(good); let mut bad = Card::plain("", "Ana", 6); bad.color = "red;background:url(x)".to_string(); bad.sound = "javascript:alert(1)".to_string(); bad.image = "https://x.example/a.gif\" onerror=\"x".to_string(); bad.volume = 250; + bad.design = "../dashboard".to_string(); board.show(bad); let list = alerts_of(&board); assert_eq!(list[0].str_or("color", ""), "#ff00aa"); assert_eq!(list[0].str_or("sound", ""), "media:ding.mp3"); assert_eq!(list[0].str_or("image", ""), "media:party.gif"); + assert_eq!(list[0].str_or("design", ""), "follow_alert-2"); + assert_eq!(list[1].str_or("design", "x"), ""); assert_eq!( (list[1].str_or("color", "x"), list[1].str_or("sound", "x")), ("", "") diff --git a/src/integrations/api.rs b/src/integrations/api.rs index 3c2bab5..fe16d95 100644 --- a/src/integrations/api.rs +++ b/src/integrations/api.rs @@ -55,6 +55,40 @@ pub fn new_id() -> String { format!("i{}", &crate::crypto::random_token()[..12]) } +/// The OBS dock's events list: read it, play an event's alert again, or end +/// the alert on stream now. A dock reaches these with its own token (web.rs +/// `classify_access`): none changes a setting. +pub fn dock(method: &str, path: &str, body: &str, ctrl: &Arc) -> Option { + let handle = ctrl.integrations()?; + let reply = match (method, path) { + ("GET", "/dock/events") => ok(handle.feed.to_json()), + ("POST", "/dock/events/replay") => { + match form_id(body).and_then(|id| handle.feed.card(id)) { + Some(card) => { + handle.alerts.show(card); + done() + } + None => fail("that event has no alert to play again"), + } + } + ("POST", "/dock/alerts/skip") => { + handle.alerts.skip(); + done() + } + _ => return None, + }; + Some(reply) +} + +/// `id=7` from a dock's form body. +fn form_id(body: &str) -> Option { + body.split('&') + .find_map(|pair| pair.strip_prefix("id="))? + .trim() + .parse() + .ok() +} + /// Handle `/integrations*`, `/connections/*`, `/twitch/*`. `None` when the /// path is not ours. /// @@ -87,10 +121,12 @@ pub async fn route( ("twitch", handle.twitch.status_json()), ("obs", handle.obs_status()), ("alerts", alerts_json(&handle, &settings.borrow(), cfg_path)), + ("tracking", handle.goals.to_json()), ])), ("POST", "/integrations/save") => save(&req(), settings, cfg_path, local_steps_allowed), ("POST", "/integrations/add") => add(&req(), settings, cfg_path, local_steps_allowed), ("POST", "/integrations/toggle") => toggle(&req(), settings, cfg_path, local_steps_allowed), + ("POST", "/integrations/folder") => move_to_folder(&req(), settings, cfg_path), ("POST", "/integrations/delete") => { let id = req().str_or("id", "").to_string(); update(settings, cfg_path, |s| { @@ -108,6 +144,12 @@ pub async fn route( Ok(()) => ok(handle.viewers_json()), Err(why) => fail(why), }, + // Goals and timers: what they are, and moving one by hand. + ("POST", "/integrations/goals") => save_goals(&req(), &handle, settings, cfg_path), + ("POST", "/integrations/goals/change") => match change_goal(&req(), &handle) { + Ok(()) => goals_reply(&handle), + Err(why) => fail(why), + }, // The live meter of a chat activity trigger being tuned. ("POST", "/integrations/meter") => match req().get("trigger").map(meter_rules) { Some(Ok(rules)) => ok(handle.chat_meter(&rules)), @@ -151,6 +193,35 @@ pub async fn route( Err(e) => fail(e), } } + // An overlay from the overlays folder (hand-written ones too) as a + // browser source in the scene on air. Only ever one of InstantClone's + // own pages: its name goes in the address escaped. + ("POST", "/integrations/overlays/add-to-obs") => { + let asked = req(); + let slug = asked.str_or("slug", ""); + let odd = |c: char| c.is_control() || matches!(c, '/' | '\\' | ':'); + if slug.is_empty() || slug.len() > 128 || slug.contains("..") || slug.chars().any(odd) { + return Some(fail("that isn't one of your overlays")); + } + let label: String = asked + .str_or("name", slug) + .chars() + .filter(|c| !c.is_control()) + .take(60) + .collect(); + let port = settings.borrow().web_port; + let action = obsws::Action::BrowserSource { + name: format!("InstantClone {}", label.trim()), + url: format!( + "http://127.0.0.1:{port}/overlay/{}.html", + super::twitch::auth::url_encode(slug) + ), + }; + match handle.obs_now(action).await { + Ok(()) => done(), + Err(e) => fail(e), + } + } ("POST", "/integrations/midi/learn") => midi_learn(ctrl), ("POST", "/integrations/midi/poll") => { let captured = ctrl.midi().take_captured_for(&[MIDI_LEARN]); @@ -341,6 +412,7 @@ fn test_card(req: &Value) -> alerts::Card { alerts::Card { title: req.str_or("title", "").to_string(), text: text.to_string(), + message: req.str_or("message", "").trim().to_string(), seconds: req.str_or("seconds", "6").trim().parse().unwrap_or(6), color: req.str_or("color", "").trim().to_string(), sound: req.str_or("sound", "").trim().to_string(), @@ -350,6 +422,7 @@ fn test_card(req: &Value) -> alerts::Card { .parse() .unwrap_or(alerts::DEFAULT_VOLUME), image: req.str_or("image", "").trim().to_string(), + design: req.str_or("design", "").trim().to_string(), } } @@ -443,6 +516,7 @@ fn overview( ("twitch", handle.twitch.status_json()), ("obs", handle.obs_status()), ("alerts", alerts_json(handle, s, cfg_path)), + ("tracking", handle.goals.to_json()), // For the "Report it" links, which fill in the issue form's version. ("version", json::str(env!("CARGO_PKG_VERSION"))), ("twitch_shared_chat", Value::Bool(s.twitch_shared_chat)), @@ -610,7 +684,8 @@ pub fn unknown_vars(integration: &Integration) -> Vec { .collect(); // What is kept between runs is read by name; nothing lists the names. let mut prefixes: Vec = [ - "counter.", "clip.", "viewer.", "target.", "top.", "saved.", "my.", + "counter.", "clip.", "stream.", "viewer.", "target.", "top.", "saved.", "my.", "goal.", + "timer.", ] .map(String::from) .to_vec(); @@ -913,6 +988,41 @@ fn toggle( }) } +/// Put integrations (`ids`) in a folder (`folder`, empty for none), in one +/// save: renaming a folder moves everything in it at once. Nothing else +/// about them changes, so nothing is checked again. +fn move_to_folder(req: &Value, settings: &Arc>, cfg_path: &Path) -> Reply { + let ids: Vec<&str> = req + .get("ids") + .and_then(Value::as_array) + .unwrap_or(&[]) + .iter() + .filter_map(Value::as_str) + .collect(); + let folder = super::model::folder_name(req.str_or("folder", "")); + if folder.chars().count() > super::model::MAX_FOLDER_LEN { + return fail(format!( + "a folder's name is {} characters at most", + super::model::MAX_FOLDER_LEN + )); + } + update(settings, cfg_path, |s| { + let mut moved = 0; + for i in s + .integrations + .iter_mut() + .filter(|i| ids.contains(&i.id.as_str())) + { + i.folder = folder.clone(); + moved += 1; + } + if moved == 0 { + return Err("those integrations no longer exist".to_string()); + } + Ok(()) + }) +} + fn duplicate(req: &Value, settings: &Arc>, cfg_path: &Path) -> Reply { let id = req.str_or("id", "").to_string(); let copy_id = new_id(); @@ -1391,6 +1501,67 @@ fn shared_values_review(parsed: &recipe::Recipe, s: &Settings) -> Value { Value::Arr(parsed.values.iter().map(review).collect()) } +/// Where every goal and timer stands, as an answer. +fn goals_reply(handle: &super::Handle) -> Reply { + ok(json::obj([ + ("ok", Value::Bool(true)), + ("tracking", handle.goals.to_json()), + ])) +} + +/// Replace the goals and timers with the lists the dashboard sent. +fn save_goals( + req: &Value, + handle: &super::Handle, + settings: &Arc>, + cfg_path: &Path, +) -> Reply { + let (goals, timers) = match super::goals::parse_setup(req) { + Ok(setup) => setup, + Err(why) => return fail(why), + }; + let saved = update(settings, cfg_path, |s| { + s.goals = goals.clone(); + s.timers = timers.clone(); + Ok(()) + }); + if saved.0 != "200 OK" { + return saved; + } + // The engine hears of it a moment later; the answer shows it now. + handle.goals.configure(&goals, &timers); + goals_reply(handle) +} + +/// The dashboard moving a goal or driving a timer by hand (`what`, `id`, +/// `action`, and an `amount`: a count, or seconds for a timer). +fn change_goal(req: &Value, handle: &super::Handle) -> Result<(), String> { + use super::goals::{GoalChange, TimerChange}; + let id = req.str_or("id", ""); + let amount = req.get("amount").and_then(Value::as_f64).unwrap_or(0.0) as i64; + let exact = amount.max(0).unsigned_abs(); + let unknown = || "that isn't something a goal or timer does".to_string(); + if req.str_or("what", "") == "timer" { + let change = match req.str_or("action", "") { + "start" => TimerChange::Start, + "pause" => TimerChange::Pause, + "reset" => TimerChange::Reset, + "add" => TimerChange::Add(amount), + "set" => TimerChange::Set(exact), + _ => return Err(unknown()), + }; + let now = super::twitch::unix_ms(); + return handle.goals.change_timer(id, change, now).map(drop); + } + let change = match req.str_or("action", "") { + "add" => GoalChange::Add(amount), + "set" => GoalChange::Set(exact), + "reset" => GoalChange::Reset, + _ => return Err(unknown()), + }; + handle.goals.change_goal(id, change).map(drop) +} + /// Replace the streamer's own values with the list the dashboard sent. fn save_user_values( req: &Value, @@ -1546,6 +1717,7 @@ fn effect_review(i: &Integration) -> Vec { StepKind::Chat => add("info", "Writes in your Twitch chat"), StepKind::Phone => add("info", "Sends a push to your phone"), StepKind::Overlay => add("info", "Shows an alert on stream"), + StepKind::Dock => add("info", "Lists events in your OBS dock"), _ => {} }); } @@ -2164,4 +2336,42 @@ mod tests { assert_eq!(order[2], "b"); let _ = std::fs::remove_dir_all(cfg.parent().unwrap()); } + + /// Moving to a folder moves only the ones named, in one save, and + /// changes nothing else about them. + #[test] + fn integrations_move_to_a_folder_together() { + let (settings, cfg) = scratch("folder"); + for id in ["a", "b", "c"] { + let i = presets::build("delay_notice", id.into(), "").unwrap(); + assert_eq!(save(&i.to_json(), &settings, &cfg, true).0, "200 OK"); + } + let before = stored(&settings, "a").unwrap(); + let move_them = |ids: &[&str], folder: &str| { + let ids = Value::Arr(ids.iter().map(|id| json::str(*id)).collect()); + move_to_folder( + &json::obj([("ids", ids), ("folder", json::str(folder))]), + &settings, + &cfg, + ) + }; + assert_eq!(move_them(&["a", "b"], " Chat\n").0, "200 OK"); + let folders: Vec = settings + .borrow() + .integrations + .iter() + .map(|i| i.folder.clone()) + .collect(); + assert_eq!(folders, ["Chat", "Chat", ""]); + assert_eq!( + stored(&settings, "a").unwrap(), + Integration { + folder: "Chat".into(), + ..before + } + ); + assert_ne!(move_them(&["gone"], "Chat").0, "200 OK"); + assert_ne!(move_them(&["c"], &"x".repeat(41)).0, "200 OK"); + let _ = std::fs::remove_dir_all(cfg.parent().unwrap()); + } } diff --git a/src/integrations/effects.rs b/src/integrations/effects.rs index 6db58f1..9078270 100644 --- a/src/integrations/effects.rs +++ b/src/integrations/effects.rs @@ -215,6 +215,7 @@ pub struct RealHost { pub default_delay_ms: AtomicU32, pub programs: crate::sync::Mutex>, pub alerts: Arc, + pub feed: Arc, /// When this stream started (Unix ms), 0 between streams. The engine /// keeps it, from the stream's own events. pub stream_started_ms: AtomicU64, @@ -306,10 +307,22 @@ impl Host for RealHost { self.twitch.send(text, as_bot, reply_to) } + fn chat_command(&self, command: &super::host::ChatCommand) -> Result<(), String> { + self.twitch.run_command(command) + } + fn marker(&self, description: &str) -> Result<(), String> { self.twitch.marker(description) } + fn channel( + &self, + title: Option<&str>, + category: Option<&str>, + ) -> Result, String> { + self.twitch.set_channel(title, category) + } + fn reward(&self, title: &str, enabled: bool) -> Result<(), String> { self.twitch.set_reward_named(title, enabled) } @@ -413,6 +426,10 @@ impl Host for RealHost { self.alerts.show(card); } + fn dock_event(&self, group: &str, text: &str, card: Option) { + self.feed.add(group, text, card); + } + fn obs(&self, action: super::obsws::Action) -> Result { let (reply, outcome) = std::sync::mpsc::channel(); let wait = super::obsws::CALL_TIMEOUT + Duration::from_secs(1); diff --git a/src/integrations/engine.rs b/src/integrations/engine.rs index 8992c4d..60387cd 100644 --- a/src/integrations/engine.rs +++ b/src/integrations/engine.rs @@ -117,6 +117,10 @@ const RECENT_KEEP: usize = 12; /// accepts, then refuses) would otherwise alert on every round. Each /// destination gets at most one "live" and one "dropped" per window. const FLAP_WINDOW: Duration = Duration::from_secs(300); +/// Someone who unfollows and follows again, over and over, would otherwise +/// set off the follow alert each time. A follow from the same person counts +/// once in this long. +const FOLLOW_AGAIN: Duration = Duration::from_secs(24 * 3600); /// A chat activity trigger stays quiet this long after a stream starts: /// the first seconds of chat ("hi!", "hello") are a burst by nature. pub const ACTIVITY_WARMUP: Duration = Duration::from_secs(30); @@ -226,6 +230,10 @@ pub struct Handle { pub twitch: Arc, /// What "Show on stream" steps put on the alerts browser source. pub alerts: Arc, + /// The OBS dock's events list. + pub feed: Arc, + /// Goals and timers: where each stands, and what moves them. + pub goals: Arc, store: Arc, /// Integration hotkeys Windows wouldn't register (another app holds /// them, or there are too many), for the dashboard to point out. @@ -583,6 +591,7 @@ pub fn start( &settings.borrow().twitch_client_id, )); let alerts = Arc::new(Board::new()); + let feed = Arc::new(super::feed::Feed::new()); let obs = Arc::new(Mutex::new(obsws::Status::default())); let (obs_tx, obs_commands) = mpsc::channel(16); let handle = Arc::new(Handle { @@ -591,6 +600,8 @@ pub fn start( stats: Mutex::new(HashMap::new()), twitch: twitch.clone(), alerts: alerts.clone(), + feed: feed.clone(), + goals: store.goals.clone(), store: store.clone(), refused_keys: Mutex::new(Vec::new()), dropped: AtomicU64::new(0), @@ -623,6 +634,7 @@ pub fn start( default_delay_ms: Default::default(), programs: Default::default(), alerts, + feed, stream_started_ms: AtomicU64::new(0), onair: Mutex::new("off"), obs: obs_tx, @@ -680,6 +692,7 @@ struct Engine { /// The delay's phase and armed delay at the last tick: see `watch_phase`. phase_seen: Option<(&'static str, u32)>, flap: FlapGuard, + follows: FollowGuard, discord_messages: DiscordMessages, discord_turns: Arc, /// `{uses}` changed since the data file was last written. @@ -766,6 +779,7 @@ impl Engine { delay_seen: None, phase_seen: None, flap: FlapGuard::default(), + follows: FollowGuard::default(), discord_messages, discord_turns, uses_dirty: false, @@ -822,6 +836,7 @@ impl Engine { self.watch_delay(); self.watch_onair(); self.watch_session(); + self.watch_goals(); self.watch_destinations(); self.update_activity_gate(); self.settle_scenes(); @@ -839,7 +854,11 @@ impl Engine { } fn apply_settings(&mut self, s: &Settings) { - self.integrations = s.integrations.iter().cloned().map(Arc::new).collect(); + self.integrations = s + .integrations + .iter() + .map(|i| Arc::new(i.runnable())) + .collect(); self.env = Arc::new(make_env( (s.discord_channels.clone(), s.user_values.clone()), s.phone.clone(), @@ -852,6 +871,7 @@ impl Engine { .store(s.auto_arm_delay_ms.max(1000), Ordering::Relaxed); self.host.twitch.use_client_id(&s.twitch_client_id); self.shared_chat = s.twitch_shared_chat; + self.handle.goals.configure(&s.goals, &s.timers); // Forget per-trigger state for integrations that are gone. let alive: Vec<&str> = self.integrations.iter().map(|i| i.id.as_str()).collect(); let keep = |key: &String| alive.iter().any(|id| key.starts_with(&format!("{id}#"))); @@ -896,6 +916,7 @@ impl Engine { self.chat_room.prune(Instant::now()); self.last_run.retain(|_, t| t.elapsed() < longest_run); self.flap.prune(Instant::now()); + self.follows.prune(Instant::now()); // Here rather than on every settings change: a delete undone within // the minute keeps its count and its Discord message. let alive: Vec<&str> = self.integrations.iter().map(|i| i.id.as_str()).collect(); @@ -945,6 +966,11 @@ impl Engine { } fn event(&mut self, event: Event) { + // Dropped before the session counts it: a repeat is no new follower. + if self.follows.repeated(&event, Instant::now()) { + return; + } + self.handle.goals.observe(&event, unix_ms()); // The session sees every event, flapping or not: smart destination // alerts are built on the drops the flap guard holds back. let made = self.observe(&event); @@ -1015,6 +1041,7 @@ impl Engine { match e.kind { EventKind::StreamStarted => { self.timeline.lock().clear(); + self.handle.goals.stream_started(unix_ms()); self.still_down_fired.clear(); self.steady_fired.clear(); self.drops_seen.clear(); @@ -1027,7 +1054,10 @@ impl Engine { self.scene_entered(&scene, "", true); } } - EventKind::StreamEnded => self.host.stream_started_ms.store(0, Ordering::Relaxed), + EventKind::StreamEnded => { + self.host.stream_started_ms.store(0, Ordering::Relaxed); + self.handle.goals.stream_ended(); + } _ => {} } } @@ -1047,6 +1077,18 @@ impl Engine { } } + /// Each tick: a goal reached or a timer run out is an event, whatever + /// moved it (Twitch, a step or the dashboard). + fn watch_goals(&mut self) { + let now = unix_ms(); + if self.session.is_on() { + self.handle.goals.still_live(now); + } + for event in self.handle.goals.take_news(now) { + self.event(event); + } + } + /// A destination dropped: tell the "keeps dropping" triggers whose /// threshold it reached, each with its own count and window. fn check_unstable(&mut self, dropped: &Event) { @@ -1581,9 +1623,10 @@ impl Engine { self.scene_pending.retain(|_, p| p.scene == now_scene); } - /// Listen on Twitch for the events enabled integrations start on. + /// Listen on Twitch for the events enabled integrations start on, and + /// the ones goals and timers count. fn update_twitch_events(&mut self) { - let mut wanted: Vec = Vec::new(); + let mut wanted: Vec = self.handle.goals.wanted_events(); for (_, _, h) in self.enabled_handlers() { if let Trigger::Event { kind, .. } = &h.trigger { if super::twitch::eventsub::subscription(*kind).is_some() && !wanted.contains(kind) @@ -1969,6 +2012,39 @@ fn phase_events(before: (&str, u32), now: (&str, u32)) -> Vec { events } +/// See `FOLLOW_AGAIN`. +#[derive(Default)] +struct FollowGuard { + /// When each person's follow last counted, by login. + counted: HashMap, +} + +impl FollowGuard { + fn prune(&mut self, now: Instant) { + self.counted + .retain(|_, t| now.duration_since(*t) < FOLLOW_AGAIN); + } + + /// True for a follow from someone whose follow already counted. + fn repeated(&mut self, event: &Event, now: Instant) -> bool { + if event.kind != EventKind::TwitchFollow { + return false; + } + let login = event.var("user_login").unwrap_or(""); + // A follow with no name can't be told apart from another. + if login.is_empty() { + return false; + } + match self.counted.get(login) { + Some(at) if now.duration_since(*at) < FOLLOW_AGAIN => true, + _ => { + self.counted.insert(login.to_string(), now); + false + } + } + } +} + /// See `FLAP_WINDOW`. What it holds back isn't lost: a destination whose /// last held event differs from what was last said about it gets that event /// once the window ends, so "live" is never the final word on a dead one. @@ -2079,6 +2155,7 @@ fn make_env( counters: store.counters.clone(), viewers: store.viewers.clone(), saved: store.saved.clone(), + goals: store.goals.clone(), // Written with the run counts a moment later (`save_uses_if_due`): // a chat command counting every message must not rewrite the file // each time, on the engine's own thread. @@ -2109,11 +2186,12 @@ fn event_vars(event: &Event) -> BTreeMap { /// filter can list several values (`YouTube, Kick`) and use wildcards /// (`*timed out*`); case doesn't matter. fn filters_match(filters: &BTreeMap, event: &Event) -> bool { - filters.iter().all(|(name, want)| { - want.trim().is_empty() - || event.kind.is_param(name) - || event.var(name).is_some_and(|have| matches_any(have, want)) - }) + event.kind.at_least(filters, event) + && filters.iter().all(|(name, want)| { + want.trim().is_empty() + || event.kind.is_param(name) + || event.var(name).is_some_and(|have| matches_any(have, want)) + }) } /// Whether `text` matches any of the comma separated `patterns`, each @@ -2129,6 +2207,21 @@ pub fn matches_any(text: &str, patterns: &str) -> bool { fn filter_samples(filters: &BTreeMap, vars: &mut BTreeMap) { for (name, want) in filters { let want = want.trim(); + // A minimum (`min_bits`): the sample amount is at least that. + if let Some(var) = name.strip_prefix("min_") { + let least = want + .parse::() + .ok() + .filter(|n| n.is_finite()) + .unwrap_or(0.0) + .min(1e6); + if let Some(have) = vars.get_mut(var) { + if have.parse::().is_ok_and(|n| n < least) { + *have = format!("{least:.0}"); + } + } + continue; + } let plain = !want.is_empty() && !want.contains([',', '*', '?']); if plain && vars.contains_key(name) { vars.insert(name.clone(), want.to_string()); @@ -2342,6 +2435,23 @@ mod tests { } } + #[test] + fn a_follow_from_the_same_person_counts_once_a_day() { + let mut guard = FollowGuard::default(); + let follow = |login: &str| Event::new(EventKind::TwitchFollow).with("user_login", login); + let t0 = Instant::now(); + assert!(!guard.repeated(&follow("nat"), t0)); + assert!(guard.repeated(&follow("nat"), t0 + Duration::from_secs(5))); + assert!(!guard.repeated(&follow("sam"), t0 + Duration::from_secs(5))); + assert!(!guard.repeated(&follow(""), t0)); + assert!(!guard.repeated(&follow(""), t0)); + let sub = Event::new(EventKind::TwitchSub).with("user_login", "nat"); + assert!(!guard.repeated(&sub, t0)); + let next_day = t0 + FOLLOW_AGAIN; + guard.prune(next_day); + assert!(!guard.repeated(&follow("nat"), next_day)); + } + #[test] fn a_flapping_destination_alerts_once_per_window() { let mut guard = FlapGuard::default(); @@ -2609,6 +2719,20 @@ mod tests { ); } + /// "From 1000 bits" runs on 1000 bits and up, and its test reads so. + #[test] + fn a_minimum_narrows_the_trigger_and_its_sample() { + let least = BTreeMap::from([("min_bits".to_string(), "1000".to_string())]); + let cheer = |bits: &str| Event::new(EventKind::TwitchCheer).with("bits", bits); + assert!(filters_match(&least, &cheer("1000"))); + assert!(!filters_match(&least, &cheer("500"))); + let trigger = Trigger::Event { + kind: EventKind::TwitchCheer, + filters: least, + }; + assert_eq!(sample_vars(&trigger).0["bits"], "1000"); + } + #[test] fn a_new_stream_forgets_per_subject_messages_only() { let mut messages = HashMap::from([ diff --git a/src/integrations/event.rs b/src/integrations/event.rs index 493de21..4d4834b 100644 --- a/src/integrations/event.rs +++ b/src/integrations/event.rs @@ -48,6 +48,9 @@ pub enum EventKind { TwitchRaid, TwitchRedeem, TwitchAd, + // Made by the goals and timers (see `goals`). + GoalReached, + TimerEnded, } /// One variable an event carries: its name and a sample value. @@ -61,7 +64,7 @@ pub type ParamSpec = (&'static str, &'static str, &'static str); const MAX_PARAM: f64 = 1_000_000.0; impl EventKind { - pub const ALL: [EventKind; 36] = [ + pub const ALL: [EventKind; 38] = [ EventKind::ObsConnected, EventKind::ObsDisconnected, EventKind::EbDetected, @@ -99,6 +102,8 @@ impl EventKind { EventKind::TwitchRaid, EventKind::TwitchRedeem, EventKind::TwitchAd, + EventKind::GoalReached, + EventKind::TimerEnded, ]; pub fn id(self) -> &'static str { @@ -139,6 +144,8 @@ impl EventKind { EventKind::TwitchRaid => "twitch_raid", EventKind::TwitchRedeem => "twitch_redeem", EventKind::TwitchAd => "twitch_ad", + EventKind::GoalReached => "goal_reached", + EventKind::TimerEnded => "timer_ended", } } @@ -184,6 +191,8 @@ impl EventKind { EventKind::TwitchRaid => "A raid comes in", EventKind::TwitchRedeem => "Someone redeems channel points", EventKind::TwitchAd => "An ad break starts", + EventKind::GoalReached => "A goal is reached", + EventKind::TimerEnded => "A timer runs out", } } @@ -225,6 +234,7 @@ impl EventKind { | EventKind::TwitchRaid | EventKind::TwitchRedeem | EventKind::TwitchAd => "Twitch", + EventKind::GoalReached | EventKind::TimerEnded => "Goals and timers", } } @@ -339,11 +349,14 @@ impl EventKind { ("tier", "1"), ("total", "42"), ], + // `words`: the message without its cheers (Cheer100), for an + // alert to show. EventKind::TwitchCheer => &[ ("user", "Nat"), ("user_login", "nat"), ("bits", "500"), ("message", "Cheer500 gg"), + ("words", "gg"), ], EventKind::TwitchRaid => &[("user", "Nat"), ("user_login", "nat"), ("viewers", "42")], EventKind::TwitchRedeem => &[ @@ -354,6 +367,15 @@ impl EventKind { ("input", "with lemon"), ], EventKind::TwitchAd => &[("seconds", "90"), ("automatic", "yes")], + // `goal` and `timer` are the names you gave them; the ids are + // what `{goal.subs}` and a step know them by. + EventKind::GoalReached => &[ + ("goal", "Sub goal"), + ("goal_id", "subs"), + ("value", "50"), + ("target", "50"), + ], + EventKind::TimerEnded => &[("timer", "Subathon"), ("timer_id", "subathon")], EventKind::ObsConnected | EventKind::EbDetected | EventKind::AllDestinationsDown @@ -372,10 +394,37 @@ impl EventKind { ("within_min", "10", "Within (minutes)"), ], EventKind::DestinationSteady => &[("for_min", "10", "Live again for (minutes)")], + // The least amount that runs it (`min_`, see + // `at_least`). 1 is any: every one has at least that. + EventKind::TwitchCheer => &[("min_bits", "1", "At least (bits)")], + EventKind::TwitchRaid => &[("min_viewers", "1", "At least (viewers)")], + EventKind::TwitchGift => &[("min_count", "1", "At least (subs)")], + EventKind::TwitchResub => &[("min_months", "1", "At least (months)")], _ => &[], } } + /// Whether an event is as big as a trigger's minimums ask (`min_bits`: + /// at least that many bits). A minimum of 1 or less lets every one + /// through, a raid of nobody too. + pub fn at_least( + self, + filters: &std::collections::BTreeMap, + event: &Event, + ) -> bool { + self.params().iter().all(|(name, ..)| { + let Some(var) = name.strip_prefix("min_") else { + return true; + }; + let least = self.param(filters, name); + let have = event + .var(var) + .and_then(|v| v.trim().parse::().ok()) + .unwrap_or(0.0); + least <= 1.0 || have >= least + }) + } + /// A trigger's setting `name`, or its default when unset or not a /// positive number. Capped at a million (11 days in seconds): the /// engine turns these into durations, and an absurd one typed in the @@ -518,4 +567,26 @@ mod tests { let _ = std::time::Duration::from_secs_f64(kind.param(&set("inf"), "after_s") * 60.0); assert!(kind.is_param("after_s") && !kind.is_param("destination")); } + + #[test] + fn a_minimum_lets_only_big_enough_ones_through() { + let cheer = |bits: &str| Event::new(EventKind::TwitchCheer).with("bits", bits); + let least = |n: &str| BTreeMap::from([("min_bits".to_string(), n.to_string())]); + let kind = EventKind::TwitchCheer; + assert!( + kind.at_least(&BTreeMap::new(), &cheer("1")), + "no minimum set" + ); + assert!(kind.at_least(&least("100"), &cheer("100"))); + assert!(!kind.at_least(&least("100"), &cheer("99"))); + assert!( + !kind.at_least(&least("100"), &cheer("lots")), + "not a number" + ); + let raid = Event::new(EventKind::TwitchRaid).with("viewers", "0"); + assert!( + EventKind::TwitchRaid.at_least(&BTreeMap::new(), &raid), + "a raid of nobody still counts" + ); + } } diff --git a/src/integrations/feed.rs b/src/integrations/feed.rs new file mode 100644 index 0000000..15c47f4 --- /dev/null +++ b/src/integrations/feed.rs @@ -0,0 +1,122 @@ +//! The OBS dock's events list: what "Add to the dock's events" steps wrote, +//! oldest first, each with the alert its run showed, to play again. +//! +//! Kept in memory for this run of the app: the dock asks for the list when +//! the state stream says it changed (`changes`). + +use super::alerts::{self, Card}; +use crate::json::{self, Value}; +use crate::sync::Mutex; +use std::collections::VecDeque; + +/// Events kept: the newest this many. +const KEEP: usize = 100; +const MAX_TEXT: usize = 200; +const MAX_GROUP: usize = 40; + +pub struct Feed { + inner: Mutex, +} + +#[derive(Default)] +struct Inner { + last_id: u64, + /// Bumped by every add and clear. + changes: u64, + events: VecDeque, +} + +struct Entry { + id: u64, + at_ms: u64, + group: String, + text: String, + /// The alert the same run showed, for Replay. + card: Option, +} + +impl Feed { + pub fn new() -> Feed { + Feed { + inner: Mutex::new(Inner::default()), + } + } + + /// Add an event: a line, the group the dock filters it by, and the alert + /// to replay, if its run showed one. + pub fn add(&self, group: &str, text: &str, card: Option) { + let mut inner = self.inner.lock(); + inner.last_id += 1; + inner.changes += 1; + let entry = Entry { + id: inner.last_id, + at_ms: super::twitch::unix_ms(), + group: alerts::one_line(group, MAX_GROUP), + text: alerts::one_line(text, MAX_TEXT), + card, + }; + if inner.events.len() >= KEEP { + inner.events.pop_front(); + } + inner.events.push_back(entry); + } + + /// Changes with every add and clear: the dock fetches the list then. + pub fn changes(&self) -> u64 { + self.inner.lock().changes + } + + /// The alert event `id` showed, to put up again. + pub fn card(&self, id: u64) -> Option { + let inner = self.inner.lock(); + inner.events.iter().find(|e| e.id == id)?.card.clone() + } + + pub fn to_json(&self) -> Value { + let inner = self.inner.lock(); + Value::Arr( + inner + .events + .iter() + .map(|e| { + json::obj([ + ("id", Value::Num(e.id as f64)), + ("at_ms", Value::Num(e.at_ms as f64)), + ("group", json::str(&e.group)), + ("text", json::str(&e.text)), + ("replay", Value::Bool(e.card.is_some())), + ]) + }) + .collect(), + ) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn events_keep_their_alert_to_replay_and_the_newest_hundred() { + let feed = Feed::new(); + feed.add( + "Bits", + "Nat cheered\n500 bits", + Some(Card::plain("Bits", "Nat", 6)), + ); + feed.add("", "OBS crashed", None); + let list = feed.to_json(); + let first = &list.as_array().unwrap()[0]; + assert_eq!(first.str_or("text", ""), "Nat cheered 500 bits"); + assert!(first.bool_or("replay", false)); + assert_eq!(feed.card(1).map(|c| c.text), Some("Nat".to_string())); + assert!(feed.card(2).is_none(), "nothing to replay"); + let before = feed.changes(); + for n in 0..KEEP { + feed.add("", &n.to_string(), None); + } + assert_eq!(feed.to_json().as_array().unwrap().len(), KEEP); + assert!(feed.card(1).is_none(), "the oldest went"); + assert!(feed.changes() > before); + } +} diff --git a/src/integrations/goals.rs b/src/integrations/goals.rs new file mode 100644 index 0000000..e9f0182 --- /dev/null +++ b/src/integrations/goals.rs @@ -0,0 +1,985 @@ +//! Goals and timers: numbers a stream works toward and clocks that run +//! down, drawn by overlay widgets. +//! +//! A goal counts one thing (follows, subs, bits, or only what steps and the +//! dashboard add) toward a target. A timer counts down, and can gain time +//! from subs, bits and follows: a subathon. What they are is a setting; +//! where they stand is kept in the integrations' data file, so a restart +//! mid-stream loses nothing. +//! +//! One `Tracker` is shared by the engine (events), the steps, the dashboard +//! and the overlay feed. Whatever moves a goal, reaching its target is +//! noticed here and handed to the engine as an event at its next tick; a +//! timer running out is too. + +use super::event::{Event, EventKind}; +use crate::json::{self, Value}; +use crate::sync::Mutex; +use std::collections::BTreeMap; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; + +pub const MAX_GOALS: usize = 16; +pub const MAX_TIMERS: usize = 8; +const MAX_ID: usize = 32; +const MAX_NAME: usize = 40; +/// The most a goal counts to. +pub const MAX_TARGET: u64 = 1_000_000_000; +/// The longest a timer runs: 30 days. +pub const MAX_TIMER_S: u64 = 30 * 24 * 3600; +/// What a tier 2 and a tier 3 sub add to a timer, against a tier 1: about +/// what they cost. +const TIER_WEIGHTS: [u64; 3] = [1, 2, 5]; +/// A stream "starting" this soon after one was last seen live is that one +/// carrying on: InstantClone restarted mid-stream (a crash, an update), and +/// OBS connecting again isn't a new stream. +const SAME_STREAM_MS: u64 = 10 * 60 * 1000; +/// How often a live stream is noted for that, in the data file. +const LIVE_NOTE_EVERY_MS: u64 = 60 * 1000; +/// A timer that ran out longer ago than this ran out while the app was +/// closed: it ends quietly, instead of "ending" as the app starts. +const ENDED_NEWS_WITHIN_MS: u64 = 60 * 1000; + +/// What a goal counts by itself. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Counts { + Follows, + /// New subs, resubs and each gifted sub. + Subs, + Bits, + /// Nothing: only steps and the dashboard move it. + Own, +} + +impl Counts { + pub fn id(self) -> &'static str { + match self { + Counts::Follows => "follows", + Counts::Subs => "subs", + Counts::Bits => "bits", + Counts::Own => "own", + } + } + + fn from_id(id: &str) -> Counts { + match id { + "follows" => Counts::Follows, + "subs" => Counts::Subs, + "bits" => Counts::Bits, + _ => Counts::Own, + } + } +} + +#[derive(Clone, Debug, PartialEq)] +pub struct Goal { + /// What steps and widgets know it by (`{goal.subs}`). + pub id: String, + pub name: String, + pub counts: Counts, + pub target: u64, + /// Start again from 0 with every stream. + pub each_stream: bool, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct Timer { + pub id: String, + pub name: String, + /// What it starts from, and goes back to on a reset. + pub start_s: u64, + /// Seconds each sub, each 100 bits and each follow add; 0 for none. + pub per_sub_s: u64, + pub per_100_bits_s: u64, + pub per_follow_s: u64, + /// Tier 2 and 3 subs add more (`TIER_WEIGHTS`). + pub tiers: bool, + /// The most it can hold, however much is added; 0 for no limit. + pub max_s: u64, +} + +/// `a-z`, `0-9` and `_`: safe in a template name, a config key and a page. +pub fn valid_id(id: &str) -> bool { + !id.is_empty() + && id.len() <= MAX_ID + && id + .bytes() + .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'_') +} + +fn name_of(v: &Value) -> String { + let flat = super::template::one_line(v.str_or("name", "")); + flat.trim().chars().take(MAX_NAME).collect() +} + +impl Goal { + /// A saved or sent goal, or `None` when it has no usable id or name. + pub fn from_json(v: &Value) -> Option { + let goal = Goal { + id: v.str_or("id", "").to_string(), + name: name_of(v), + counts: Counts::from_id(v.str_or("counts", "")), + target: v.u64_or("target", 0).min(MAX_TARGET), + each_stream: v.bool_or("each_stream", false), + }; + (valid_id(&goal.id) && !goal.name.is_empty() && goal.target > 0).then_some(goal) + } + + pub fn to_json(&self) -> Value { + json::obj([ + ("id", json::str(&self.id)), + ("name", json::str(&self.name)), + ("counts", json::str(self.counts.id())), + ("target", Value::Num(self.target as f64)), + ("each_stream", Value::Bool(self.each_stream)), + ]) + } +} + +impl Timer { + pub fn from_json(v: &Value) -> Option { + let seconds = |key: &str| v.u64_or(key, 0).min(MAX_TIMER_S); + let timer = Timer { + id: v.str_or("id", "").to_string(), + name: name_of(v), + start_s: seconds("start_s"), + per_sub_s: seconds("per_sub_s"), + per_100_bits_s: seconds("per_100_bits_s"), + per_follow_s: seconds("per_follow_s"), + tiers: v.bool_or("tiers", true), + max_s: seconds("max_s"), + }; + (valid_id(&timer.id) && !timer.name.is_empty() && timer.start_s > 0).then_some(timer) + } + + pub fn to_json(&self) -> Value { + let n = |s: u64| Value::Num(s as f64); + json::obj([ + ("id", json::str(&self.id)), + ("name", json::str(&self.name)), + ("start_s", n(self.start_s)), + ("per_sub_s", n(self.per_sub_s)), + ("per_100_bits_s", n(self.per_100_bits_s)), + ("per_follow_s", n(self.per_follow_s)), + ("tiers", Value::Bool(self.tiers)), + ("max_s", n(self.max_s)), + ]) + } + + fn start_ms(&self) -> u64 { + self.start_s * 1000 + } + + /// `left_ms` kept within what the timer may hold. + fn capped(&self, left_ms: u64) -> u64 { + let most = if self.max_s > 0 { + self.max_s + } else { + MAX_TIMER_S + }; + left_ms.min(most * 1000) + } +} + +/// The goals and timers the dashboard sent, checked: what it says is wrong +/// is what the streamer reads. +pub fn parse_setup(req: &Value) -> Result<(Vec, Vec), String> { + let list = |key: &str| req.get(key).and_then(Value::as_array).unwrap_or(&[]); + let goals: Vec = list("goals").iter().filter_map(Goal::from_json).collect(); + let timers: Vec = list("timers").iter().filter_map(Timer::from_json).collect(); + if goals.len() != list("goals").len() || timers.len() != list("timers").len() { + return Err("each one needs a name and a number above 0".to_string()); + } + if goals.len() > MAX_GOALS || timers.len() > MAX_TIMERS { + return Err(format!( + "that's more than {MAX_GOALS} goals or {MAX_TIMERS} timers" + )); + } + let mut ids: Vec<&str> = goals.iter().map(|g| g.id.as_str()).collect(); + let goal_ids = ids.len(); + ids.sort_unstable(); + ids.dedup(); + let mut timer_ids: Vec<&str> = timers.iter().map(|t| t.id.as_str()).collect(); + timer_ids.sort_unstable(); + timer_ids.dedup(); + if ids.len() != goal_ids || timer_ids.len() != timers.len() { + return Err("two of them have the same short name".to_string()); + } + Ok((goals, timers)) +} + +/// Where a timer stands. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Clock { + Paused { left_ms: u64 }, + Running { ends_at_ms: u64 }, +} + +impl Clock { + fn left_ms(self, now_ms: u64) -> u64 { + match self { + Clock::Paused { left_ms } => left_ms, + Clock::Running { ends_at_ms } => ends_at_ms.saturating_sub(now_ms), + } + } + + /// The same clock holding `left_ms`. + fn holding(self, left_ms: u64, now_ms: u64) -> Clock { + match self { + Clock::Paused { .. } => Clock::Paused { left_ms }, + Clock::Running { .. } => Clock::Running { + ends_at_ms: now_ms + left_ms, + }, + } + } +} + +pub enum GoalChange { + Add(i64), + Set(u64), + Reset, +} + +pub enum TimerChange { + /// Run from where it stands, or from the top once it has run out. + Start, + Pause, + /// Back to its starting time, stopped. + Reset, + /// Seconds more, or fewer. + Add(i64), + /// Seconds left, from now. + Set(u64), +} + +#[derive(Default)] +struct Inner { + goals: Vec, + timers: Vec, + values: BTreeMap, + clocks: BTreeMap, + /// When a stream was last seen live (0: none since the last one ended). + live_at_ms: u64, + /// Goals reached and timers run out since the engine last asked. + news: Vec, +} + +impl Inner { + fn clock(&self, timer: &Timer) -> Clock { + let fresh = Clock::Paused { + left_ms: timer.start_ms(), + }; + self.clocks.get(&timer.id).copied().unwrap_or(fresh) + } + + /// Give goal `at` a new value; crossing its target is news. + fn set_value(&mut self, at: usize, value: u64) { + let goal = &self.goals[at]; + let value = value.min(MAX_TARGET); + let before = self.values.insert(goal.id.clone(), value).unwrap_or(0); + if before < goal.target && value >= goal.target { + let reached = Event::new(EventKind::GoalReached) + .with("goal", goal.name.clone()) + .with("goal_id", goal.id.clone()) + .with("value", value.to_string()) + .with("target", goal.target.to_string()); + self.news.push(reached); + } + } +} + +pub struct Tracker { + inner: Mutex, + /// The data file's "something worth saving changed" flag. + dirty: Arc, +} + +impl Tracker { + /// `saved` is what `state_json` wrote the last time the app ran. + pub fn new(saved: Option<&Value>, dirty: Arc) -> Tracker { + let mut inner = Inner::default(); + let fields = |key: &str| match saved.and_then(|v| v.get(key)) { + Some(Value::Obj(fields)) => fields.iter().collect::>(), + _ => Vec::new(), + }; + for (id, value) in fields("goals") { + if let Some(n) = value.as_f64().filter(|n| *n >= 0.0) { + inner.values.insert(id.clone(), (n as u64).min(MAX_TARGET)); + } + } + for (id, clock) in fields("timers") { + let at = clock.u64_or("at_ms", 0); + let clock = if clock.bool_or("running", false) { + Clock::Running { ends_at_ms: at } + } else { + Clock::Paused { left_ms: at } + }; + inner.clocks.insert(id.clone(), clock); + } + inner.live_at_ms = saved.map_or(0, |v| v.u64_or("live_at_ms", 0)); + Tracker { + inner: Mutex::new(inner), + dirty, + } + } + + /// Where every goal and timer stands, for the data file. + pub fn state_json(&self) -> Value { + let inner = self.inner.lock(); + let goals = inner + .values + .iter() + .map(|(id, n)| (id.clone(), Value::Num(*n as f64))); + let timers = inner.clocks.iter().map(|(id, clock)| { + let (running, at) = match *clock { + Clock::Running { ends_at_ms } => (true, ends_at_ms), + Clock::Paused { left_ms } => (false, left_ms), + }; + let kept = json::obj([ + ("running", Value::Bool(running)), + ("at_ms", Value::Num(at as f64)), + ]); + (id.clone(), kept) + }); + json::obj([ + ("goals", Value::Obj(goals.collect())), + ("timers", Value::Obj(timers.collect())), + ("live_at_ms", Value::Num(inner.live_at_ms as f64)), + ]) + } + + fn changed(&self) { + self.dirty.store(true, Ordering::Relaxed); + } + + /// The goals and timers that exist now. One that is gone forgets where + /// it stood. + pub fn configure(&self, goals: &[Goal], timers: &[Timer]) { + let mut inner = self.inner.lock(); + if inner.goals == goals && inner.timers == timers { + return; + } + inner.goals = goals.to_vec(); + inner.timers = timers.to_vec(); + let Inner { + goals, + timers, + values, + clocks, + .. + } = &mut *inner; + values.retain(|id, _| goals.iter().any(|g| g.id == *id)); + clocks.retain(|id, _| timers.iter().any(|t| t.id == *id)); + self.changed(); + } + + /// The Twitch events a goal counts or a timer gains time from: the + /// engine listens for these even when no integration does. + pub fn wanted_events(&self) -> Vec { + let inner = self.inner.lock(); + let counted = |counts: Counts| { + inner.goals.iter().any(|g| g.counts == counts) + || inner.timers.iter().any(|t| match counts { + Counts::Follows => t.per_follow_s > 0, + Counts::Subs => t.per_sub_s > 0, + Counts::Bits => t.per_100_bits_s > 0, + Counts::Own => false, + }) + }; + let mut wanted = Vec::new(); + if counted(Counts::Follows) { + wanted.push(EventKind::TwitchFollow); + } + if counted(Counts::Subs) { + wanted.extend([EventKind::TwitchSub, EventKind::TwitchResub]); + } + if counted(Counts::Bits) { + wanted.push(EventKind::TwitchCheer); + } + wanted + } + + /// Count a follow, a sub or bits toward the goals that count them, and + /// give running timers the time they add. + pub fn observe(&self, event: &Event, now_ms: u64) { + let number = |name: &str| event.var(name).and_then(|v| v.parse::().ok()); + let (counts, amount) = match event.kind { + EventKind::TwitchFollow => (Counts::Follows, 1), + EventKind::TwitchSub | EventKind::TwitchResub => (Counts::Subs, 1), + EventKind::TwitchCheer => (Counts::Bits, number("bits").unwrap_or(0)), + _ => return, + }; + let tier = number("tier").unwrap_or(1).clamp(1, 3) as usize; + let mut inner = self.inner.lock(); + // Most events move nothing: those leave the data file alone. + let mut moved = false; + for at in 0..inner.goals.len() { + if inner.goals[at].counts == counts { + let now = inner.values.get(&inner.goals[at].id).copied().unwrap_or(0); + inner.set_value(at, now.saturating_add(amount)); + moved = true; + } + } + for at in 0..inner.timers.len() { + let timer = &inner.timers[at]; + let seconds = match counts { + Counts::Follows => timer.per_follow_s, + Counts::Subs if timer.tiers => timer.per_sub_s * TIER_WEIGHTS[tier - 1], + Counts::Subs => timer.per_sub_s, + Counts::Bits => amount.saturating_mul(timer.per_100_bits_s) / 100, + Counts::Own => 0, + }; + let clock = inner.clock(timer); + let left = clock.left_ms(now_ms); + // One that ran out is over: a late sub doesn't restart it. + if seconds == 0 || left == 0 { + continue; + } + let left = timer.capped(left.saturating_add(seconds.saturating_mul(1000))); + let id = timer.id.clone(); + inner.clocks.insert(id, clock.holding(left, now_ms)); + moved = true; + } + if moved { + self.changed(); + } + } + + /// A step or the dashboard moving a goal; its new value. + pub fn change_goal(&self, id: &str, change: GoalChange) -> Result { + let mut inner = self.inner.lock(); + let Some(at) = inner.goals.iter().position(|g| g.id == id) else { + return Err(format!("there is no goal called {id}")); + }; + let now = inner.values.get(id).copied().unwrap_or(0); + let value = match change { + GoalChange::Add(by) if by < 0 => now.saturating_sub(by.unsigned_abs()), + GoalChange::Add(by) => now.saturating_add(by.unsigned_abs()), + GoalChange::Set(value) => value, + GoalChange::Reset => 0, + }; + inner.set_value(at, value); + self.changed(); + Ok(value.min(MAX_TARGET)) + } + + /// A step or the dashboard driving a timer; the milliseconds it holds. + pub fn change_timer(&self, id: &str, change: TimerChange, now_ms: u64) -> Result { + let mut inner = self.inner.lock(); + let Some(timer) = inner.timers.iter().find(|t| t.id == id).cloned() else { + return Err(format!("there is no timer called {id}")); + }; + let clock = inner.clock(&timer); + let left = clock.left_ms(now_ms); + let next = match change { + // One that ran out starts over from the top. + TimerChange::Start if left == 0 => Clock::Running { + ends_at_ms: now_ms + timer.start_ms(), + }, + TimerChange::Start => Clock::Running { + ends_at_ms: now_ms + left, + }, + TimerChange::Pause => Clock::Paused { left_ms: left }, + TimerChange::Reset => Clock::Paused { + left_ms: timer.start_ms(), + }, + TimerChange::Add(by) => { + let by_ms = by.unsigned_abs().saturating_mul(1000); + let left = if by < 0 { + left.saturating_sub(by_ms) + } else { + left.saturating_add(by_ms) + }; + clock.holding(timer.capped(left), now_ms) + } + TimerChange::Set(seconds) => { + clock.holding(timer.capped(seconds.saturating_mul(1000)), now_ms) + } + }; + inner.clocks.insert(timer.id, next); + self.changed(); + Ok(next.left_ms(now_ms)) + } + + /// A new stream: the goals that count per stream start over. Unless it + /// is the last one carrying on, after InstantClone restarted mid-stream. + pub fn stream_started(&self, now_ms: u64) { + let mut inner = self.inner.lock(); + let Inner { + goals, + values, + live_at_ms, + .. + } = &mut *inner; + let carries_on = *live_at_ms > 0 && now_ms.saturating_sub(*live_at_ms) <= SAME_STREAM_MS; + if !carries_on { + for goal in goals.iter().filter(|g| g.each_stream) { + values.remove(&goal.id); + } + } + *live_at_ms = now_ms; + self.changed(); + } + + /// Each tick while a stream is live: noted (once a minute, and only with + /// a goal that counts per stream) so a restart can tell the same stream + /// from a new one. + pub fn still_live(&self, now_ms: u64) { + let mut inner = self.inner.lock(); + let per_stream = inner.goals.iter().any(|g| g.each_stream); + if per_stream && now_ms.saturating_sub(inner.live_at_ms) >= LIVE_NOTE_EVERY_MS { + inner.live_at_ms = now_ms; + self.changed(); + } + } + + /// The stream ended: the next one is a new one, however soon. + pub fn stream_ended(&self) { + let mut inner = self.inner.lock(); + if inner.live_at_ms != 0 { + inner.live_at_ms = 0; + self.changed(); + } + } + + /// Goals reached and timers run out since the last call, as events. + pub fn take_news(&self, now_ms: u64) -> Vec { + let mut inner = self.inner.lock(); + let over: Vec = inner + .timers + .iter() + .filter(|t| matches!(inner.clocks.get(&t.id), Some(Clock::Running { ends_at_ms }) if *ends_at_ms <= now_ms)) + .cloned() + .collect(); + for timer in over { + let ended = inner + .clocks + .insert(timer.id.clone(), Clock::Paused { left_ms: 0 }); + let just_now = matches!(ended, Some(Clock::Running { ends_at_ms }) + if now_ms.saturating_sub(ends_at_ms) <= ENDED_NEWS_WITHIN_MS); + if just_now { + let ended = Event::new(EventKind::TimerEnded) + .with("timer", timer.name) + .with("timer_id", timer.id); + inner.news.push(ended); + } + self.changed(); + } + std::mem::take(&mut inner.news) + } + + /// What a template name under `goal.` says: `subs` is the count, and + /// `subs.target`, `subs.left`, `subs.percent` and `subs.name` the rest. + pub fn goal_text(&self, name: &str) -> Option { + let (id, part) = name.split_once('.').unwrap_or((name, "")); + let inner = self.inner.lock(); + let goal = inner.goals.iter().find(|g| g.id == id)?; + let value = inner.values.get(id).copied().unwrap_or(0); + Some(match part { + "" => value.to_string(), + "target" => goal.target.to_string(), + "left" => goal.target.saturating_sub(value).to_string(), + "percent" => (value.min(goal.target) * 100 / goal.target).to_string(), + "name" => goal.name.clone(), + _ => return None, + }) + } + + /// What a template name under `timer.` says: `subathon` is the time + /// left as a clock, and `subathon.seconds`, `subathon.running` and + /// `subathon.name` the rest. + pub fn timer_text(&self, name: &str, now_ms: u64) -> Option { + let (id, part) = name.split_once('.').unwrap_or((name, "")); + let inner = self.inner.lock(); + let timer = inner.timers.iter().find(|t| t.id == id)?; + let clock = inner.clock(timer); + let left = clock.left_ms(now_ms); + Some(match part { + "" => clock_text(left), + "seconds" => left.div_ceil(1000).to_string(), + "running" => { + let running = matches!(clock, Clock::Running { .. }) && left > 0; + if running { "yes" } else { "no" }.to_string() + } + "name" => timer.name.clone(), + _ => return None, + }) + } + + /// Everything, for the dashboard: what each is and where it stands. + /// A running timer says when it ends, not how long is left, so this + /// only changes when something happens. + pub fn to_json(&self) -> Value { + let inner = self.inner.lock(); + let goals = inner.goals.iter().map(|goal| { + let value = inner.values.get(&goal.id).copied().unwrap_or(0); + with_fields(goal.to_json(), [("value", Value::Num(value as f64))]) + }); + let timers = inner + .timers + .iter() + .map(|timer| with_fields(timer.to_json(), clock_fields(inner.clock(timer)))); + json::obj([ + ("goals", Value::Arr(goals.collect())), + ("timers", Value::Arr(timers.collect())), + ]) + } + + /// What an overlay draws, as two JSON arrays: each goal's name, count + /// and target, and each timer's name and clock. It is on stream anyway; + /// how they are set up stays out of it. + pub fn overlay_json(&self) -> (String, String) { + let inner = self.inner.lock(); + let goals = inner.goals.iter().map(|goal| { + let value = inner.values.get(&goal.id).copied().unwrap_or(0); + json::obj([ + ("id", json::str(&goal.id)), + ("name", json::str(&goal.name)), + ("value", Value::Num(value as f64)), + ("target", Value::Num(goal.target as f64)), + ]) + }); + let timers = inner.timers.iter().map(|timer| { + let named = json::obj([ + ("id", json::str(&timer.id)), + ("name", json::str(&timer.name)), + ]); + with_fields(named, clock_fields(inner.clock(timer))) + }); + ( + Value::Arr(goals.collect()).to_json(), + Value::Arr(timers.collect()).to_json(), + ) + } +} + +/// A clock as pages read it: `ends_at_ms` while it runs (Unix time), +/// `left_ms` while it doesn't. +fn clock_fields(clock: Clock) -> [(&'static str, Value); 3] { + let (running, ends_at, left) = match clock { + Clock::Running { ends_at_ms } => (true, ends_at_ms, 0), + Clock::Paused { left_ms } => (false, 0, left_ms), + }; + [ + ("running", Value::Bool(running)), + ("ends_at_ms", Value::Num(ends_at as f64)), + ("left_ms", Value::Num(left as f64)), + ] +} + +fn with_fields(base: Value, more: [(&'static str, Value); N]) -> Value { + let Value::Obj(mut fields) = base else { + return base; + }; + fields.extend(more.into_iter().map(|(k, v)| (k.to_string(), v))); + Value::Obj(fields) +} + +/// Milliseconds as a countdown reads: `4:05`, or `1:02:09` from an hour up. +/// Rounded up, so it says 0:00 only once it is over. +pub fn clock_text(left_ms: u64) -> String { + super::host::fmt_clock(left_ms.div_ceil(1000).saturating_mul(1000)) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn goal(id: &str, counts: Counts, target: u64) -> Goal { + Goal { + id: id.to_string(), + name: id.to_uppercase(), + counts, + target, + each_stream: false, + } + } + + fn subathon() -> Timer { + Timer { + id: "subathon".to_string(), + name: "Subathon".to_string(), + start_s: 3600, + per_sub_s: 300, + per_100_bits_s: 60, + per_follow_s: 0, + tiers: true, + max_s: 0, + } + } + + fn tracker(goals: &[Goal], timers: &[Timer]) -> Tracker { + let tracker = Tracker::new(None, Default::default()); + tracker.configure(goals, timers); + tracker + } + + fn sub(tier: &str) -> Event { + Event::new(EventKind::TwitchSub).with("tier", tier) + } + + fn kinds(news: &[Event]) -> Vec { + news.iter().map(|e| e.kind).collect() + } + + #[test] + fn a_goal_counts_its_own_kind_and_is_reached_once() { + let t = tracker( + &[ + goal("subs", Counts::Subs, 2), + goal("bits", Counts::Bits, 500), + ], + &[], + ); + t.observe(&sub("1"), 0); + t.observe(&Event::new(EventKind::TwitchFollow), 0); + t.observe(&Event::new(EventKind::TwitchCheer).with("bits", "120"), 0); + assert_eq!(t.goal_text("subs").unwrap(), "1"); + assert_eq!(t.goal_text("bits").unwrap(), "120"); + let heard = [ + EventKind::TwitchSub, + EventKind::TwitchResub, + EventKind::TwitchCheer, + ]; + assert_eq!(t.wanted_events(), heard); + assert!(t.take_news(0).is_empty()); + + t.observe(&Event::new(EventKind::TwitchResub), 0); + let news = t.take_news(0); + assert_eq!(kinds(&news), [EventKind::GoalReached]); + assert_eq!(news[0].var("goal"), Some("SUBS")); + assert_eq!(news[0].var("target"), Some("2")); + // Past the target it keeps counting, and says nothing more. + t.observe(&sub("1"), 0); + assert!(t.take_news(0).is_empty()); + assert_eq!(t.goal_text("subs.left").unwrap(), "0"); + assert_eq!(t.goal_text("subs.percent").unwrap(), "100"); + } + + #[test] + fn steps_and_the_dashboard_move_a_goal() { + let t = tracker(&[goal("pot", Counts::Own, 10)], &[]); + assert_eq!(t.change_goal("pot", GoalChange::Add(4)), Ok(4)); + assert_eq!(t.change_goal("pot", GoalChange::Add(-9)), Ok(0)); + assert_eq!(t.change_goal("pot", GoalChange::Set(10)), Ok(10)); + assert_eq!(kinds(&t.take_news(0)), [EventKind::GoalReached]); + assert_eq!(t.change_goal("pot", GoalChange::Reset), Ok(0)); + assert!(t.change_goal("nope", GoalChange::Reset).is_err()); + // A sub is not what this one counts. + t.observe(&sub("1"), 0); + assert_eq!(t.goal_text("pot").unwrap(), "0"); + } + + #[test] + fn a_timer_runs_gains_time_and_ends_once() { + let t = tracker(&[], &[subathon()]); + assert_eq!(t.timer_text("subathon", 0).unwrap(), "1:00:00"); + t.change_timer("subathon", TimerChange::Start, 1_000) + .unwrap(); + // A tier 1 sub is 5 minutes, a tier 3 five times that, 250 bits 2:30. + t.observe(&sub("1"), 1_000); + t.observe(&sub("3"), 1_000); + t.observe( + &Event::new(EventKind::TwitchCheer).with("bits", "250"), + 1_000, + ); + assert_eq!(t.timer_text("subathon.seconds", 1_000).unwrap(), "5550"); + assert_eq!(t.timer_text("subathon.running", 1_000).unwrap(), "yes"); + + // Paused, it holds what it had, and a sub still adds to it. + let left = t.change_timer("subathon", TimerChange::Pause, 51_000); + assert_eq!(left, Ok(5_500_000)); + t.observe(&sub("1"), 900_000); + assert_eq!(t.timer_text("subathon", 900_000).unwrap(), "1:36:40"); + + t.change_timer("subathon", TimerChange::Set(2), 0).unwrap(); + t.change_timer("subathon", TimerChange::Start, 0).unwrap(); + assert!(t.take_news(1_999).is_empty()); + let news = t.take_news(2_000); + assert_eq!(kinds(&news), [EventKind::TimerEnded]); + assert_eq!(news[0].var("timer"), Some("Subathon")); + assert!(t.take_news(9_000).is_empty()); + // Over is over: a late sub doesn't bring it back. + t.observe(&sub("1"), 9_000); + assert_eq!(t.timer_text("subathon", 9_000).unwrap(), "0:00"); + assert_eq!(t.timer_text("subathon.running", 9_000).unwrap(), "no"); + assert_eq!( + t.change_timer("subathon", TimerChange::Start, 9_000), + Ok(3_600_000) + ); + assert_eq!(t.timer_text("subathon.running", 9_500).unwrap(), "yes"); + assert_eq!( + t.change_timer("subathon", TimerChange::Reset, 9_500), + Ok(3_600_000) + ); + assert_eq!(t.timer_text("subathon.running", 9_500).unwrap(), "no"); + } + + #[test] + fn a_timer_never_holds_more_than_its_limit() { + let mut capped = subathon(); + capped.max_s = 3700; + capped.tiers = false; + let t = tracker(&[], &[capped]); + t.observe(&sub("3"), 0); + assert_eq!(t.timer_text("subathon.seconds", 0).unwrap(), "3700"); + assert_eq!( + t.change_timer("subathon", TimerChange::Add(-4000), 0), + Ok(0) + ); + assert_eq!( + t.change_timer("subathon", TimerChange::Add(9_999_999), 0), + Ok(3_700_000) + ); + } + + #[test] + fn where_things_stand_survives_a_restart() { + let dirty: Arc = Default::default(); + let setup = ( + [ + goal("subs", Counts::Subs, 50), + goal("daily", Counts::Follows, 5), + ], + [subathon()], + ); + let t = Tracker::new(None, dirty.clone()); + t.configure(&setup.0, &setup.1); + t.observe(&sub("1"), 0); + t.observe(&Event::new(EventKind::TwitchFollow), 0); + t.change_timer("subathon", TimerChange::Start, 5_000) + .unwrap(); + assert!(dirty.load(Ordering::Relaxed)); + + let saved = json::parse(&t.state_json().to_json()).unwrap(); + let back = Tracker::new(Some(&saved), Default::default()); + back.configure(&setup.0, &setup.1); + assert_eq!(back.goal_text("subs").unwrap(), "1"); + assert_eq!(back.timer_text("subathon.seconds", 6_000).unwrap(), "3899"); + + // A goal that counts per stream starts over; one that is gone + // forgets where it stood. + let mut daily = setup.0[1].clone(); + daily.each_stream = true; + back.configure(&[daily.clone()], &[]); + back.stream_started(7_000); + assert_eq!(back.goal_text("daily").unwrap(), "0"); + assert!(back.goal_text("subs").is_none()); + back.configure(&[daily, setup.0[0].clone()], &setup.1); + assert_eq!(back.goal_text("subs").unwrap(), "0"); + assert_eq!(back.timer_text("subathon", 0).unwrap(), "1:00:00"); + } + + #[test] + fn a_restart_mid_stream_carries_on_and_a_new_stream_starts_over() { + const MIN: u64 = 60_000; + let mut daily = goal("daily", Counts::Follows, 5); + daily.each_stream = true; + let restart = |t: &Tracker| { + let saved = json::parse(&t.state_json().to_json()).unwrap(); + let back = Tracker::new(Some(&saved), Default::default()); + back.configure(&[daily.clone()], &[subathon()]); + back + }; + let t = restart(&Tracker::new(None, Default::default())); + t.stream_started(0); + t.observe(&Event::new(EventKind::TwitchFollow), MIN); + t.still_live(30 * MIN); + + // Back 3 minutes after it was last seen live: the same stream. + let back = restart(&t); + back.stream_started(33 * MIN); + assert_eq!(back.goal_text("daily").unwrap(), "1"); + // Ended, then a new one, however soon: from 0. + back.stream_ended(); + let back = restart(&back); + back.stream_started(34 * MIN); + assert_eq!(back.goal_text("daily").unwrap(), "0"); + // Live long ago, never ended (the app was closed): a new one too. + back.observe(&Event::new(EventKind::TwitchFollow), 35 * MIN); + let back = restart(&back); + back.stream_started(300 * MIN); + assert_eq!(back.goal_text("daily").unwrap(), "0"); + } + + #[test] + fn a_timer_that_ran_out_while_the_app_was_closed_ends_quietly() { + let t = tracker(&[], &[subathon()]); + t.change_timer("subathon", TimerChange::Start, 0).unwrap(); + let next_day = 24 * 3600 * 1000; + assert!(t.take_news(next_day).is_empty()); + assert_eq!(t.timer_text("subathon", next_day).unwrap(), "0:00"); + } + + #[test] + fn an_event_nothing_counts_leaves_the_data_file_alone() { + let dirty: Arc = Default::default(); + let t = Tracker::new(None, dirty.clone()); + t.configure(&[goal("subs", Counts::Subs, 50)], &[subathon()]); + dirty.store(false, Ordering::Relaxed); + t.observe(&Event::new(EventKind::TwitchFollow), 0); + assert!(!dirty.load(Ordering::Relaxed)); + // More bits than any real cheer fill the timer to its limit, no more. + let bits = u64::MAX.to_string(); + t.observe(&Event::new(EventKind::TwitchCheer).with("bits", bits), 0); + assert!(dirty.load(Ordering::Relaxed)); + let most = MAX_TIMER_S.to_string(); + assert_eq!(t.timer_text("subathon.seconds", 0).unwrap(), most); + } + + #[test] + fn a_running_clock_reads_the_same_until_something_happens() { + let t = tracker(&[goal("subs", Counts::Subs, 50)], &[subathon()]); + t.change_timer("subathon", TimerChange::Start, 1_000) + .unwrap(); + let (goals, timers) = t.overlay_json(); + assert_eq!( + goals, + r#"[{"id":"subs","name":"SUBS","value":0,"target":50}]"# + ); + let clock = json::parse(&timers).unwrap(); + let clock = &clock.as_array().unwrap()[0]; + assert_eq!(clock.u64_or("ends_at_ms", 0), 3_601_000); + assert!(clock.bool_or("running", false)); + // How it is set up is the dashboard's business, not the stream's. + assert!(!timers.contains("per_sub_s")); + assert_eq!(t.overlay_json().1, timers); + assert_eq!(t.to_json().to_json(), t.to_json().to_json()); + } + + #[test] + fn a_setup_is_checked_before_it_is_kept() { + let ok = json::parse( + r#"{"goals":[{"id":"subs","name":" Sub goal ","counts":"subs","target":50}], + "timers":[{"id":"t1","name":"Subathon","start_s":3600,"per_sub_s":300}]}"#, + ) + .unwrap(); + let (goals, timers) = parse_setup(&ok).unwrap(); + assert_eq!( + (goals[0].name.as_str(), goals[0].counts), + ("Sub goal", Counts::Subs) + ); + assert!(timers[0].tiers && timers[0].max_s == 0); + assert_eq!(Goal::from_json(&goals[0].to_json()).unwrap(), goals[0]); + assert_eq!(Timer::from_json(&timers[0].to_json()).unwrap(), timers[0]); + + for bad in [ + r#"{"goals":[{"id":"Sub Goal","name":"x","target":5}]}"#, + r#"{"goals":[{"id":"a","name":"","target":5}]}"#, + r#"{"goals":[{"id":"a","name":"x","target":0}]}"#, + r#"{"timers":[{"id":"a","name":"x","start_s":0}]}"#, + r#"{"goals":[{"id":"a","name":"x","target":5},{"id":"a","name":"y","target":5}]}"#, + ] { + assert!(parse_setup(&json::parse(bad).unwrap()).is_err(), "{bad}"); + } + assert_eq!(parse_setup(&Value::Null).unwrap(), (vec![], vec![])); + } + + #[test] + fn a_countdown_reads_like_a_clock() { + assert_eq!(clock_text(0), "0:00"); + assert_eq!(clock_text(1), "0:01"); + assert_eq!(clock_text(245_000), "4:05"); + assert_eq!(clock_text(3_729_000), "1:02:09"); + } +} diff --git a/src/integrations/host.rs b/src/integrations/host.rs index b2846d7..69adfbd 100644 --- a/src/integrations/host.rs +++ b/src/integrations/host.rs @@ -47,6 +47,48 @@ pub struct HttpResponse { pub body: String, } +/// A Twitch command a chat step's own words start with (`/timeout {user} +/// 10m`). Twitch chat stopped running these in 2023; the API runs them. +#[derive(Debug, PartialEq, Eq)] +pub enum ChatCommand { + Timeout { + login: String, + seconds: u32, + reason: String, + }, + Ban { + login: String, + reason: String, + }, + Unban { + login: String, + }, + Shoutout { + login: String, + }, + Announce { + text: String, + }, +} + +impl ChatCommand { + /// What it does, for the run's log: "time out ana for 10m". + pub fn describe(&self) -> String { + match self { + Self::Timeout { login, seconds, .. } => { + format!( + "time out {login} for {}", + fmt_delay(seconds.saturating_mul(1000)) + ) + } + Self::Ban { login, .. } => format!("ban {login}"), + Self::Unban { login } => format!("lift {login}'s ban or timeout"), + Self::Shoutout { login } => format!("shout out {login}"), + Self::Announce { text } => format!("announce \"{text}\""), + } + } +} + pub struct ClipInfo { pub id: String, pub url: String, @@ -117,7 +159,17 @@ pub trait Host: Send + Sync { /// Post in the connected channel's chat. `reply_to` is the id of the /// message being answered, when there is one. fn chat(&self, text: &str, as_bot: bool, reply_to: Option<&str>) -> Result<(), String>; + /// Run a Twitch command in the connected channel, as its broadcaster. + fn chat_command(&self, command: &ChatCommand) -> Result<(), String>; fn marker(&self, description: &str) -> Result<(), String>; + /// Set the stream's title, its category (by a name as someone types + /// it), or both. Answers with the category's name on Twitch, when one + /// was asked for. + fn channel( + &self, + title: Option<&str>, + category: Option<&str>, + ) -> Result, String>; /// Switch a channel points reward made here on or off, by its title. fn reward(&self, title: &str, enabled: bool) -> Result<(), String>; fn clip(&self) -> Result; @@ -131,6 +183,8 @@ pub trait Host: Send + Sync { fn file(&self, path: &str, text: &str, append: bool) -> Result<(), String>; /// Put a card on the alerts browser source. fn overlay(&self, card: super::alerts::Card); + /// Add an event to the OBS dock's list, with the alert to replay. + fn dock_event(&self, group: &str, text: &str, card: Option); /// Ask OBS to do something, or what it's doing, through its WebSocket /// server. A check answers with values; an action with none. fn obs(&self, action: super::obsws::Action) -> Result; diff --git a/src/integrations/mod.rs b/src/integrations/mod.rs index 1dd454c..3976736 100644 --- a/src/integrations/mod.rs +++ b/src/integrations/mod.rs @@ -11,6 +11,8 @@ //! converted into a "command from a website". //! - `runner`: runs one handler's steps against a `host::Host`. //! - `alerts`: what "Show on stream" steps put on the alerts page in OBS. +//! - `feed`: the OBS dock's events list, which "Dock event" steps add to. +//! - `goals`: goals and timers, counted from events and drawn by overlays. //! - `host` / `effects`: the live state and I/O a run needs, and the real //! implementation of both. //! - `engine`: the isolated thread that matches events to integrations. @@ -32,6 +34,8 @@ pub mod clock; pub mod effects; pub mod engine; pub mod event; +pub mod feed; +pub mod goals; pub mod host; pub mod lights; pub mod model; diff --git a/src/integrations/model.rs b/src/integrations/model.rs index 1752da0..a3b46f1 100644 --- a/src/integrations/model.rs +++ b/src/integrations/model.rs @@ -16,9 +16,14 @@ use std::collections::BTreeMap; pub const MAX_INTEGRATIONS: usize = 100; /// Room for the "every event" webhook, one trigger per event. pub const MAX_HANDLERS: usize = 32; -pub const MAX_STEPS: usize = 64; +/// Enough for a chat game with its paths spelled out (a slot machine with +/// a pay table, streaks and a jackpot takes about 64), and still a bound +/// on what one imported recipe can bring. `web/integrations.js` has the +/// same number. +pub const MAX_STEPS: usize = 256; pub const MAX_DEPTH: usize = 4; const MAX_NAME_LEN: usize = 80; +pub const MAX_FOLDER_LEN: usize = 40; const MAX_PARAM_LEN: usize = 4000; const MAX_ID_LEN: usize = 40; @@ -26,6 +31,8 @@ const MAX_ID_LEN: usize = 40; pub struct Integration { pub id: String, pub name: String, + /// The folder the dashboard lists it in, or empty for none. + pub folder: String, pub enabled: bool, /// Catalog entry this came from, or empty when built by hand. pub preset: String, @@ -310,10 +317,17 @@ pub enum StepKind { Call, /// Switch a channel points reward made here on or off. Reward, + /// Set the stream's title, its category, or both, on Twitch. + Channel, + /// Move a goal or drive a timer (see `goals`). + Goal, + /// Add a line to the OBS dock's events list, with the alert the run + /// showed to replay (see `feed`). + Dock, } impl StepKind { - pub const ALL: [StepKind; 25] = [ + pub const ALL: [StepKind; 28] = [ StepKind::Discord, StepKind::Chat, StepKind::Phone, @@ -339,6 +353,9 @@ impl StepKind { StepKind::WaitChat, StepKind::Call, StepKind::Reward, + StepKind::Channel, + StepKind::Goal, + StepKind::Dock, ]; pub fn id(self) -> &'static str { @@ -368,6 +385,9 @@ impl StepKind { StepKind::WaitChat => "wait_chat", StepKind::Call => "call", StepKind::Reward => "reward", + StepKind::Channel => "channel", + StepKind::Goal => "goal", + StepKind::Dock => "dock", } } @@ -396,6 +416,7 @@ impl StepKind { StepKind::Overlay => &[("text", "write what shows on stream")], StepKind::EditText => &[("op", "pick how to change the text")], StepKind::Timeline => &[("text", "write the timeline line")], + StepKind::Dock => &[("text", "write the event's line")], StepKind::Obs => &[("action", "pick what to do in OBS")], StepKind::Light => &[ ("device", "pick the light or plug"), @@ -404,12 +425,18 @@ impl StepKind { StepKind::Viewer => &[("value", "name the viewer value, like coins")], StepKind::Call => &[("block", "pick the block to run")], StepKind::Reward => &[("title", "pick the channel points reward")], + StepKind::Goal => &[ + ("id", "pick the goal or timer"), + ("action", "pick what happens to it"), + ], StepKind::WaitDelay | StepKind::Stop | StepKind::Marker | StepKind::Clip | StepKind::Math - | StepKind::WaitChat => &[], + | StepKind::WaitChat + // A title or a category: either is enough (see `validate_steps`). + | StepKind::Channel => &[], } } @@ -492,6 +519,11 @@ impl Integration { if self.name.chars().count() > MAX_NAME_LEN { errors.push(format!("the name is longer than {MAX_NAME_LEN} characters")); } + if self.folder.chars().count() > MAX_FOLDER_LEN { + errors.push(format!( + "the folder's name is longer than {MAX_FOLDER_LEN} characters" + )); + } if self.handlers.is_empty() { incomplete.push("add at least one trigger".to_string()); } @@ -515,6 +547,7 @@ impl Integration { validate_steps(&handler.steps, 1, &mut errors, missing); } self.block_problems(&mut errors, &mut incomplete); + self.moderation_problems(&mut errors); errors.dedup(); incomplete.dedup(); Problems { @@ -551,6 +584,52 @@ impl Integration { } } + /// A ban or timeout of whoever a viewer typed runs only from a chat + /// command or message just mods can use: from anything else (or a + /// block that runs it) any viewer could moderate anyone. + fn moderation_problems(&self, errors: &mut Vec) { + if !self.unsafe_moderation().is_empty() { + errors.push( + "a ban or timeout of who a viewer typed needs a command only mods can use \ + (or name {user}: the viewer themself)" + .to_string(), + ); + } + } + + /// The triggers any viewer can set off whose steps (or the blocks they + /// run) ban, time out or unban someone a viewer could pick. A switched + /// off one counts, as it can be switched on. + fn unsafe_moderation(&self) -> Vec { + let blocks: BTreeMap<&str, &[Step]> = self + .handlers + .iter() + .filter_map(|h| match &h.trigger { + Trigger::Block { name } => Some((name.trim(), h.steps.as_slice())), + _ => None, + }) + .collect(); + self.handlers + .iter() + .enumerate() + .filter(|(_, h)| !matches!(h.trigger, Trigger::Block { .. }) && !mods_only(&h.trigger)) + .filter(|(_, h)| moderates_viewer_pick(&h.steps, &blocks, &mut Vec::new())) + .map(|(index, _)| index) + .collect() + } + + /// This integration as it may run: a trigger that would let any viewer + /// moderate anyone stays off, as saving it would be refused. One saved + /// before chat steps ran Twitch commands (when `/timeout {target}` was + /// only posted as text) would otherwise start moderating for real. + pub fn runnable(&self) -> Integration { + let mut safe = self.clone(); + for index in self.unsafe_moderation() { + safe.handlers[index].enabled = false; + } + safe + } + /// Whether any step runs a program or writes a file. pub fn has_local_effects(&self) -> bool { let mut found = false; @@ -564,6 +643,7 @@ impl Integration { json::obj([ ("id", json::str(&self.id)), ("name", json::str(&self.name)), + ("folder", json::str(&self.folder)), ("enabled", Value::Bool(self.enabled)), ("preset", json::str(&self.preset)), ("cooldown_ms", Value::Num(self.cooldown_ms as f64)), @@ -588,6 +668,7 @@ impl Integration { Ok(Integration { id: v.str_or("id", "").to_string(), name: v.str_or("name", "").trim().to_string(), + folder: folder_name(v.str_or("folder", "")), enabled: v.bool_or("enabled", true), preset: v.str_or("preset", "").to_string(), cooldown_ms: v.u64_or("cooldown_ms", 0), @@ -597,6 +678,16 @@ impl Integration { } } +/// A folder's name as it is kept: one line (it's a heading on the +/// dashboard), trimmed. +pub fn folder_name(text: &str) -> String { + text.chars() + .map(|c| if c.is_control() { ' ' } else { c }) + .collect::() + .trim() + .to_string() +} + /// The hotkeys and MIDI pads that switched-on integrations listen for, /// each once. pub fn shortcuts(integrations: &[Integration]) -> (Vec, Vec) { @@ -759,6 +850,63 @@ fn valid_block_name(name: &str) -> bool { !name.trim().is_empty() && name.len() <= MAX_ID_LEN && name.chars().all(fits) } +/// Whether a ban or timeout naming `who` can only reach the viewer themself +/// (`{user}`) or one name written into the step. Anything else filled in +/// could be someone a viewer picked: `{target}`, `{arg1}`, `{message}`, +/// `{words}`, a reply waited for, or a value a step set from any of them. +fn names_self_or_fixed(who: &str) -> bool { + let who = who.trim_start_matches('@'); + who == "{user}" || !who.contains('{') +} + +/// Whether `steps` (or a block they run) ban, time out or unban whoever a +/// viewer typed: `/ban {target}`. A switched-off step counts, as it can be +/// switched on. +fn moderates_viewer_pick<'a>( + steps: &'a [Step], + blocks: &BTreeMap<&'a str, &'a [Step]>, + seen: &mut Vec<&'a str>, +) -> bool { + let mut found = false; + let mut runs = Vec::new(); + visit_steps(steps, &mut |step| { + if step.kind == StepKind::Call { + runs.push(step.param("block").trim()); + } + if step.kind != StepKind::Chat { + return; + } + let Some(rest) = step.param("text").trim_start().strip_prefix('/') else { + return; + }; + let mut words = rest.split_whitespace(); + let command = words.next().unwrap_or("").to_ascii_lowercase(); + let who = words.next().unwrap_or(""); + found |= matches!(command.as_str(), "ban" | "timeout" | "unban" | "untimeout") + && !names_self_or_fixed(who); + }); + found + || runs.into_iter().any(|name| { + if seen.contains(&name) { + return false; + } + seen.push(name); + blocks + .get(name) + .is_some_and(|steps| moderates_viewer_pick(steps, blocks, seen)) + }) +} + +/// A chat command or message only mods (or the streamer) can start. +fn mods_only(trigger: &Trigger) -> bool { + match trigger { + Trigger::ChatCommand { roles, .. } | Trigger::ChatMessage { roles, .. } => { + !roles.everyone && !roles.subs && !roles.vips + } + _ => false, + } +} + /// The steps of each switched-on block of `integration`, by name. pub fn blocks(integration: &Integration) -> BTreeMap> { integration @@ -842,6 +990,17 @@ fn validate_steps( if step.kind == StepKind::Light { light_step_problems(step, missing, errors); } + // Either is enough: `!settitle` sets one, `!setgame` the other. + if step.kind == StepKind::Channel + && step.param("title").trim().is_empty() + && step.param("category").trim().is_empty() + { + missing.push("write the stream's new title or category".to_string()); + } + // Like a reward: chat never picks which goal or timer is moved. + if step.kind == StepKind::Goal && step.param("id").contains('{') { + errors.push("a goal's or timer's name can't use variables".to_string()); + } // The value's name is part of `{viewer.}`: fixed, and plain. let value = step.param("value").trim(); if step.kind == StepKind::Viewer && !value.is_empty() && !super::viewers::valid_name(value) @@ -1462,6 +1621,7 @@ mod tests { Integration { id: "tell-chat".into(), name: "Tell chat".into(), + folder: String::new(), enabled: true, preset: "tell_chat".into(), cooldown_ms: 60_000, @@ -1503,6 +1663,50 @@ mod tests { } } + #[test] + fn a_viewer_cannot_pick_who_gets_banned() { + let says = "a ban or timeout of who a viewer typed"; + let with = |roles: Roles, text: &str| { + let mut i = sample(); + i.handlers[1].trigger = Trigger::ChatCommand { + command: "!bonk".into(), + aliases: vec![], + roles, + user_cooldown_ms: 0, + cooldown_reply: String::new(), + }; + i.handlers[1].steps = vec![Step::new(StepKind::Chat, &[("text", text)])]; + i.validate().iter().any(|e| e.starts_with(says)) + }; + assert!(with(Roles::EVERYONE, "/timeout {target} 60")); + assert!(with(Roles::EVERYONE, "/BAN {arg1} spam")); + assert!(!with(Roles::MODS, "/timeout {target} 60")); + assert!(!with(Roles::EVERYONE, "/timeout {user} 60 roulette")); + assert!(!with(Roles::EVERYONE, "/timeout @{user} 60")); + assert!(!with(Roles::EVERYONE, "/ban nightbot_spam")); + assert!(!with(Roles::EVERYONE, "/shoutout {target}")); + // Whatever else fills it in could be a viewer's pick: a cheer's + // words, a reply waited for, a value a step set from what they typed. + assert!(with(Roles::EVERYONE, "/timeout {words} 60")); + assert!(with(Roles::EVERYONE, "/ban {pick.message}")); + assert!(with(Roles::EVERYONE, "/unban {who}")); + // Through a block it runs, from a trigger anyone sets off. + let mut i = sample(); + i.handlers[0].steps = vec![Step::new(StepKind::Call, &[("block", "bonk")])]; + i.handlers.push(Handler { + enabled: true, + trigger: Trigger::Block { + name: "bonk".into(), + }, + steps: vec![Step::new(StepKind::Chat, &[("text", "/ban {message}")])], + }); + assert!(i.validate().iter().any(|e| e.starts_with(says))); + // Saved before the check, it runs with that trigger off, the rest on. + let runs = i.runnable(); + assert!(!runs.handlers[0].enabled); + assert_eq!(runs.handlers[2].enabled, i.handlers[2].enabled); + } + #[test] fn blocks_are_named_apart_and_must_exist_to_be_run() { let block = |name: &str| Handler { @@ -1687,6 +1891,25 @@ mod tests { i } + /// A folder is one line, kept with the integration, and not too long. + #[test] + fn an_integration_keeps_its_folder() { + let mut i = sample(); + i.folder = "Alerts".into(); + assert_eq!(Integration::from_json(&i.to_json()).unwrap(), i); + let mut v = i.to_json(); + if let Value::Obj(fields) = &mut v { + for (k, val) in fields.iter_mut() { + if k == "folder" { + *val = json::str(" Chat\ngames "); + } + } + } + assert_eq!(Integration::from_json(&v).unwrap().folder, "Chat games"); + i.folder = "x".repeat(MAX_FOLDER_LEN + 1); + assert!(i.validate().iter().any(|e| e.contains("folder"))); + } + /// The handler's steps and three checks inside each other make /// MAX_DEPTH levels, as deep as the builder lets a step go: that saves /// and loads. One check more is refused, by both. diff --git a/src/integrations/presets.rs b/src/integrations/presets.rs index 9cb4f0c..c76d63f 100644 --- a/src/integrations/presets.rs +++ b/src/integrations/presets.rs @@ -36,6 +36,15 @@ pub const PRESETS: &[Preset] = &[ recommended: true, preview: ("overlay", "Nat is raiding with 42 viewers", ""), }, + Preset { + id: "hype_alerts", + name: "Hype Twitch alerts", + category: "alerts", + description: "Twitch alerts with bigger ones built in: cheers from 1,000 and 10,000 bits, raids from 50 viewers and gift bombs from 10 and 50 subs play a bigger design for longer. Use it instead of Twitch alerts.", + needs: &["twitch", "dock"], + recommended: false, + preview: ("overlay", "Nat cheered 10000 bits", ""), + }, Preset { id: "crash_alert", name: "OBS crash alert", @@ -237,6 +246,15 @@ pub const PRESETS: &[Preset] = &[ recommended: false, preview: ("json", "", ""), }, + Preset { + id: "stream_log", + name: "Stream log in the dock", + category: "auto", + description: "Your stream's moments in the OBS dock's Events, each in a group to filter: crashes and recoveries, platforms dropping and coming back, delay changes, highlights and chapters.", + needs: &["dock"], + recommended: false, + preview: ("dock", "YouTube dropped: connection timed out", ""), + }, Preset { id: "crash_counter", name: "Crash counter", @@ -738,6 +756,8 @@ pub fn build(preset_id: &str, id: String, discord_channel: &str) -> Option twitch_alerts(), + "hype_alerts" => hype_alerts(), + "stream_log" => stream_log(), "crash_counter" => vec![on( EventKind::HoldOpened, vec![ @@ -757,6 +777,7 @@ pub fn build(preset_id: &str, id: String, discord_channel: &str) -> Option Vec { }] } -/// One "Show on stream" alert per Twitch event, each playing the alerts -/// page's theme sound. A gifted sub reaches each viewer as a sub too: -/// those are left to the one gift alert. +/// Each Twitch alert: its event, the alert's small title and words, the +/// viewer's own words it shows (a resub's message, a cheer's without the +/// cheers), and its line in the OBS dock's events with the group the dock +/// filters by. +const TWITCH_ALERTS: &[(EventKind, &str, &str, &str, &str, &str)] = &[ + ( + EventKind::TwitchFollow, + "New follower", + "{user}", + "", + "Follows", + "{user} followed", + ), + ( + EventKind::TwitchSub, + "New sub", + "{user} just subscribed", + "", + "Subs", + "{user} subscribed (tier {tier})", + ), + ( + EventKind::TwitchResub, + "Resub", + "{user}: {months#month#months}", + "{message}", + "Subs", + "{user} resubscribed: {months#month#months}", + ), + ( + EventKind::TwitchGift, + "Gifted subs", + "{user} gifted {count#sub#subs}", + "", + "Subs", + "{user} gifted {count#sub#subs}", + ), + ( + EventKind::TwitchCheer, + "Bits", + "{user} cheered {bits#bit#bits}", + "{words}", + "Bits", + "{user} cheered {bits#bit#bits}", + ), + ( + EventKind::TwitchRaid, + "Raid", + "{user} is raiding with {viewers#viewer#viewers}", + "", + "Raids", + "{user} raided with {viewers#viewer#viewers}", + ), +]; + +/// One "Show on stream" alert per Twitch event, each playing its design's +/// sound and listed in the OBS dock. A gifted sub reaches each viewer as a +/// sub too: those are left to the one gift alert. fn twitch_alerts() -> Vec { - let alert = |title: &str, text: &str| { - Step::new( - StepKind::Overlay, - &[ - ("title", title), - ("text", text), - ("seconds", "6"), - ("sound", super::alerts::THEME_SOUND), - ], - ) - }; - // Welcoming raiders in chat is the streamer's call: off to start. - let mut welcome = chat("Welcome, raiders from {user}!"); - welcome.enabled = false; - vec![ - on( - EventKind::TwitchFollow, - vec![alert("New follower", "{user}")], + TWITCH_ALERTS + .iter() + .map(|&(kind, title, text, message, group, line)| { + let alert = Step::new( + StepKind::Overlay, + &[ + ("title", title), + ("text", text), + ("message", message), + ("seconds", "6"), + ("sound", super::alerts::THEME_SOUND), + ], + ); + let mut steps = vec![alert, dock_line(group, line)]; + if kind == EventKind::TwitchRaid { + // Welcoming raiders in chat is the streamer's call: off to start. + let mut welcome = chat("Welcome, raiders from {user}!"); + welcome.enabled = false; + steps.push(welcome); + } + match kind { + EventKind::TwitchSub => on_filtered(kind, &[("gift", "no")], steps), + _ => on(kind, steps), + } + }) + .collect() +} + +/// Hype alerts' bigger ones: (event, the amount it goes by, from how much, +/// the small title, the design, seconds on screen). Smallest first per +/// event. Each plays its design's sound, louder. +const HYPE_TIERS: &[(EventKind, &str, u32, &str, &str, &str)] = &[ + ( + EventKind::TwitchCheer, + "bits", + 1_000, + "Big cheer!", + "alert-banner", + "8", + ), + ( + EventKind::TwitchCheer, + "bits", + 10_000, + "Huge cheer!", + "alert-cinema", + "10", + ), + ( + EventKind::TwitchRaid, + "viewers", + 50, + "Big raid!", + "alert-cinema", + "10", + ), + ( + EventKind::TwitchGift, + "count", + 10, + "Gift bomb!", + "alert-banner", + "8", + ), + ( + EventKind::TwitchGift, + "count", + 50, + "Huge gift bomb!", + "alert-cinema", + "10", + ), +]; +const HYPE_VOLUME: &str = "90"; + +/// Twitch alerts with bigger ones: from an amount up, the alert is another +/// one (`HYPE_TIERS`). Kept as the editor's "Bigger ones" keeps them: "if +/// {bits} is less than 1000, the alert; otherwise the next", each check +/// inside the last, so the editor and the builder both show them. +fn hype_alerts() -> Vec { + let mut handlers = twitch_alerts(); + for handler in &mut handlers { + let Trigger::Event { kind, .. } = &handler.trigger else { + continue; + }; + let tiers: Vec<_> = HYPE_TIERS.iter().filter(|t| t.0 == *kind).collect(); + let Some(&&(_, amount, ..)) = tiers.first() else { + continue; + }; + let alert = handler.steps.remove(0); + let bigger = tiers.iter().map(|&&(_, _, _, title, design, seconds)| { + with( + alert.clone(), + &[ + ("title", title), + ("design", design), + ("seconds", seconds), + ("volume", HYPE_VOLUME), + ], + ) + }); + let alerts: Vec = std::iter::once(alert.clone()).chain(bigger).collect(); + // From the biggest in: each check sends anything smaller to its + // own alert, the rest on to the next. + let mut chain = alerts[alerts.len() - 1].clone(); + for n in (0..tiers.len()).rev() { + let mut check = Step::new( + StepKind::If, + &[ + ("left", &format!("{{{amount}}}")), + ("op", "less"), + ("right", &tiers[n].2.to_string()), + ], + ); + check.then = vec![alerts[n].clone()]; + check.otherwise = vec![chain]; + chain = check; + } + handler.steps.insert(0, chain); + } + handlers +} + +/// InstantClone's own moments listed in the OBS dock's Events, each in a +/// group the dock filters by. +fn stream_log() -> Vec { + [ + (EventKind::StreamStarted, "Stream", "Stream started"), + ( + EventKind::StreamEnded, + "Stream", + "Stream ended after {duration}: {crashes#crash#crashes}, {drops#drop#drops}", ), - on_filtered( - EventKind::TwitchSub, - &[("gift", "no")], - vec![alert("New sub", "{user} just subscribed")], + ( + EventKind::HoldOpened, + "Crashes", + "OBS dropped ({reason}): holding for {hold}", ), - on( - EventKind::TwitchResub, - vec![alert("Resub", "{user}: {months#month#months}")], + ( + EventKind::ObsBack, + "Crashes", + "OBS is back after {down_for}", ), - on( - EventKind::TwitchGift, - vec![alert("Gifted subs", "{user} gifted {count#sub#subs}")], + (EventKind::HoldExpired, "Crashes", "The {hold} hold ran out"), + ( + EventKind::DestinationDropped, + "Platforms", + "{destination} dropped: {reason}", ), - on( - EventKind::TwitchCheer, - vec![alert("Bits", "{user} cheered {bits#bit#bits}")], + ( + EventKind::DestinationRecovered, + "Platforms", + "{destination} is back after {down_for}", ), - on( - EventKind::TwitchRaid, - vec![ - alert("Raid", "{user} is raiding with {viewers#viewer#viewers}"), - welcome, - ], + ( + EventKind::DestinationUnstable, + "Platforms", + "{destination} dropped {drops} times in {within}", + ), + (EventKind::DelayOn, "Delay", "Delay on: {delay}"), + ( + EventKind::DelayChanged, + "Delay", + "Delay changed from {previous} to {delay}", ), + (EventKind::DelayOff, "Delay", "Delay off"), ] + .iter() + .map(|&(kind, group, text)| on(kind, vec![dock_line(group, text)])) + .chain(std::iter::once(on_filtered( + EventKind::TimelineUpdated, + &[("kind", "highlight, chapter")], + vec![dock_line("Moments", "{line}")], + ))) + .collect() +} + +fn dock_line(group: &str, line: &str) -> Step { + Step::new(StepKind::Dock, &[("group", group), ("text", line)]) +} + +/// Bring Twitch alerts from before 0.1.17 up to date, once: a resub and a +/// cheer show the viewer's words, and each alert is listed in the OBS +/// dock's events, right after it. One that has either already (a step +/// listing it in the dock, or an alert that says what to do with the +/// viewer's words, even nothing) is left as it is. Returns whether +/// anything changed. +pub fn upgrade_twitch_alerts(integration: &mut Integration) -> bool { + if integration.preset != "twitch_alerts" { + return false; + } + let mut seen_new = false; + for handler in &integration.handlers { + super::model::visit_steps(&handler.steps, &mut |s| { + seen_new |= s.kind == StepKind::Dock + || (s.kind == StepKind::Overlay && s.params.contains_key("message")); + }); + } + if seen_new { + return false; + } + let mut changed = false; + for handler in &mut integration.handlers { + let Trigger::Event { kind, .. } = &handler.trigger else { + continue; + }; + let Some(&(_, _, _, message, group, line)) = + TWITCH_ALERTS.iter().find(|row| row.0 == *kind) + else { + continue; + }; + let Some(at) = handler + .steps + .iter() + .position(|s| s.kind == StepKind::Overlay) + else { + continue; + }; + handler.steps[at] + .params + .insert("message".into(), message.into()); + handler.steps.insert(at + 1, dock_line(group, line)); + changed = true; + } + changed } /// The on-air state as a light on the network, picked in the editor. @@ -1472,6 +1729,7 @@ pub fn legacy_alerts(id: String, channel: &str) -> Integration { preset: "stream_alerts".to_string(), cooldown_ms: 0, quiet: None, + folder: String::new(), // Ordered by the life of a stream; the card previews the first. handlers: vec![ on( @@ -1903,8 +2161,66 @@ mod tests { } } + /// Hype alerts' bigger ones are the editor's "Bigger ones": checks on + /// the amount, smallest first, the alert as it was below them all. + #[test] + fn hype_alerts_go_bigger_from_an_amount_up() { + let i = build("hype_alerts", "h".into(), "").unwrap(); + let cheer = i + .handlers + .iter() + .find(|h| { + matches!( + h.trigger, + Trigger::Event { + kind: EventKind::TwitchCheer, + .. + } + ) + }) + .unwrap(); + let check = &cheer.steps[0]; + assert_eq!( + (check.param("left"), check.param("op"), check.param("right")), + ("{bits}", "less", "1000") + ); + assert_eq!(check.then[0].param("title"), "Bits"); + assert_eq!(check.then[0].param("design"), ""); + let next = &check.otherwise[0]; + assert_eq!(next.param("right"), "10000"); + assert_eq!(next.then[0].param("design"), "alert-banner"); + assert_eq!(next.otherwise[0].param("title"), "Huge cheer!"); + assert_eq!(next.otherwise[0].param("message"), "{words}"); + assert_eq!(cheer.steps[1].kind, StepKind::Dock, "listed after it"); + assert!(i.validate().is_empty(), "{:?}", i.validate()); + } + #[test] fn legacy_alerts_validate() { assert!(legacy_alerts("legacy".into(), "chan").validate().is_empty()); } + + /// Twitch alerts as 0.1.16 made them catch up once: the viewer's words + /// and the dock's events, as a new copy has them. Changed since, or + /// caught up already, they stay as they are. + #[test] + fn twitch_alerts_from_before_catch_up_once() { + let fresh = build("twitch_alerts", "t".into(), "").unwrap(); + let mut old = fresh.clone(); + for handler in &mut old.handlers { + handler.steps.retain(|s| s.kind != StepKind::Dock); + handler.steps[0].params.remove("message"); + } + assert!(upgrade_twitch_alerts(&mut old)); + assert_eq!(old, fresh); + assert!(!upgrade_twitch_alerts(&mut old), "only once"); + + // A dock line taken out later stays out. + let mut trimmed = fresh.clone(); + trimmed.handlers[0] + .steps + .retain(|s| s.kind != StepKind::Dock); + assert!(!upgrade_twitch_alerts(&mut trimmed)); + assert!(fresh.validate().is_empty()); + } } diff --git a/src/integrations/recipe.rs b/src/integrations/recipe.rs index 804a7cb..fd0b593 100644 --- a/src/integrations/recipe.rs +++ b/src/integrations/recipe.rs @@ -178,6 +178,8 @@ fn alerts_json(integrations: &[Integration], look: &Look, media_folder: &Path) - /// call tokens and the hotkey, pad or Stream Deck address a button starts /// from. fn make_private(i: &mut Integration) { + // Where the sharer keeps it is theirs: the importer's lists stay theirs. + i.folder.clear(); for h in &mut i.handlers { strip_private(&mut h.steps); match &mut h.trigger { @@ -253,6 +255,12 @@ fn strip_private(steps: &mut [super::model::Step]) { if is_web_link(s.param("sound")) { s.params.insert("sound".into(), THEME_SOUND.into()); } + // A design the sharer made is a file on their PC: the + // importer's alerts play their own default. A ready-made + // one comes along, as every install can make it. + if !super::alerts::is_ready_design(s.param("design")) { + s.params.remove("design"); + } } // A light on someone's own network: the color stays. StepKind::Light => { @@ -365,6 +373,9 @@ fn parse_alerts(v: &Value) -> Result { .collect(); let mut look = Look::from_json(v.get("look").unwrap_or(&Value::Null)); look.css = css_that_stays_here(&look.css); + // Its design is a file on the sharer's PC: taken, the look becomes a + // design of the importer's own (the dashboard makes it). + look.design = String::new(); // A theme sound linked from the web: as for an alert's own, see // `strip_private`. if is_web_link(&look.sound) { @@ -545,6 +556,14 @@ mod tests { assert_eq!(recipe.values, mine[..2]); } + #[test] + fn a_shared_look_leaves_its_design_behind() { + let shared = json::parse(r#"{"look":{"theme":"signal","design":"my-alerts"}}"#).unwrap(); + let alerts = parse_alerts(&shared).unwrap(); + assert_eq!(alerts.look.design, ""); + assert_eq!(alerts.look.theme, "signal"); + } + #[test] fn alerts_linked_from_the_web_stay_behind() { let mut steps = vec![Step::new( @@ -553,10 +572,12 @@ mod tests { ("image", "https://x.example/y.gif"), ("sound", "https://x.example/s.mp3"), ("text", "{user} followed"), + ("design", "my-alerts"), ], )]; strip_private(&mut steps); assert_eq!(steps[0].param("image"), ""); + assert_eq!(steps[0].param("design"), "", "a design stays home"); assert_eq!(steps[0].param("sound"), THEME_SOUND); assert_eq!(steps[0].param("text"), "{user} followed"); let mut upload = vec![Step::new(StepKind::Overlay, &[("sound", "media:ding.mp3")])]; @@ -566,6 +587,13 @@ mod tests { "media:ding.mp3", "an upload stays" ); + let mut ready = vec![Step::new(StepKind::Overlay, &[("design", "alert-banner")])]; + strip_private(&mut ready); + assert_eq!( + ready[0].param("design"), + "alert-banner", + "every install has it" + ); } /// The largest recipe export writes must be one import takes. With the diff --git a/src/integrations/runner.rs b/src/integrations/runner.rs index 6451f93..32b1c1d 100644 --- a/src/integrations/runner.rs +++ b/src/integrations/runner.rs @@ -18,7 +18,8 @@ use super::chat_room::{self, ChatRoom}; use super::clock; use super::event::{Event, EventKind}; use super::host::{ - fmt_clock, fmt_delay, fmt_duration, DiscordCard, DiscordMessage, Host, HttpRequest, LiveState, + fmt_clock, fmt_delay, fmt_duration, ChatCommand, DiscordCard, DiscordMessage, Host, + HttpRequest, LiveState, }; use super::lights; use super::model::{DiscordChannel, PhoneConnection, Step, StepKind}; @@ -80,6 +81,9 @@ pub struct RunEnv { pub saved: Arc>>, /// Called after any of those three changes, to save it. pub data_changed: Box, + /// Goals and timers (`{goal.subs}`, `{timer.subathon}`), which save + /// themselves. + pub goals: Arc, /// Who chatted lately, and the messages steps are waiting for. pub chat_room: Arc, /// The streamer's own values (`{my.name}`), by name. @@ -236,6 +240,8 @@ struct Runner { /// A `stop` step asked for the run to stay out of the history. quiet: bool, call_depth: usize, + /// The alert this run showed last, for a dock event to replay. + shown: Option, } /// The values that last between runs: counters, what viewers hold, and @@ -323,6 +329,7 @@ pub async fn run( kept, quiet: false, call_depth: 0, + shown: None, }; let flow = runner.steps(steps).await; let status = match (flow, runner.failed) { @@ -390,6 +397,7 @@ impl Runner { vars: &self.ctx.vars, kept: &self.kept, user_values: &self.env.user_values, + goals: &self.env.goals, timeline: &self.env.timeline, live, } @@ -452,7 +460,10 @@ impl Runner { StepKind::Math => self.math(step), StepKind::WaitChat => self.wait_chat(step).await, StepKind::Reward => self.reward(step).await, + StepKind::Channel => self.channel(step).await, + StepKind::Goal => self.goal(step), StepKind::Overlay => self.overlay(step), + StepKind::Dock => self.dock(step), StepKind::EditText => self.edit_text(step), StepKind::Timeline => self.timeline(step), StepKind::Obs => self.obs(step).await, @@ -807,7 +818,20 @@ impl Runner { } async fn chat(&mut self, step: &Step) { - let text = safe_chat_text(&self.marked(self.fill(step, "text"))); + let text = match chat_command_of(step.param("text")) { + // `/me` is the one command chat still runs itself. + Some(("me", words)) => { + let said = safe_chat_text(&self.marked(self.fill_text(words))); + let said: String = said.chars().take(MAX_CHAT_LEN - 4).collect(); + if said.is_empty() { + said + } else { + format!("/me {said}") + } + } + Some((name, words)) => return self.twitch_command(name, words).await, + None => safe_chat_text(&self.marked(self.fill(step, "text"))), + }; if text.is_empty() { self.record("Chat", StepStatus::Skipped, "the message came out empty"); return; @@ -829,6 +853,28 @@ impl Runner { } } + /// A chat step that starts with a Twitch command: its words filled in, + /// then run for the channel. A test never moderates anyone. + async fn twitch_command(&mut self, name: &str, words: &str) { + let label = "Twitch chat"; + let command = match chat_command(name, &self.fill_text(words), &self.ctx.vars) { + Ok(command) => command, + Err(why) => return self.record(label, StepStatus::Failed, why), + }; + let said = command.describe(); + if self.dry(label, said.clone()) { + return; + } + if self.ctx.test { + let detail = format!("would {said} (tests never run chat commands)"); + return self.record(label, StepStatus::Skipped, detail); + } + match self.blocking(move |h| h.chat_command(&command)).await { + Ok(()) => self.record(label, StepStatus::Ok, said), + Err(e) => self.record(label, StepStatus::Failed, e), + } + } + async fn phone(&mut self, step: &Step) { let phone = self.env.phone.clone(); if !phone.is_set() { @@ -1100,6 +1146,7 @@ impl Runner { let card = alerts::Card { title, text: text.clone(), + message: self.fill(step, "message"), seconds, color: step.param("color").trim().to_string(), sound: step.param("sound").trim().to_string(), @@ -1109,11 +1156,37 @@ impl Runner { .parse() .unwrap_or(alerts::DEFAULT_VOLUME), image: step.param("image").trim().to_string(), + design: step.param("design").trim().to_string(), }; + self.shown = Some(card.clone()); self.host.overlay(card); self.record("On stream", StepStatus::Ok, text); } + /// A line in the OBS dock's events, in its group, with the alert this + /// run showed before it to replay (unless `replay` is "no"). + fn dock(&mut self, step: &Step) { + let text = self.fill(step, "text"); + if text.trim().is_empty() { + self.record("Dock event", StepStatus::Skipped, "the line came out empty"); + return; + } + if self.ctx.test { + self.record( + "Dock event", + StepStatus::Skipped, + format!("would add \"{text}\" (tests stay out of the dock)"), + ); + return; + } + let card = match step.param("replay") { + "no" => None, + _ => self.shown.clone(), + }; + self.host.dock_event(&self.fill(step, "group"), &text, card); + self.record("Dock event", StepStatus::Ok, text); + } + /// Ask OBS to switch a scene, show or hide a source, or set a text. async fn obs(&mut self, step: &Step) { let text = match step.param("action") { @@ -1439,6 +1512,105 @@ impl Runner { } } + /// Set the stream's title, its category, or both. A part that comes + /// out blank (`!settitle` with nothing after it) is left as it is. The + /// next steps get `{stream.ok}`, the category as Twitch names it in + /// `{stream.category}`, and `{stream.error}` when Twitch said no. + async fn channel(&mut self, step: &Step) { + let title = self.fill(step, "title").trim().to_string(); + let category = self.fill(step, "category").trim().to_string(); + let given = |text: &String| (!text.is_empty()).then(|| text.clone()); + let (title, category) = (given(&title), given(&category)); + let label = "Twitch channel"; + if title.is_none() && category.is_none() { + self.set("stream.ok", "no"); + let why = "the title and the category both came out empty"; + return self.record(label, StepStatus::Failed, why); + } + let wanted = [ + title.as_ref().map(|t| format!("title \"{t}\"")), + category.as_ref().map(|c| format!("category \"{c}\"")), + ] + .into_iter() + .flatten() + .collect::>() + .join(", "); + if self.ctx.test { + self.set("stream.ok", "yes"); + self.set("stream.category", category.unwrap_or_default()); + let detail = format!("would set {wanted} (tests never change your channel)"); + return self.record(label, StepStatus::Skipped, detail); + } + let done = self + .blocking(move |h| h.channel(title.as_deref(), category.as_deref())) + .await; + match done { + Ok(found) => { + self.set("stream.ok", "yes"); + self.set("stream.category", found.unwrap_or_default()); + self.record(label, StepStatus::Ok, format!("set {wanted}")); + } + Err(e) => { + self.set("stream.ok", "no"); + self.set("stream.error", redact_urls(&e)); + self.record(label, StepStatus::Failed, e); + } + } + } + + /// Move a goal or drive a timer (see `goals`). Which one is fixed by + /// the streamer; how much can come from chat. A timer's amount is in + /// seconds. + fn goal(&mut self, step: &Step) { + use super::goals::{clock_text, GoalChange, TimerChange}; + let (id, action) = (step.param("id").trim(), step.param("action")); + let timer = step.param("what") == "timer"; + let label = format!("{} {id}", if timer { "Timer" } else { "Goal" }); + let typed = self.fill(step, "amount"); + let amount = match (typed.trim(), matches!(action, "add" | "subtract" | "set")) { + (_, false) => 0, + // "Add to the goal" with nothing typed counts one. + ("", true) => 1, + (text, true) => match text.parse::() { + Ok(amount) => amount, + Err(_) => { + let why = format!("\"{text}\" isn't a whole number"); + return self.record(label, StepStatus::Failed, why); + } + }, + }; + if self.ctx.test { + let detail = format!("would {action} (tests never change your goals and timers)"); + return self.record(label, StepStatus::Skipped, detail); + } + let exact = amount.max(0).unsigned_abs(); + let done = if timer { + let change = match action { + "start" => TimerChange::Start, + "pause" => TimerChange::Pause, + "reset" => TimerChange::Reset, + "subtract" => TimerChange::Add(amount.saturating_neg()), + "set" => TimerChange::Set(exact), + _ => TimerChange::Add(amount), + }; + let now = super::twitch::unix_ms(); + self.env.goals.change_timer(id, change, now).map(clock_text) + } else { + let change = match action { + "reset" => GoalChange::Reset, + "subtract" => GoalChange::Add(amount.saturating_neg()), + "set" => GoalChange::Set(exact), + _ => GoalChange::Add(amount), + }; + let moved = self.env.goals.change_goal(id, change); + moved.map(|value| value.to_string()) + }; + match done { + Ok(now) => self.record(label, StepStatus::Ok, now), + Err(why) => self.record(label, StepStatus::Failed, why), + } + } + /// What a "Wait for chat" step hands the next steps. fn heard(&mut self, name: &str, user: &str, login: &str, message: &str) { self.set(format!("{name}.ok"), "yes"); @@ -1531,6 +1703,7 @@ struct Lookup<'a> { vars: &'a BTreeMap, kept: &'a Kept, user_values: &'a BTreeMap, + goals: &'a super::goals::Tracker, timeline: &'a crate::sync::Mutex, live: &'a LiveState, } @@ -1591,6 +1764,18 @@ impl Vars for Lookup<'_> { .to_string(), ); } + // Only a goal or timer that exists: `{goal.json.current}` is still a + // web call's answer saved as "goal", and a typo stays as written. + let goal = name + .strip_prefix("goal.") + .and_then(|goal| self.goals.goal_text(goal)); + let clock = || { + name.strip_prefix("timer.") + .and_then(|timer| self.goals.timer_text(timer, super::twitch::unix_ms())) + }; + if let Some(text) = goal.or_else(clock) { + return Some(text); + } if let Some(found) = self.viewer_value(name) { return Some(found); } @@ -1823,6 +2008,111 @@ fn is_whole_number(text: &str) -> bool { !text.is_empty() && text.bytes().all(|b| b.is_ascii_digit()) } +/// The Twitch command a chat step's own words start with (its name and the +/// words after it), when it is one InstantClone runs. Only the streamer's +/// words can start one: what a viewer typed only fills it in. +fn chat_command_of(template: &str) -> Option<(&'static str, &str)> { + let rest = template.trim_start().strip_prefix('/')?; + let (word, words) = rest.split_once(char::is_whitespace).unwrap_or((rest, "")); + let name = match word.to_ascii_lowercase().as_str() { + "timeout" => "timeout", + "ban" => "ban", + "unban" | "untimeout" => "unban", + "shoutout" | "so" => "shoutout", + "announce" => "announce", + "me" => "me", + _ => return None, + }; + Some((name, words.trim())) +} + +/// The longest timeout Twitch gives: two weeks. +const MAX_TIMEOUT_S: u32 = 1_209_600; + +/// `90`, `10m`, `1h`, `2d` or `1w` in seconds, as Twitch's own /timeout +/// reads it. +fn timeout_seconds(word: &str) -> Option { + let split = word + .find(|c: char| !c.is_ascii_digit()) + .unwrap_or(word.len()); + let (number, unit) = word.split_at(split); + let per = match unit.to_ascii_lowercase().as_str() { + "" | "s" => 1, + "m" => 60, + "h" => 3600, + "d" => 86_400, + "w" => 604_800, + _ => return None, + }; + number.parse::().ok()?.checked_mul(per) +} + +/// A command's words, filled in: who it is about, for how long, and why. +/// The viewer who started the run is named by their login, whatever their +/// display name is written in. +fn chat_command( + name: &str, + filled: &str, + vars: &BTreeMap, +) -> Result { + let mut words = filled.split_whitespace(); + if name == "announce" { + let text: String = filled.trim().chars().take(MAX_CHAT_LEN).collect(); + if text.is_empty() { + return Err("the announcement came out empty".to_string()); + } + return Ok(ChatCommand::Announce { text }); + } + let who = words + .next() + .ok_or("it names nobody: write who, like {user}")?; + // `{user}` or `{target}` filled in: their login, as a name shown can + // differ. An anonymous gift or cheer has none ("Anonymous" is a real + // account, not them). + let login = match ["user", "target"] + .into_iter() + .find(|var| vars.get(*var).is_some_and(|shown| shown == who)) + { + Some(var) => login_of(vars, if var == "user" { "viewer" } else { "target" }).ok_or( + format!("{who} has no Twitch login (an anonymous gift or cheer)"), + )?, + None => viewers::login_key(who), + }; + let plain = |c: char| c.is_ascii_alphanumeric() || c == '_'; + if login.is_empty() || login.len() > 25 || !login.chars().all(plain) { + return Err(format!("\"{who}\" isn't a Twitch login")); + } + let rest: Vec<&str> = words.collect(); + let reason_from = |at: usize| -> String { + rest[at.min(rest.len())..] + .join(" ") + .chars() + .take(500) + .collect() + }; + Ok(match name { + "timeout" => { + let given = rest.first().and_then(|w| timeout_seconds(w)); + let seconds = given.unwrap_or(600); + if seconds == 0 || seconds > MAX_TIMEOUT_S { + return Err("a timeout is 1 second to 2 weeks".to_string()); + } + let reason = reason_from(usize::from(given.is_some())); + ChatCommand::Timeout { + login, + seconds, + reason, + } + } + "ban" => ChatCommand::Ban { + login, + reason: reason_from(0), + }, + "unban" => ChatCommand::Unban { login }, + _ => ChatCommand::Shoutout { login }, + }) +} + /// Keep a chat message one line, within Twitch's limit, and unable to run /// a chat command even when it starts with text a viewer typed. pub fn safe_chat_text(text: &str) -> String { @@ -2204,6 +2494,10 @@ mod tests { .push(format!("chat bot={as_bot} reply={reply_to:?} {text}")); Ok(()) } + fn chat_command(&self, command: &ChatCommand) -> Result<(), String> { + self.calls.lock().push(format!("command {command:?}")); + Ok(()) + } fn marker(&self, d: &str) -> Result<(), String> { self.calls.lock().push(format!("marker {d}")); Ok(()) @@ -2244,6 +2538,12 @@ mod tests { card.title, card.text, card.seconds, card.color, card.sound )); } + fn dock_event(&self, group: &str, text: &str, card: Option) { + let replay = card.map(|c| c.text).unwrap_or_default(); + self.calls + .lock() + .push(format!("dock {group}|{text}|{replay}")); + } fn obs(&self, action: obsws::Action) -> Result { self.calls.lock().push(format!("obs {action:?}")); Ok(match action { @@ -2264,6 +2564,17 @@ mod tests { self.calls.lock().push(format!("reward {title} {enabled}")); Ok(()) } + fn channel( + &self, + title: Option<&str>, + category: Option<&str>, + ) -> Result, String> { + self.calls + .lock() + .push(format!("channel {title:?} {category:?}")); + // Twitch answers with its own name for the category. + Ok(category.map(str::to_uppercase)) + } } fn env() -> Arc { @@ -2281,6 +2592,10 @@ mod tests { viewers: Default::default(), saved: Default::default(), data_changed: Box::new(|| {}), + goals: Arc::new(crate::integrations::goals::Tracker::new( + None, + Default::default(), + )), chat_room: Default::default(), user_values: BTreeMap::from([ ("coin".to_string(), "gem".to_string()), @@ -2450,6 +2765,90 @@ mod tests { assert!(log[0].detail.contains("[TEST] Hi Ana"), "{}", log[0].detail); } + #[test] + fn a_chat_step_starting_with_a_command_runs_it_and_a_viewer_never_starts_one() { + assert_eq!( + chat_command_of("/timeout {user} 1s"), + Some(("timeout", "{user} 1s")) + ); + assert_eq!( + chat_command_of(" /SO {target}"), + Some(("shoutout", "{target}")) + ); + assert_eq!( + chat_command_of("/untimeout {user}"), + Some(("unban", "{user}")) + ); + assert_eq!(chat_command_of("/me waves"), Some(("me", "waves"))); + assert_eq!(chat_command_of("/mod {user}"), None); + assert_eq!(chat_command_of("{message}"), None); + assert_eq!(timeout_seconds("90"), Some(90)); + assert_eq!(timeout_seconds("10m"), Some(600)); + assert_eq!(timeout_seconds("1W"), Some(604_800)); + assert_eq!(timeout_seconds("soon"), None); + assert_eq!(timeout_seconds("99999999999"), None); + } + + #[test] + fn a_command_names_the_viewer_by_their_login_and_reads_how_long() { + let vars: BTreeMap = + [("user", "Ána B"), ("user_login", "ana_b"), ("target", "Bo")] + .into_iter() + .map(|(k, v)| (k.to_string(), v.to_string())) + .collect(); + let timeout = chat_command("timeout", "Ána B 1s too loud", &vars); + assert!(timeout.is_err(), "a display name with a space is two words"); + let timeout = chat_command("timeout", "@Bo 1s too loud", &vars).unwrap(); + assert_eq!( + timeout, + ChatCommand::Timeout { + login: "bo".into(), + seconds: 1, + reason: "too loud".into() + } + ); + let plain = chat_command("timeout", "Bo spam", &vars).unwrap(); + assert_eq!( + plain, + ChatCommand::Timeout { + login: "bo".into(), + seconds: 600, + reason: "spam".into() + } + ); + assert!(chat_command("timeout", "Bo 3w", &vars).is_err()); + assert!(chat_command("ban", "", &vars).is_err()); + assert!(chat_command("announce", " ", &vars).is_err()); + assert!(chat_command("ban", "a/b", &vars).is_err()); + // An anonymous gift has no login: never the account "anonymous". + let anonymous: BTreeMap = [("user", "Anonymous"), ("user_login", "")] + .into_iter() + .map(|(k, v)| (k.to_string(), v.to_string())) + .collect(); + let refused = chat_command("shoutout", "Anonymous", &anonymous).unwrap_err(); + assert!(refused.contains("anonymous gift"), "{refused}"); + } + + #[tokio::test] + async fn a_timeout_step_runs_for_the_viewer_and_a_test_only_says_so() { + let host = Arc::new(FakeHost::default()); + let steps = [Step::new(StepKind::Chat, &[("text", "/timeout {user} 1s")])]; + let said = [("user", "Ána"), ("user_login", "ana")]; + go(&host, &steps, ctx(&said, false)).await; + assert_eq!( + *host.calls.lock(), + [r#"command Timeout { login: "ana", seconds: 1, reason: "" }"#] + ); + let host = Arc::new(FakeHost::default()); + let (_, log) = go(&host, &steps, ctx(&said, true)).await; + assert!(host.calls.lock().is_empty()); + assert!( + log[0].detail.contains("would time out ana for 1s"), + "{}", + log[0].detail + ); + } + #[tokio::test] async fn switched_off_steps_are_skipped_with_what_is_inside() { let host = Arc::new(FakeHost::default()); @@ -2844,6 +3243,44 @@ mod tests { ); } + /// A dock event replays the alert its run showed before it, or none. + #[tokio::test] + async fn a_dock_event_keeps_the_alert_its_run_showed() { + let host = Arc::new(FakeHost::default()); + let listed = |replay: &str| { + Step::new( + StepKind::Dock, + &[ + ("group", "Bits"), + ("text", "{user} cheered"), + ("replay", replay), + ], + ) + }; + let steps = [ + listed(""), + Step::new(StepKind::Overlay, &[("text", "by {user}")]), + listed(""), + listed("no"), + ]; + go(&host, &steps, ctx(&[("user", "Ana")], false)).await; + let (_, log) = go(&host, &steps, ctx(&[("user", "Ana")], true)).await; + assert_eq!( + log[0].status, + StepStatus::Skipped, + "tests stay out of the dock" + ); + assert_eq!( + host.calls.lock().as_slice(), + [ + "dock Bits|Ana cheered|", + "overlay |by Ana|6||", + "dock Bits|Ana cheered|by Ana", + "dock Bits|Ana cheered|", + ] + ); + } + #[tokio::test] async fn edited_text_feeds_later_steps() { let host = Arc::new(FakeHost::default()); @@ -3185,6 +3622,84 @@ mod tests { assert_eq!(log[0].status, StepStatus::Skipped); } + #[tokio::test] + async fn a_channel_step_sets_what_it_is_given_and_a_test_leaves_twitch_alone() { + let steps = [ + Step::new(StepKind::Channel, &[("title", "{args}")]), + Step::new(StepKind::Channel, &[("category", "{args}")]), + ]; + let host = Arc::new(FakeHost::default()); + go(&host, &steps, ctx(&[("args", "valo")], false)).await; + let calls: Vec = host.calls.lock().iter().map(|c| c.to_string()).collect(); + assert_eq!( + calls, + [ + r#"channel Some("valo") None"#, + r#"channel None Some("valo")"# + ] + ); + // `!settitle` with nothing after it: nothing to set, and Twitch isn't asked. + let host = Arc::new(FakeHost::default()); + let (_, log) = go(&host, &steps, ctx(&[("args", " ")], false)).await; + assert!(host.calls.lock().is_empty()); + assert_eq!(log[0].status, StepStatus::Failed); + let host = Arc::new(FakeHost::default()); + let (_, log) = go(&host, &steps, ctx(&[("args", "valo")], true)).await; + assert!(host.calls.lock().is_empty()); + assert_eq!(log[0].status, StepStatus::Skipped); + } + + #[tokio::test] + async fn a_goal_step_moves_goals_and_timers_and_a_test_leaves_them() { + use crate::integrations::goals::{Goal, Timer}; + let env = env(); + let subs = json::parse(r#"{"id":"subs","name":"Sub goal","counts":"subs","target":50}"#); + let break_ = json::parse(r#"{"id":"brb","name":"Break","start_s":300}"#); + env.goals.configure( + &[Goal::from_json(&subs.unwrap()).unwrap()], + &[Timer::from_json(&break_.unwrap()).unwrap()], + ); + let goal = |pairs: &[(&str, &str)]| Step::new(StepKind::Goal, pairs); + let steps = [ + goal(&[("id", "subs"), ("action", "add"), ("amount", "{arg1}")]), + goal(&[("id", "subs"), ("action", "subtract")]), + goal(&[ + ("what", "timer"), + ("id", "brb"), + ("action", "add"), + ("amount", "90"), + ]), + goal(&[("id", "nope"), ("action", "reset")]), + Step::new( + StepKind::SetVar, + &[ + ("name", "said"), + ( + "value", + "{goal.subs}/{goal.subs.target} {timer.brb} {goal.nope|none} {goal.json.x}", + ), + ], + ), + ]; + let host = Arc::new(FakeHost::default()); + // A web call's answer saved as "goal" still reads as one. + let vars = [("arg1", "5"), ("goal.body", r#"{"x":7}"#)]; + let (_, log) = run(&steps, ctx(&vars, false), host, env.clone()).await; + let details: Vec<&str> = log.iter().map(|l| l.detail.as_str()).collect(); + assert_eq!(details[..3], ["5", "4", "6:30"]); + assert_eq!(log[3].status, StepStatus::Failed); + assert_eq!(details[4], "4/50 6:30 none 7"); + + // What chat typed has to be a number, and a test changes nothing. + let host = Arc::new(FakeHost::default()); + let (_, log) = run(&steps, ctx(&[("arg1", "lots")], false), host, env.clone()).await; + assert_eq!(log[0].status, StepStatus::Failed); + let host = Arc::new(FakeHost::default()); + let (_, log) = run(&steps, ctx(&[("arg1", "5")], true), host, env.clone()).await; + assert_eq!(log[0].status, StepStatus::Skipped); + assert_eq!(env.goals.goal_text("subs").unwrap(), "3"); + } + #[tokio::test] async fn a_step_can_name_the_viewer_itself() { let env = env(); @@ -3500,6 +4015,9 @@ mod tests { fn chat(&self, _: &str, _: bool, _: Option<&str>) -> Result<(), String> { Ok(()) } + fn chat_command(&self, _: &ChatCommand) -> Result<(), String> { + Ok(()) + } fn marker(&self, _: &str) -> Result<(), String> { Ok(()) } @@ -3519,6 +4037,7 @@ mod tests { Ok(()) } fn overlay(&self, _: alerts::Card) {} + fn dock_event(&self, _: &str, _: &str, _: Option) {} fn obs(&self, _: obsws::Action) -> Result { Ok(Vec::new()) } @@ -3528,6 +4047,9 @@ mod tests { fn reward(&self, _: &str, _: bool) -> Result<(), String> { Ok(()) } + fn channel(&self, _: Option<&str>, _: Option<&str>) -> Result, String> { + Ok(None) + } } let steps = [Step::new( StepKind::Discord, diff --git a/src/integrations/store.rs b/src/integrations/store.rs index 3df6b23..6704864 100644 --- a/src/integrations/store.rs +++ b/src/integrations/store.rs @@ -1,7 +1,7 @@ //! What integrations keep between runs, in its own file next to the config: //! the Twitch logins, the counters, what each viewer holds, saved texts, -//! when viewers last used a command with a long cooldown, and how often -//! each integration ran. +//! when viewers last used a command with a long cooldown, where each goal +//! and timer stands, and how often each integration ran. //! //! Kept apart from the settings on purpose. Tokens change on their own //! every few hours and counters on every crash; routing those through the @@ -72,6 +72,8 @@ pub struct Store { /// A counter, viewer value, saved text or cooldown worth keeping /// changed since the file was last written. pub dirty: Arc, + /// Where each goal and timer stands (see `goals`). + pub goals: Arc, /// Runs per integration id, for `{uses}`. pub uses: Mutex>, /// Serialises writes so two savers never interleave. @@ -113,6 +115,8 @@ impl Store { .collect(), _ => BTreeMap::new(), }; + let dirty: Arc = Default::default(); + let goals = super::goals::Tracker::new(doc.get("tracking"), dirty.clone()); Store { path, accounts: Mutex::new(Accounts { @@ -123,7 +127,8 @@ impl Store { viewers: Arc::new(Mutex::new(ViewerValues::from_json(doc.get("viewers")))), saved: Arc::new(Mutex::new(saved)), cooldowns: Mutex::new(whole_numbers("cooldowns")), - dirty: Default::default(), + dirty, + goals: Arc::new(goals), uses: Mutex::new(whole_numbers("uses")), write_lock: Mutex::new(()), } @@ -177,6 +182,7 @@ impl Store { ("saved", saved), ("cooldowns", whole_numbers(&self.cooldowns.lock())), ("uses", whole_numbers(&self.uses.lock())), + ("tracking", self.goals.state_json()), ]) }; // On disk before the rename: after a power cut the file is the old @@ -212,6 +218,11 @@ mod tests { store.viewers.lock().apply("coins", "ana", paid).unwrap(); store.saved.lock().insert("winner".into(), "Ana".into()); store.cooldowns.lock().insert("i1#0#ana".into(), 99); + let subs = json::parse(r#"{"id":"subs","name":"Subs","target":50}"#).unwrap(); + let subs = [super::super::goals::Goal::from_json(&subs).unwrap()]; + store.goals.configure(&subs, &[]); + let seven = super::super::goals::GoalChange::Set(7); + store.goals.change_goal("subs", seven).unwrap(); store.save().unwrap(); let back = Store::open(&dir); @@ -222,6 +233,8 @@ mod tests { assert_eq!(back.viewers.lock().get("coins", "ana"), 250); assert_eq!(back.saved.lock().get("winner").unwrap(), "Ana"); assert_eq!(back.cooldowns.lock().get("i1#0#ana"), Some(&99)); + back.goals.configure(&subs, &[]); + assert_eq!(back.goals.goal_text("subs").unwrap(), "7"); } #[test] diff --git a/src/integrations/twitch/auth.rs b/src/integrations/twitch/auth.rs index 008b148..0e8b829 100644 --- a/src/integrations/twitch/auth.rs +++ b/src/integrations/twitch/auth.rs @@ -14,12 +14,14 @@ use crate::json::{self, Value}; /// Chat, VOD markers and clips for the streamer's account, reading the /// channel's follows, subs, bits and ad breaks for Twitch event triggers /// (see `eventsub`), and channel points: hearing redemptions, and making -/// and switching the rewards the streamer sets up here. A login from -/// before rewards could be managed still hears redemptions; managing -/// them asks for a new login. +/// and switching the rewards the streamer sets up here, and the chat +/// commands a chat step runs (timeouts, bans, shoutouts, announcements). A +/// login from before one of these still does the rest; that one asks for a +/// new login. pub const MAIN_SCOPES: &str = "chat:read chat:edit channel:manage:broadcast clips:edit \ moderator:read:followers channel:read:subscriptions bits:read \ - channel:read:redemptions channel:manage:redemptions channel:read:ads"; + channel:read:redemptions channel:manage:redemptions channel:read:ads \ + moderator:manage:banned_users moderator:manage:shoutouts moderator:manage:announcements"; /// A bot account only reads and posts chat. pub const BOT_SCOPES: &str = "chat:read chat:edit"; @@ -41,6 +43,8 @@ pub struct Tokens { pub access: String, pub refresh: String, pub expires_in: u64, + /// What the login allows, when Twitch said. + pub scopes: Option>, } pub enum Poll { @@ -61,6 +65,7 @@ pub enum TokenError { pub struct Validated { pub login: String, pub user_id: String, + pub scopes: Option>, } pub fn start_device(client_id: &str, scopes: &str) -> Result { @@ -143,6 +148,7 @@ pub fn validate(access: &str) -> Result { Ok(Validated { login: v.str_or("login", "").to_string(), user_id: v.str_or("user_id", "").to_string(), + scopes: scope_list(v.get("scopes")), }) } @@ -151,6 +157,48 @@ fn tokens(v: &Value) -> Tokens { access: v.str_or("access_token", "").to_string(), refresh: v.str_or("refresh_token", "").to_string(), expires_in: v.u64_or("expires_in", 3600), + scopes: scope_list(v.get("scope")), + } +} + +/// A token's scopes as Twitch lists them (`["chat:read", ...]`), or None +/// when the answer has no list. +fn scope_list(v: Option<&Value>) -> Option> { + let list = v?.as_array()?; + Some( + list.iter() + .filter_map(|s| s.as_str().map(str::to_string)) + .collect(), + ) +} + +/// What a main login is missing of `MAIN_SCOPES`, each as what it allows: +/// a login from before a scope was asked for keeps working without it, and +/// the dashboard asks for a new login once. +pub fn missing_uses(granted: &[String]) -> Vec<&'static str> { + let mut uses: Vec<&'static str> = MAIN_SCOPES + .split_whitespace() + .filter(|s| !granted.iter().any(|g| g == s)) + .map(scope_use) + .collect(); + uses.dedup(); + uses +} + +fn scope_use(scope: &str) -> &'static str { + match scope { + "moderator:manage:banned_users" => "timeouts and bans from chat steps", + "moderator:manage:shoutouts" => "shoutouts", + "moderator:manage:announcements" => "announcements", + "channel:manage:redemptions" => "making and switching channel points rewards", + "channel:read:redemptions" => "channel points triggers", + "channel:read:ads" => "ad break triggers", + "channel:read:subscriptions" => "sub triggers", + "bits:read" => "bits triggers", + "moderator:read:followers" => "follow triggers", + "channel:manage:broadcast" => "stream markers and the title step", + "clips:edit" => "clips", + _ => "chat", } } @@ -210,4 +258,31 @@ mod tests { "scopes=chat%3Aread%20chat%3Aedit&a=x%26y%3Dz" ); } + + /// A login from before chat steps could moderate says what logging in + /// again would allow; a current one is short of nothing. + #[test] + fn an_older_login_says_what_logging_in_again_allows() { + let all: Vec = MAIN_SCOPES.split_whitespace().map(str::to_string).collect(); + assert!(missing_uses(&all).is_empty()); + let older: Vec = all + .iter() + .filter(|s| !s.starts_with("moderator:manage:")) + .cloned() + .collect(); + assert_eq!( + missing_uses(&older), + [ + "timeouts and bans from chat steps", + "shoutouts", + "announcements" + ] + ); + let v = json::parse(r#"{"scope":["chat:read","bits:read"]}"#).unwrap(); + assert_eq!( + tokens(&v).scopes, + Some(vec!["chat:read".to_string(), "bits:read".to_string()]) + ); + assert_eq!(tokens(&json::parse("{}").unwrap()).scopes, None); + } } diff --git a/src/integrations/twitch/eventsub.rs b/src/integrations/twitch/eventsub.rs index ccc6c0a..e2250f7 100644 --- a/src/integrations/twitch/eventsub.rs +++ b/src/integrations/twitch/eventsub.rs @@ -547,7 +547,8 @@ pub fn to_event(kind_type: &str, e: &Value) -> Option { .with("user", user("user_name")) .with("user_login", e.str_or("user_login", "")) .with("bits", number("bits")) - .with("message", e.str_or("message", "")), + .with("message", e.str_or("message", "")) + .with("words", without_cheers(e.str_or("message", ""))), "channel.raid" => Event::new(EventKind::TwitchRaid) .with("user", user("from_broadcaster_user_name")) .with("user_login", e.str_or("from_broadcaster_user_login", "")) @@ -570,6 +571,22 @@ pub fn to_event(kind_type: &str, e: &Value) -> Option { }) } +/// A cheer's message without its cheers: Twitch writes each as a word of +/// letters then the bits (`Cheer100`, `Kappa50`), which an alert has no use +/// for once it says how many bits came. +fn without_cheers(message: &str) -> String { + message + .split_whitespace() + .filter(|word| { + let digits = word.trim_start_matches(|c: char| c.is_ascii_alphabetic()); + !(digits.len() < word.len() + && !digits.is_empty() + && digits.bytes().all(|b| b.is_ascii_digit())) + }) + .collect::>() + .join(" ") +} + /// Host and path of a Twitch `wss://` address (port 443). fn split_url(url: &str) -> Option<(String, String)> { let rest = url.strip_prefix("wss://")?; @@ -711,6 +728,15 @@ mod tests { } } + #[test] + fn a_cheer_alert_gets_the_words_without_the_cheers() { + assert_eq!(without_cheers("Cheer100 gg Kappa50 wp"), "gg wp"); + // Letters then a number read as a cheer: rare in a message. + assert_eq!(without_cheers("gg2 wp b100x 100"), "wp b100x 100"); + assert_eq!(without_cheers("2024 Cheer"), "2024 Cheer"); + assert_eq!(without_cheers("Cheer500"), ""); + } + #[test] fn conditions_name_the_channel_the_way_each_type_wants() { assert!(condition("channel.raid", "7") diff --git a/src/integrations/twitch/helix.rs b/src/integrations/twitch/helix.rs index 3cb53f4..585c37d 100644 --- a/src/integrations/twitch/helix.rs +++ b/src/integrations/twitch/helix.rs @@ -1,8 +1,9 @@ //! The Helix API calls integrations use: VOD markers, clips, subscribing -//! to Twitch events, and the channel points rewards made here. -//! Blocking; run off the async thread. +//! to Twitch events, the channel points rewards made here, and the chat +//! commands a chat step runs. Blocking; run off the async thread. -use super::auth::read_body; +use super::auth::{read_body, url_encode}; +use crate::integrations::host::ChatCommand; use crate::json::{self, Value}; const API: &str = "https://api.twitch.tv/helix"; @@ -203,21 +204,192 @@ fn parse_rewards(body: &str) -> Vec { .collect() } +/// The longest title Twitch takes for a stream. +pub const MAX_STREAM_TITLE: usize = 140; + +/// A category on Twitch: a game, or something like "Just Chatting". +#[derive(Clone, Debug, PartialEq)] +pub struct Category { + pub id: String, + pub name: String, +} + +/// What to change on the channel: each part that is given. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct ChannelEdit { + pub title: Option, + pub category_id: Option, +} + +impl ChannelEdit { + fn to_json(&self) -> String { + let mut fields = Vec::new(); + if let Some(title) = &self.title { + let title: String = title.chars().take(MAX_STREAM_TITLE).collect(); + fields.push(("title".to_string(), json::str(title))); + } + if let Some(id) = &self.category_id { + fields.push(("game_id".to_string(), json::str(id))); + } + Value::Obj(fields).to_json() + } +} + +/// Set the stream's title, its category, or both. +pub fn update_channel(auth: &Auth, edit: &ChannelEdit) -> Result<(), String> { + let url = format!( + "{API}/channels?broadcaster_id={}", + super::auth::url_encode(auth.user_id) + ); + let (status, text) = send(auth, "PATCH", &url, &edit.to_json())?; + match status { + 204 | 200 => Ok(()), + _ => Err(explain_scope( + status, + &text, + "change the stream's title or category", + "change them", + )), + } +} + +/// The category a streamer means by `name`. A mod types "valo", not +/// "VALORANT": the one called exactly `name` if there is one, otherwise +/// the first Twitch's own search finds. +pub fn find_category(auth: &Auth, name: &str) -> Result { + let name = super::auth::url_encode(name.trim()); + let lookups = [ + format!("{API}/games?name={name}"), + format!("{API}/search/categories?query={name}&first=1"), + ]; + for url in lookups { + let (status, text) = send(auth, "GET", &url, "")?; + if status != 200 { + return Err(explain(status, &text, "look up the category")); + } + if let Some(found) = parse_categories(&text).into_iter().next() { + return Ok(found); + } + } + Err("Twitch has no category by that name".to_string()) +} + +/// The categories in a Twitch answer; one missing a field is skipped. +fn parse_categories(body: &str) -> Vec { + let doc = json::parse(body).unwrap_or(Value::Null); + let list = doc.get("data").and_then(Value::as_array).unwrap_or(&[]); + list.iter() + .filter_map(|c| { + Some(Category { + id: c.get("id")?.as_str()?.to_string(), + name: c.get("name")?.as_str()?.to_string(), + }) + }) + .collect() +} + +/// The id of the Twitch user called `login`. +fn user_id(auth: &Auth, login: &str) -> Result { + let url = format!("{API}/users?login={}", url_encode(login)); + let (status, text) = send(auth, "GET", &url, "")?; + if status != 200 { + return Err(explain(status, &text, "look up the viewer")); + } + json::parse(&text) + .ok() + .as_ref() + .and_then(|v| v.path("data.0.id")) + .and_then(Value::as_str) + .map(str::to_string) + .ok_or_else(|| format!("Twitch has no user called {login}")) +} + +/// A ban, or a timeout of `seconds`, as Twitch takes it. +fn ban_body(user_id: &str, seconds: Option, reason: &str) -> String { + let mut data = vec![("user_id".to_string(), json::str(user_id))]; + if let Some(seconds) = seconds { + data.push(("duration".to_string(), Value::Num(f64::from(seconds)))); + } + if !reason.is_empty() { + data.push(("reason".to_string(), json::str(reason))); + } + Value::Obj(vec![("data".to_string(), Value::Obj(data))]).to_json() +} + +/// Run a chat step's command in the channel, as its broadcaster (who +/// moderates their own chat). +pub fn run_command(auth: &Auth, command: &ChatCommand) -> Result<(), String> { + let me = url_encode(auth.user_id); + let bans = format!("{API}/moderation/bans?broadcaster_id={me}&moderator_id={me}"); + let (what, method, url, body) = match command { + ChatCommand::Timeout { + login, + seconds, + reason, + } => { + let id = user_id(auth, login)?; + ( + "time them out", + "POST", + bans, + ban_body(&id, Some(*seconds), reason), + ) + } + ChatCommand::Ban { login, reason } => { + let id = user_id(auth, login)?; + ("ban them", "POST", bans, ban_body(&id, None, reason)) + } + ChatCommand::Unban { login } => { + let id = user_id(auth, login)?; + let url = format!("{bans}&user_id={}", url_encode(&id)); + ("lift their ban or timeout", "DELETE", url, String::new()) + } + ChatCommand::Shoutout { login } => { + let id = user_id(auth, login)?; + let url = format!( + "{API}/chat/shoutouts?from_broadcaster_id={me}&to_broadcaster_id={}&moderator_id={me}", + url_encode(&id) + ); + ("shout them out", "POST", url, String::new()) + } + ChatCommand::Announce { text } => { + let url = format!("{API}/chat/announcements?broadcaster_id={me}&moderator_id={me}"); + let body = json::obj([("message", json::str(text.as_str()))]).to_json(); + ("post the announcement", "POST", url, body) + } + }; + let (status, text) = send(auth, method, &url, &body)?; + match status { + 200 | 204 => Ok(()), + _ => Err(explain_scope(status, &text, what, "run chat commands")), + } +} + /// Why Twitch said no to a rewards call, in the streamer's terms. A login /// from before this app managed rewards lacks the permission (401), and a /// channel that isn't Affiliate or Partner has no channel points (403). fn explain_rewards(status: u16, body: &str, what: &str) -> String { match status { - 401 => format!( - "Twitch refused to {what}: log out of Twitch here and in again, so InstantClone may manage rewards" - ), 403 => format!( "Twitch refused to {what}: channel points need an Affiliate or Partner channel, and an app can only change rewards it made" ), - _ => explain(status, body, what), + _ => explain_scope(status, body, what, "manage rewards"), } } +/// A 401 from a login made before InstantClone asked for what `needs` (Twitch +/// says "Missing scope") is fixed by logging in again. Any other 401 is a +/// token that ran out, which `explain` words so the call is renewed and +/// tried again. +fn explain_scope(status: u16, body: &str, what: &str, needs: &str) -> String { + if status == 401 && body.to_ascii_lowercase().contains("missing scope") { + return format!( + "Twitch refused to {what}: log out of Twitch here and in again, so InstantClone may {needs}" + ); + } + explain(status, body, what) +} + fn post(auth: &Auth, url: &str, body: &str) -> Result<(u16, String), String> { send(auth, "POST", url, body) } @@ -273,6 +445,15 @@ fn explain(status: u16, body: &str, what: &str) -> String { mod reward_tests { use super::*; + #[test] + fn a_timeout_says_how_long_and_a_ban_does_not() { + assert_eq!( + ban_body("42", Some(600), "spam"), + r#"{"data":{"user_id":"42","duration":600,"reason":"spam"}}"# + ); + assert_eq!(ban_body("42", None, ""), r#"{"data":{"user_id":"42"}}"#); + } + #[test] fn rewards_are_read_from_what_twitch_answers() { let body = r#"{"data":[ @@ -294,6 +475,43 @@ mod reward_tests { assert!(parse_rewards("not json").is_empty()); } + #[test] + fn a_channel_edit_sends_only_what_is_given() { + let title_only = ChannelEdit { + title: Some("Rankeds hasta Ascendente".into()), + category_id: None, + }; + assert_eq!( + title_only.to_json(), + r#"{"title":"Rankeds hasta Ascendente"}"# + ); + let both = ChannelEdit { + title: Some("x".repeat(MAX_STREAM_TITLE + 20)), + category_id: Some("516575".into()), + }; + let sent = json::parse(&both.to_json()).unwrap(); + assert_eq!(sent.str_or("title", "").chars().count(), MAX_STREAM_TITLE); + assert_eq!(sent.str_or("game_id", ""), "516575"); + assert_eq!(ChannelEdit::default().to_json(), "{}"); + } + + #[test] + fn categories_are_read_from_both_of_twitchs_answers() { + // `games` and `search/categories` name the same two fields. + let games = + r#"{"data":[{"id":"516575","name":"VALORANT","box_art_url":"x","igdb_id":"126459"}]}"#; + let search = r#"{"data":[{"broadcaster_language":"","id":"509658","name":"Just Chatting"},{"name":"no id"}]}"#; + let valorant = Category { + id: "516575".into(), + name: "VALORANT".into(), + }; + assert_eq!(parse_categories(games), vec![valorant]); + let found = parse_categories(search); + assert_eq!((found.len(), found[0].name.as_str()), (1, "Just Chatting")); + assert!(parse_categories(r#"{"data":[]}"#).is_empty()); + assert!(parse_categories("not json").is_empty()); + } + #[test] fn an_edit_sends_only_what_changed() { let switch = RewardEdit { @@ -321,6 +539,14 @@ mod reward_tests { old_login.contains("log out of Twitch here and in again"), "{old_login}" ); + // A token that ran out reads as the words a renewal answers to. + let expired = r#"{"status":401,"message":"Invalid OAuth token"}"#; + for refused in [ + explain_rewards(401, expired, "make the reward"), + explain_scope(401, expired, "post the announcement", "run chat commands"), + ] { + assert!(refused.ends_with("log in to Twitch again"), "{refused}"); + } let no_points = explain_rewards(403, "{}", "make the reward"); assert!(no_points.contains("Affiliate or Partner"), "{no_points}"); let twice = explain_rewards( diff --git a/src/integrations/twitch/mod.rs b/src/integrations/twitch/mod.rs index 72abc0a..1b2b7d3 100644 --- a/src/integrations/twitch/mod.rs +++ b/src/integrations/twitch/mod.rs @@ -141,6 +141,9 @@ pub struct Twitch { incoming: mpsc::Sender, /// Shown on the dashboard when Twitch logged us out. notice: Mutex>, + /// What the main login allows, once Twitch said (at login, at each + /// refresh and check): what it lacks, the dashboard asks for. + main_scopes: Mutex>>, upkeep_now: Arc, /// Set by `check_tokens_soon`: validate on the next upkeep round. validate_soon: AtomicBool, @@ -172,6 +175,7 @@ impl Twitch { bot: Conn::default(), incoming, notice: Mutex::new(None), + main_scopes: Mutex::new(None), upkeep_now: Arc::new(Notify::new()), validate_soon: AtomicBool::new(false), log, @@ -442,6 +446,7 @@ impl Twitch { if !self.login_current(attempt) { return; } + self.note_scopes(which, who.scopes.or(tokens.scopes)); self.set_account( which, Some(TwitchAccount { @@ -479,6 +484,7 @@ impl Twitch { }); } self.set_account(which, None); + self.note_scopes(which, None); if which == Which::Main { // No channel to join any more. self.bot.stop(); @@ -516,6 +522,17 @@ impl Twitch { let access = account.access.clone(); match tokio::task::spawn_blocking(move || auth::validate(&access)).await { Ok(Err(auth::TokenError::Revoked)) => {} + // Only while it is still this login: one made meanwhile + // (to allow more) has its own list. + Ok(Ok(valid)) => { + if self + .account(which) + .is_some_and(|now| now.access == account.access) + { + self.note_scopes(which, valid.scopes); + } + return; + } _ => return, } } else if !expiring && !auth_failed { @@ -542,6 +559,9 @@ impl Twitch { if let Some(token) = &*self.conn(which).token.lock() { *token.lock() = tokens.access.clone(); } + if tokens.scopes.is_some() { + self.note_scopes(which, tokens.scopes.clone()); + } self.set_account( which, Some(TwitchAccount { @@ -586,6 +606,14 @@ impl Twitch { } } + /// What Twitch says the main login allows (None: logged out, or not + /// known yet). The bot only chats: its scopes are never short. + fn note_scopes(&self, which: Which, scopes: Option>) { + if which == Which::Main { + *self.main_scopes.lock() = scopes; + } + } + /// Ask the upkeep loop to check tokens now (after a refused call): /// Twitch may have revoked one that isn't due for a refresh yet. pub fn check_tokens_soon(&self) { @@ -662,6 +690,31 @@ impl Twitch { self.helix_call(helix::create_clip) } + /// Run a chat step's Twitch command in the channel. + pub fn run_command(&self, command: &super::host::ChatCommand) -> Result<(), String> { + self.helix_call(|a| helix::run_command(a, command)) + } + + /// Set the stream's title, its category, or both. `category` is a name + /// as someone types it ("valo"); the answer is the name Twitch has for + /// the category it found, when one was asked for. + pub fn set_channel( + &self, + title: Option<&str>, + category: Option<&str>, + ) -> Result, String> { + let found = match category { + Some(name) => Some(self.helix_call(|a| helix::find_category(a, name))?), + None => None, + }; + let edit = helix::ChannelEdit { + title: title.map(str::to_string), + category_id: found.as_ref().map(|c| c.id.clone()), + }; + self.helix_call(|a| helix::update_channel(a, &edit))?; + Ok(found.map(|c| c.name)) + } + /// The channel points rewards this app made on the channel. pub fn rewards(&self) -> Result, String> { self.helix_call(helix::rewards) @@ -710,12 +763,22 @@ impl Twitch { ChatState::AuthFailed => "reconnecting".to_string(), ChatState::Error(e) => format!("error: {e}"), }; + // What a login from before a permission was asked for can't do + // yet: logging in again once allows it. + let missing = match (which, &*self.main_scopes.lock()) { + (Which::Main, Some(granted)) if account.is_some() => auth::missing_uses(granted), + _ => Vec::new(), + }; json::obj([ ( "login", json::str(account.map(|a| a.login).unwrap_or_default()), ), ("chat", json::str(state)), + ( + "missing", + Value::Arr(missing.into_iter().map(json::str).collect()), + ), ]) }; let flow = match &*self.login.lock() { diff --git a/src/obs_register.rs b/src/obs_register.rs index c86b36e..37fe319 100644 --- a/src/obs_register.rs +++ b/src/obs_register.rs @@ -263,7 +263,8 @@ pub fn is_registered(web_port: u16, ingest_port: u16) -> bool { /// /// Leftover literal servers inside OUR entry also count as stale: they /// would sit in the dropdown as options that fail under one IP Family -/// setting each. +/// setting each. So does an entry from before HEVC and AV1 were offered: +/// OBS would switch an AV1 encoder to H.264 for it. /// /// Every check runs against `entry_span` and not the whole file. Scanning /// the file would let anything InstantClone-shaped elsewhere in @@ -282,6 +283,7 @@ fn registration_is_current(file: &str, web_port: u16, ingest_port: u16) -> bool ]; entry.contains(&server_url(ingest_port)) && entry.contains(&multitrack_url(web_port)) + && entry.contains(r#""av1""#) && !stale_literals.iter().any(|u| entry.contains(u.as_str())) } @@ -342,7 +344,7 @@ fn entry_json(web_port: u16, ingest_port: u16) -> String { "max video bitrate": 50000, "max audio bitrate": 320 }}, - "supported video codecs": ["h264"] + "supported video codecs": ["h264", "hevc", "av1"] }}"#, web = web_port, mtv = multitrack_url(web_port), @@ -1966,6 +1968,12 @@ mod tests { !registration_is_current(&with_leftovers, 7799, 1935), "a superset of our entry is still out of date" ); + + // One from before HEVC and AV1 were offered: OBS would swap an AV1 + // encoder for H.264 under it. + let h264_only = current.replace(r#"["h264", "hevc", "av1"]"#, r#"["h264"]"#); + assert_ne!(h264_only, current); + assert!(!registration_is_current(&h264_only, 7799, 1935)); } /// Found on a real install: a leftover `InstantClone (local proxy)` diff --git a/src/web.rs b/src/web.rs index 1e96ab2..6351a2f 100644 --- a/src/web.rs +++ b/src/web.rs @@ -908,19 +908,16 @@ async fn route( }; // Pluggable overlays: any GET /overlay/ reads from disk. if method == "GET" && bare_path.starts_with("/overlay/") { - let name = &bare_path["/overlay/".len()..]; - return serve_overlay_file(name, settings); + let name = overlay_file_name(&bare_path["/overlay/".len()..]); + return serve_overlay_file(&name, settings); } // Overlay Studio writes: POST /overlays/ saves a baked overlay, // POST /overlays//delete removes it. The slug is restricted to a // safe charset (no separators) so no path can escape overlays_dir. - // POST /overlays/seeded marks the built-in presets as installed; POST - // /overlays/reset wipes the Studio overlays and clears that flag so the - // dashboard re-seeds the defaults on its next load. + // POST /overlays/seeded marks the built-in presets as installed. if method == "POST" && bare_path.starts_with("/overlays/") { return match overlay_post(&bare_path["/overlays/".len()..], body) { OverlayPost::MarkSeeded => overlays_mark_seeded(settings, cfg_path), - OverlayPost::Reset => overlays_reset(settings, cfg_path), OverlayPost::Delete(slug) => overlay_delete(slug, settings), OverlayPost::Save(slug) => overlay_save(slug, body, settings), }; @@ -1316,6 +1313,18 @@ async fn route( ("POST", "/config/reset") => post_config_reset(query, ctrl, settings, cfg_path).await, ("GET", "/dock/settings") => ("200 OK", "application/json", dock_settings_json(settings)), ("POST", "/dock/settings") => post_dock_settings(body, settings, cfg_path), + // The OBS dock's events list (see `integrations::api::dock`). + ("GET", "/dock/events") + | ("POST", "/dock/events/replay") + | ("POST", "/dock/alerts/skip") => { + crate::integrations::api::dock(method, bare_path, body, ctrl).unwrap_or_else(|| { + ( + "503 Service Unavailable", + "application/json", + r#"{"ok":false,"error":"Integrations aren't running"}"#.to_string(), + ) + }) + } // Two-phase delay endpoints ("POST", "/arm") => post_arm(body, ctrl, settings, cfg_path, sysstat).await, ("POST", "/activate") => post_activate(ctrl, settings, cfg_path, sysstat).await, @@ -1648,8 +1657,10 @@ fn state_json( let vertical_present = ctrl.ingest_alive() && ctrl.vertical_track_on_wire().is_some(); format!( - r#"{{"phase":"{ph}","armed_delay_ms":{ad},"target_delay_ms":{td},"current_delay_ms":{cd},"buffer_fill_ms":{bf},"buffer_target_ms":{btm},"buffer_capacity_ms_est":{bc},"capacity_kbps":{ck},"capacity_measured":{cm},"capacity_short":{cs},"buffer_mb_running":{bmr},"restart_pending":{rp},"ingest_alive":{ia},"egress_alive":{ea},"destinations_alive":{dla},"destinations_total":{dlt},"buffer_building":{bb},"configured":{cfg},"obs_url":"{ou}","webhook_set":{ws},"video_codec":"{vc}","audio_codec":"{ac}","multitrack_video":{mtv},"multitrack_audio":{mta},"vertical_present":{vp},"cpu_pct":{cp:.2},"rss_bytes":{rb},"uptime_secs":{up},"publisher_token":{pt},"consumer_lag":{cl},"backpressure":{bp},"safe_cut_pending":{scp},"safe_cut_remaining_ms":{scr},"hold":{hold},"tail_ms":{tail},"compat_warning":{cw},"hotkey_conflicts":[{hkc}],"last_action":{la},"stats":{{"tags_sent":{ts},"bytes_sent":{bs},"cuts":{cu},"ingest_disconnects":{id},"egress_reconnects":{er},"bitrate_kbps":{br},"video_kbps":{vbr}}},"destinations":[{dl}]}}"#, + r#"{{"phase":"{ph}","armed_delay_ms":{ad},"target_delay_ms":{td},"current_delay_ms":{cd},"buffer_fill_ms":{bf},"buffer_target_ms":{btm},"buffer_capacity_ms_est":{bc},"capacity_kbps":{ck},"capacity_measured":{cm},"capacity_short":{cs},"buffer_mb_running":{bmr},"restart_pending":{rp},"ingest_alive":{ia},"egress_alive":{ea},"destinations_alive":{dla},"destinations_total":{dlt},"buffer_building":{bb},"configured":{cfg},"obs_url":"{ou}","webhook_set":{ws},"video_codec":"{vc}","audio_codec":"{ac}","multitrack_video":{mtv},"multitrack_audio":{mta},"vertical_present":{vp},"cpu_pct":{cp:.2},"rss_bytes":{rb},"uptime_secs":{up},"publisher_token":{pt},"consumer_lag":{cl},"backpressure":{bp},"safe_cut_pending":{scp},"safe_cut_remaining_ms":{scr},"hold":{hold},"tail_ms":{tail},"compat_warning":{cw},"hotkey_conflicts":[{hkc}],"last_action":{la},"feed":{fd},"stats":{{"tags_sent":{ts},"bytes_sent":{bs},"cuts":{cu},"ingest_disconnects":{id},"egress_reconnects":{er},"bitrate_kbps":{br},"video_kbps":{vbr}}},"destinations":[{dl}]}}"#, ph = ctrl.phase(), + // The dock's events list changed: it fetches the list again. + fd = ctrl.integrations().map_or(0, |h| h.feed.changes()), scp = ctrl.safe_cut_pending(), scr = ctrl.safe_cut_remaining_ms(), hold = hold_json(ctrl), @@ -1812,9 +1823,14 @@ fn overlay_state_json(ctrl: &Controller, settings: &Arc> }) .collect::>() .join(","); + // Goals and timers: names and numbers the streamer made to be shown. + let (goals, timers) = ctrl.integrations().map_or_else( + || ("[]".to_string(), "[]".to_string()), + |handle| handle.goals.overlay_json(), + ); format!( - r#"{{"phase":"{ph}","armed_delay_ms":{ad},"target_delay_ms":{td},"current_delay_ms":{cd},"buffer_fill_ms":{bf},"buffer_target_ms":{btm},"ingest_alive":{ia},"destinations_alive":{dla},"destinations_total":{dlt},"stats":{{"cuts":{cu},"bitrate_kbps":{br}}},"destinations":[{dl}]}}"#, + r#"{{"phase":"{ph}","armed_delay_ms":{ad},"target_delay_ms":{td},"current_delay_ms":{cd},"buffer_fill_ms":{bf},"buffer_target_ms":{btm},"ingest_alive":{ia},"destinations_alive":{dla},"destinations_total":{dlt},"stats":{{"cuts":{cu},"bitrate_kbps":{br}}},"destinations":[{dl}],"goals":{gl},"timers":{tm},"saved":{sv}}}"#, ph = ctrl.phase(), ad = ctrl.armed_delay_ms(), td = ctrl.target_delay_ms(), @@ -1827,6 +1843,9 @@ fn overlay_state_json(ctrl: &Controller, settings: &Arc> cu = ctrl.cuts_performed(), br = ctrl.bitrate_kbps(), dl = dots, + gl = goals, + tm = timers, + sv = overlay_saves_json(), ) } @@ -3214,6 +3233,8 @@ async fn post_config_reset( // Integrations and their connections are the user's own work too. next.integrations = prev.integrations; next.user_values = prev.user_values; + next.goals = prev.goals; + next.timers = prev.timers; next.discord_channels = prev.discord_channels; next.phone = prev.phone; next.integrations_migrated = prev.integrations_migrated; @@ -4371,10 +4392,11 @@ fn normalize_audio_track(raw: Option<&str>) -> String { /// which the baker sets to the doc name), and `studio` - whether the file /// is a Studio-authored overlay (carries an `ic-doc` comment) versus a /// legacy hand-dropped `.html`. Studio overlays can be re-edited; legacy -/// ones are still listed and usable as browser sources. +/// ones are still listed and usable as browser sources. `kind` is the page +/// of the Overlay tab it is on (see `overlay_kind`). fn list_overlays(settings: &Arc>) -> String { let dir = settings.borrow().overlays_dir.clone(); - let mut items: Vec<(String, String, bool, bool)> = Vec::new(); + let mut items: Vec<(String, String, bool, bool, &'static str, String)> = Vec::new(); if let Ok(entries) = std::fs::read_dir(&dir) { for e in entries.flatten() { let fname = match e.file_name().into_string() { @@ -4396,27 +4418,73 @@ fn list_overlays(settings: &Arc>) -> String { // quick toggle (it appends ?autohide=off to the copied URL). let autohide = content.contains("data-ah-"); let name = extract_title(&content).unwrap_or_else(|| slug.clone()); - items.push((slug, name, studio, autohide)); + let kind = overlay_kind(&content, &slug); + let build = overlay_build(&content).to_string(); + items.push((slug, name, studio, autohide, kind, build)); } } items.sort_by(|a, b| a.0.cmp(&b.0)); let mut out = String::from("["); - for (i, (slug, name, studio, autohide)) in items.iter().enumerate() { + for (i, (slug, name, studio, autohide, kind, build)) in items.iter().enumerate() { if i > 0 { out.push(','); } out.push_str(&format!( - r#"{{"slug":{},"name":{},"studio":{},"autohide":{}}}"#, + r#"{{"slug":{},"name":{},"studio":{},"autohide":{},"kind":"{}","build":"{}"}}"#, json_escape_quoted(slug), json_escape_quoted(name), studio, - autohide + autohide, + kind, + build )); } out.push(']'); out } +/// The page of the Overlay tab an overlay is on: "delay" (it shows the +/// delay), "goals" (a goal's or a timer's own, on its card) or "custom" +/// (anything else). This is the one place that decides: the dashboard takes +/// it as given. A goal's or a timer's own overlay is the file its card +/// makes, `goal-` or `timer-`; anything else marked as one (a copy) +/// is custom. Otherwise the Studio writes the page into the file as +/// ``; a file from before that is a delay overlay if +/// the Studio made it, and a hand-written one is custom. +fn overlay_kind(content: &str, slug: &str) -> &'static str { + let studio = content.contains("{meta}"); + let marked = |kind: &str| studio(&format!(r#""#)); + assert_eq!(overlay_kind(&marked("custom"), "badge"), "custom"); + assert_eq!(overlay_kind(&marked("delay"), "badge"), "delay"); + assert_eq!(overlay_kind(&marked("alerts"), "follow-alert"), "alerts"); + // A card's own file is its card's, whatever it says; a copy of one + // under another name is an overlay of its own. + assert_eq!(overlay_kind(&marked("goals"), "goal-subs"), "goals"); + assert_eq!(overlay_kind(&marked("delay"), "timer-subathon"), "goals"); + assert_eq!(overlay_kind(&marked("goals"), "sub-goal-copy"), "custom"); + // Unknown words are not taken at their word. + assert_eq!(overlay_kind(&marked("delayed"), "badge"), "delay"); + // From before the mark. + assert_eq!(overlay_kind(&studio(""), "badge"), "delay"); + assert_eq!(overlay_kind(&studio(""), "goal-subs"), "goals"); + assert_eq!(overlay_kind("my own", "goal-mine"), "custom"); + } + #[test] fn extract_title_pulls_first_title() { assert_eq!( @@ -7880,6 +8055,9 @@ user-agent: Mozilla/5.0 Chrome/141.0 Safari/537.36 ("POST", "/config/reset", Access::Admin), ("GET", "/dock/settings", Access::Control), ("POST", "/dock/settings", Access::Control), + ("GET", "/dock/events", Access::Control), + ("POST", "/dock/events/replay", Access::Control), + ("POST", "/dock/alerts/skip", Access::Control), ("POST", "/arm", Access::Control), ("POST", "/activate", Access::Control), ("POST", "/stop", Access::Control), @@ -8075,11 +8253,7 @@ user-agent: Mozilla/5.0 Chrome/141.0 Safari/537.36 "{m} {prefix}*" ); } - for p in [ - "/overlays/seeded", - "/overlays/reset", - "/overlays/sample/delete", - ] { + for p in ["/overlays/seeded", "/overlays/sample/delete"] { assert_eq!(classify_access("POST", p), Access::Admin, "POST {p}"); } // Fail closed: whatever nobody listed needs a session. diff --git a/web/alerts.html b/web/alerts.html index 5c1557a..360285f 100644 --- a/web/alerts.html +++ b/web/alerts.html @@ -18,6 +18,12 @@ entrance. Each alert can bring its own accent (--card-color), sound and picture. ?at=top in the address still wins, for setups made before. ?demo is the dashboard's preview: no feed, it shows what it is sent. + ?embed is an "Alert card" layer inside a design: one card in the look + the layer gives it, drawn when the design says, with no feed and no + sound (the alerts page around the design has those). + + An alert can name a design (an overlay made in the Studio) instead; an + alert that names none takes the default design, if there is one. --> @@ -371,6 +388,7 @@ const params = new URLSearchParams(location.search); const pinnedTop = params.get('at') === 'top'; const demo = params.has('demo'); + const embed = params.has('embed'); // This page's own fingerprint, filled in when served (alerts::page_id). const PAGE = '__ALERTS_PAGE_ID__'; // The pause between two alerts, and the longest a card waits for its @@ -399,6 +417,9 @@ 'Roboto Mono':'wght@400;600;700', 'Chakra Petch':'wght@400;500;600;700', 'Playfair Display':'ital,wght@0,400;0,700;1,400;1,600'}; const queue = []; let boot = null, seen = null, playing = false; + // The dock's Skip: how many the app has asked for, and what ends the + // alert on now (null between alerts), with the sound file it plays. + let skips = null, current = null, fileSound = null; let look = {}; function themeOf(l){ return THEMES[l.theme] ? l.theme : 'island'; } @@ -419,9 +440,12 @@ const theme = themeOf(look), own = THEMES[theme]; const body = document.body, cls = []; cls.push('theme-' + theme); - const position = pinnedTop ? 'top' : look.position; + // A card layer sits where its look says inside its own box (at the + // bottom, in the middle, unless told). + const position = pinnedTop && !embed ? 'top' : look.position; if (position === 'top' || position === 'middle') cls.push(position); if (look.align === 'left' || look.align === 'right') cls.push(look.align); + if (embed) cls.push('embed'); cls.push('anim-' + (look.animation || 'theme')); if ((look.picture || own.pic) === 'top') cls.push('pic-top'); body.className = cls.join(' '); @@ -440,9 +464,11 @@ document.getElementById('user-css').textContent = look.css || ''; } - // `media:` is an upload served next to this page. + // `media:` is an upload served next to this page. A card layer is + // handed it as that address already, or the Studio's sample picture. function mediaSrc(ref){ if (/^media:[a-z0-9._-]+$/.test(ref || '')) return '/alerts/media/' + ref.slice(6); + if (embed && /^(\/alerts\/media\/[a-z0-9._-]+|data:image\/svg\+xml,[^"'<>]*)$/.test(ref || '')) return ref; return /^https?:\/\//.test(ref || '') ? ref : ''; } @@ -478,8 +504,9 @@ const el = document.createElement('div'); el.className = 'card'; el.innerHTML = `` - + '
'; + + '
'; el.querySelector('.title').textContent = alert.title || ''; + el.querySelector('.msg').textContent = alert.message || ''; spellOut(el.querySelector('.text'), alert.text || ''); if (/^#[0-9a-f]{6}$/i.test(alert.color || '')) el.style.setProperty('--card-color', alert.color); const src = mediaSrc(alert.image); @@ -580,9 +607,13 @@ zap: (t, g) => { tone(t, 1900, 110, 0.28, 'sawtooth', g * 0.35); tone(t + 0.05, 2400, 300, 0.2, 'square', g * 0.15); }, blip: (t, g) => { tone(t, 880, 880, 0.08, 'sine', g); tone(t + 0.09, 1320, 1320, 0.12, 'sine', g * 0.8); }, }; - function play(alert){ + // `theme`: the theme of the card a design shows, whose own sound "Theme + // sound" is then. + // "The design's sound" is the design's own (`own`), else its Alert + // card's theme's, else the look's. + function play(alert, theme, own){ let sound = alert.sound || ''; - if (sound === 'theme') sound = look.sound || THEMES[themeOf(look)].sound; + if (sound === 'theme') sound = own || (THEMES[theme] ? THEMES[theme].sound : look.sound || THEMES[themeOf(look)].sound); const volume = Math.max(0, Math.min(100, Number(alert.volume == null || alert.volume === '' ? 70 : alert.volume))) / 100; if (!sound || !volume) return; try { @@ -591,6 +622,7 @@ const file = new Audio(src); file.volume = volume; file.play().catch(() => {}); + fileSound = file; return; } if (!SOUNDS[sound]) return; @@ -600,10 +632,119 @@ } catch (_){ /* no sound is better than no alert */ } } + // An alert with a design (an overlay made in the Studio) shows that + // instead of a theme's card: loaded for this one alert, laid out on a + // 1920 x 1080 canvas like every overlay and scaled to this page, it plays + // its entrance, holds, and leaves its own way. Its sound plays here. A + // design that is gone shows the theme's card, so no alert is lost. + const DESIGN_LEAVE = 900; + function designSrc(alert){ return '/overlay/' + encodeURIComponent(alert.design) + '.html'; } + // In OBS the page is the canvas: a design fills it. A dashboard's preview + // (demo) is any shape: it shows where the design's layers are (`box`, as + // the design says when it plays) as large as fits, up to half again their + // size, or the whole canvas until then. + const DEMO_PAD = 28, DEMO_ZOOM = 1.5; + function fitDesign(frame, box){ + const b = box || frame.icBox || {x:0, y:0, w:1920, h:1080}; + frame.icBox = b; + if (!demo){ + // ?at=top (an OBS source set up as 0.1.15 said): a design laid out + // low moves up, as far from the top as it was from the bottom. + const up = pinnedTop && !embed && b.y > 540 ? 2 * b.y + b.h - 1080 : 0; + frame.style.transform = 'scale(' + (innerWidth / 1920) + ') translateY(' + -up + 'px)'; + return; + } + const s = Math.min((innerWidth - 2 * DEMO_PAD) / b.w, (innerHeight - 2 * DEMO_PAD) / b.h, DEMO_ZOOM); + const x = (innerWidth - b.w * s) / 2 - b.x * s, y = (innerHeight - b.h * s) / 2 - b.y * s; + frame.style.transform = 'translate(' + x + 'px,' + y + 'px) scale(' + s + ')'; + } + addEventListener('resize', () => document.querySelectorAll('.design').forEach(f => fitDesign(f))); + // A design answers an alert with its own sound and its card's theme (if + // it has them): `shown` runs then, or a moment after it loaded when it + // doesn't answer. + window.addEventListener('message', e => { + const frame = [...document.querySelectorAll('.design')].find(f => f.contentWindow === e.source); + if (frame && frame.icShown && e.data && e.data.__ic_design) frame.icShown(e.data.__ic_design); + }); + function design(alert, hold, shown){ + const frame = document.createElement('iframe'); + frame.className = 'design'; + frame.setAttribute('aria-hidden', 'true'); + frame.tabIndex = -1; + fitDesign(frame); + frame.icShown = said => { + // Skipped or gone before it loaded: nothing to show or hear. + if (!frame.isConnected || frame.classList.contains('skipped')) return; + if (said.box) fitDesign(frame, said.box); + const then = shown; shown = null; if (then) then(said); + }; + // A saved overlay is sandboxed, with an origin of its own that has no + // name: '*' reaches it, and it checks the message came from this app. + frame.onload = () => { + frame.contentWindow.postMessage({__ic_alert:{title:alert.title || '', text:alert.text || '', message:alert.message || '', + image:mediaSrc(alert.image), color:alert.color || '', ms:hold}}, '*'); + setTimeout(() => frame.icShown({}), 1500); + }; + frame.src = designSrc(alert); + document.body.appendChild(frame); + return frame; + } + // How long a design may take to load (its fonts and pictures, on a cold + // start of OBS) before the next alert goes ahead. + const DESIGN_LOAD_WAIT = 8000; + function nextDesign(alert){ + // It leaves its own way, then goes; skipped, it fades out at once. + let left = false; + const gone = () => { + if (left) return; + left = true; + current = null; + document.querySelectorAll('.design').forEach(f => f.remove()); + setTimeout(next, GAP); + }; + // Its time on screen counts from when it shows, not from when it began + // to load: a slow load never cuts it short. + let timer = setTimeout(gone, DESIGN_LOAD_WAIT); + design(alert, alert.ms, said => { + clearTimeout(timer); + play(alert, said.theme, said.sound); + timer = setTimeout(gone, alert.ms + DESIGN_LEAVE); + }); + current = () => { + clearTimeout(timer); + document.querySelectorAll('.design').forEach(f => f.classList.add('skipped')); + setTimeout(gone, SKIP_FADE); + }; + } + // End the alert on now, sound and all; the next one comes on. + const SKIP_FADE = 250; + function skipNow(){ + const end = current; + current = null; + if (fileSound){ fileSound.pause(); fileSound = null; } + if (end) end(); + } + function next(){ const alert = queue.shift(); if (!alert){ playing = false; return; } playing = true; + if (!alert.design && !alert.checked && look.design) alert.design = look.design; + if (alert.design && !alert.checked){ + fetch(designSrc(alert), {cache:'no-store'}) + .then(r => r.ok) + .catch(() => false) + .then(ok => { + // A design that's gone: the default plays instead, or, gone too, + // the theme's card. + if (ok) alert.checked = true; + else if (look.design && alert.design !== look.design) alert.design = look.design; + else { alert.design = ''; alert.checked = true; } + queue.unshift(alert); next(); + }); + return; + } + if (alert.design){ nextDesign(alert); return; } let el; // One alert that can't be drawn is skipped: it never stops the queue. try { el = card(alert); } @@ -617,7 +758,8 @@ el.querySelector('.time i').animate([{transform:'scaleX(1)'}, {transform:'scaleX(0)'}], {duration:alert.ms, easing:'linear', fill:'forwards'}); })); - setTimeout(() => leave(el, () => setTimeout(next, GAP)), alert.ms); + const timer = setTimeout(() => { current = null; leave(el, () => setTimeout(next, GAP)); }, alert.ms); + current = () => { clearTimeout(timer); leave(el, () => setTimeout(next, GAP)); }; } // OBS keeps this page open through an app update: when the app serves a @@ -643,7 +785,13 @@ // shows; on first load, what is already up is old news. if (boot !== null) seen = 0; boot = board.boot; + skips = null; } + // A Skip asked for since the last look ends what plays now; one from + // before this page opened doesn't. + const asked = Number(board.skips) || 0; + if (skips !== null && asked > skips) skipNow(); + skips = asked; if (seen === null){ seen = top; return; } list.filter(a => a.id > seen).forEach(a => queue.push(a)); seen = Math.max(seen, top); @@ -658,8 +806,17 @@ const turn = ++demoTurn; clearTimeout(demoTimer); applyLook(data.look); - document.querySelectorAll('.card').forEach(c => c.remove()); + document.querySelectorAll('.card,.design').forEach(c => c.remove()); if (!data.card) return; + const named = data.card.design || (data.look && data.look.design); + if (named){ + const hold = data.replay === 'full' ? DEMO_HOLD : 24 * 3600 * 1000; + design(Object.assign({}, data.card, {design:named}), hold, data.sound ? said => play(data.card, said.theme, said.sound) : null); + if (data.replay === 'full') demoTimer = setTimeout(() => { + if (turn === demoTurn) showDemo({look:data.look, card:data.card}); + }, DEMO_HOLD + DESIGN_LEAVE); + return; + } const el = card(data.card); document.body.appendChild(el); if (data.sound) play(data.card); @@ -671,6 +828,40 @@ }), DEMO_HOLD); } + // An "Alert card" layer: still while its design is edited, played in, or + // sent out, as its design (this frame's parent) says. + let held = null, heldKey = ''; + function embedded(msg){ + applyLook(msg.look); + if (msg.mode === 'out' || msg.mode === 'none'){ + heldKey = ''; + const el = held; held = null; + if (el && msg.mode === 'out') leave(el, () => {}); else document.querySelectorAll('.card').forEach(c => c.remove()); + return; + } + // A look changed in the Studio comes alone: the card stays the same. + const said = msg.card || (held && held.icCard) || {}; + const key = msg.mode + JSON.stringify(said) + JSON.stringify(msg.look); + if (msg.mode === 'still' && key === heldKey && held) return; + heldKey = key; + document.querySelectorAll('.card').forEach(c => c.remove()); + const el = card(said); + el.icCard = said; + held = el; + document.body.appendChild(el); + if (msg.mode === 'still'){ el.classList.add('in', 'still'); return; } + requestAnimationFrame(() => requestAnimationFrame(() => { + el.classList.add('in'); + decode(el); + el.querySelector('.time i').animate([{transform:'scaleX(1)'}, {transform:'scaleX(0)'}], + {duration:msg.ms || 6000, easing:'linear', fill:'forwards'}); + })); + } + if (embed){ + window.addEventListener('message', e => { if (e.source === window.parent && e.data && e.data.__ic_card) embedded(e.data.__ic_card); }); + return; + } + if (demo){ window.addEventListener('message', e => { if (e.origin === location.origin && e.data && e.data.instantcloneAlert) showDemo(e.data.instantcloneAlert); diff --git a/web/dock.html b/web/dock.html index 79c5799..044e8e5 100644 --- a/web/dock.html +++ b/web/dock.html @@ -184,14 +184,21 @@ .tip.fade{opacity:0;transform:translateY(2px)} /* Shared row look: destinations, switches, overlays, profile rows. */ -.dest,.brow,.orow,.prow{border:1px solid var(--line);border-top-color:rgba(255,255,255,.11);border-radius:var(--r); +.dest,.brow,.orow,.prow,.erow{border:1px solid var(--line);border-top-color:rgba(255,255,255,.11);border-radius:var(--r); background:linear-gradient(180deg,var(--surface-2),var(--surface));box-shadow:inset 0 1px 0 var(--hl)} /* Destination quick-toggle strip */ .dests{display:flex;flex-direction:column;gap:5px} .dest{display:flex;align-items:center;gap:8px;padding:7px 9px;font-size:12px} -.dest .d-dot{width:7px;height:7px;border-radius:50%;background:var(--fg-4);flex:0 0 auto; - transition:background-color .2s,box-shadow .2s} +/* The platform's mark, with the state's dot on its corner. */ +.dest .d-mark{position:relative;width:20px;height:20px;flex:0 0 auto;display:grid;place-items:center;color:var(--fg-2); + transition:color .2s,opacity .2s} +.dest.pc .d-mark{color:var(--pc)} +.dest.off .d-mark{opacity:.5} +.dest .d-glyph{display:grid;place-items:center;font-size:11px;font-weight:700;line-height:1} +.dest .d-glyph svg,.dico svg{width:16px;height:16px;display:block} +.dest .d-dot{position:absolute;right:-3px;bottom:-3px;width:7px;height:7px;border-radius:50%;background:var(--fg-4); + outline:2px solid var(--surface);transition:background-color .2s,box-shadow .2s} .dest.alive .d-dot{background:var(--live);box-shadow:0 0 0 3px color-mix(in oklch,var(--live) 18%,transparent)} .dest .d-name{flex:1;min-width:0;font-weight:600;color:var(--fg);white-space:nowrap;overflow:hidden;text-overflow:ellipsis} .dest.off .d-name{color:var(--fg-3)} @@ -313,6 +320,20 @@ .olist{display:flex;flex-direction:column;gap:5px} .ogroup{font-size:9.5px;text-transform:uppercase;letter-spacing:.1em;color:var(--fg-4);font-weight:700;margin:6px 2px 1px} +/* Events list */ +.events{display:flex;flex-direction:column;gap:5px} +.ehead{display:flex;align-items:center;gap:6px} +.echips{display:flex;gap:4px;flex-wrap:wrap;flex:1;min-width:0} +.echip{padding:4px 9px;border-radius:999px;border:1px solid var(--line);background:var(--surface-3);color:var(--fg-3); + font:inherit;font-size:10.5px;font-weight:600;cursor:pointer;transition:border-color .15s var(--ease-out),color .15s} +.echip:hover{border-color:var(--line-2);color:var(--fg)} +.echip.on{border-color:color-mix(in oklch,var(--accent) 55%,transparent);color:var(--accent)} +.ehead .minibtn{margin-left:auto} +.elist{display:flex;flex-direction:column;gap:5px;max-height:320px;overflow-y:auto} +.erow{display:flex;align-items:center;gap:8px;padding:6px 6px 6px 10px;font-size:12px} +.e-time{flex:none;font-size:10.5px;color:var(--fg-4);font-variant-numeric:tabular-nums} +.e-text{flex:1;min-width:0;color:var(--fg);font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} + /* Mini action buttons (settings, overlays, docks) */ .minibtn{flex:0 0 auto;padding:5px 9px;border-radius:7px;border:1px solid var(--line);background:var(--surface-3); color:var(--fg-2);font-size:11px;font-weight:600;cursor:pointer;white-space:nowrap; @@ -615,6 +636,7 @@ +
diff --git a/web/dock.js b/web/dock.js index 5d3752e..6e241a4 100644 --- a/web/dock.js +++ b/web/dock.js @@ -115,26 +115,29 @@ const WIDGETS = { behavior: { label: 'Auto behavior', def: { on: false } }, settings: { label: 'Settings', def: { on: false } }, overlays: { label: 'Overlays', def: { on: false, autohide: false, search: true, group: true } }, + // `only`: the one group shown, or '' for all. + events: { label: 'Events', def: { on: false, skip: true, times: true, only: '' } }, activate: { label: 'Action button', def: { on: true, confirmcut: false, safecut: true } }, }; // Every widget that has stored options. phase + source are sub-widgets of the // status bar; bar rides along with the number screen it lives in. -const ALL = ['status', 'phase', 'source', 'number', 'bar', 'egress', 'tips', 'dest', 'profiles', 'stats', 'behavior', 'settings', 'overlays', 'activate']; +const ALL = ['status', 'phase', 'source', 'number', 'bar', 'egress', 'tips', 'dest', 'profiles', 'stats', 'behavior', 'settings', 'overlays', 'events', 'activate']; // Reorderable rows = the top-level containers the user can drag around. -const ROWS = ['status', 'number', 'profiles', 'egress', 'tips', 'dest', 'stats', 'behavior', 'settings', 'overlays', 'activate']; +const ROWS = ['status', 'number', 'profiles', 'egress', 'tips', 'dest', 'stats', 'behavior', 'settings', 'overlays', 'events', 'activate']; // DOM container per row. const WEL = { status: 'w-top', number: 'w-screen', profiles: 'w-profiles', egress: 'w-eg', tips: 'w-tip', dest: 'w-dests', - stats: 'w-stats', behavior: 'w-behavior', settings: 'w-settings', overlays: 'w-overlays', activate: 'w-cta', + stats: 'w-stats', behavior: 'w-behavior', settings: 'w-settings', overlays: 'w-overlays', events: 'w-events', activate: 'w-cta', }; // Presets = which widgets are on. Options keep their defaults / last tweak. -const ALL_ON = ['status', 'phase', 'source', 'number', 'bar', 'egress', 'tips', 'dest', 'profiles', 'stats', 'behavior', 'settings', 'overlays', 'activate']; +const ALL_ON = ['status', 'phase', 'source', 'number', 'bar', 'egress', 'tips', 'dest', 'profiles', 'stats', 'behavior', 'settings', 'overlays', 'events', 'activate']; const PRESETS = { full: { label: 'Full', on: ['status', 'phase', 'source', 'number', 'bar', 'egress', 'tips', 'dest', 'activate'] }, minimal: { label: 'Minimal', on: ['status', 'phase', 'source', 'number', 'activate'] }, delay: { label: 'Delay only', on: ['number', 'activate'] }, dest: { label: 'Destinations', on: ['status', 'phase', 'source', 'dest'] }, control: { label: 'Control', on: ['status', 'source', 'behavior', 'settings', 'overlays'] }, + events: { label: 'Events', on: ['status', 'source', 'events'] }, dashboard: { label: 'Dashboard', on: ALL_ON }, }; // Editable options per row. An option targets another widget's store via `w` @@ -174,6 +177,7 @@ const OPTS = { overlays: [ { k: 'search', label: 'Search bar' }, { k: 'group', label: 'Folders' }, { k: 'autohide', label: 'Copy with autohide off' }, ], + events: [{ k: 'skip', label: 'Skip button' }, { k: 'times', label: 'Times' }], activate: [{ k: 'safecut', label: 'Cut after airs' }, { k: 'confirmcut', label: 'Confirm cut' }], }; @@ -830,6 +834,8 @@ function applyState(j) { renderDests(); renderStats(); renderCta(); + // The events list changed: fetch it. + if (cfg.w.events.on && s.feed !== eventsSeen) { eventsSeen = s.feed; fetchEvents(); } } async function tick() { @@ -854,6 +860,25 @@ let destIdsSeen = ''; // destination ids the platforms were fetched for const PLATFORM_COLORS = { twitch: '#9146ff', youtube: '#ff4d4d', kick: '#53fc18', trovo: '#21c26d', restream: '#f27a3f', }; +// Platform marks: the dashboard's own (index.html, kept the same by a +// test). Twitch, YouTube and Kick are their glyphs; relays and local +// targets get a plain symbol; any other platform shows its letter. +const PLATFORM_MARK = { + twitch: '', + youtube: '', + kick: '', + restream: '', + custom: '', + sink: '', +}; +// Put `slug`'s mark (or `fallback`'s first letter) in `el`, once per change. +function setMark(el, slug, fallback) { + const key = slug + '|' + fallback; + if (el._mark === key) return; + el._mark = key; + if (PLATFORM_MARK[slug]) el.innerHTML = PLATFORM_MARK[slug]; + else el.textContent = (slug || fallback || '?').slice(0, 1).toUpperCase(); +} async function fetchDestPlatforms() { const r = await fetchJ('/destinations'); if (!r.ok || !Array.isArray(r.j)) return; @@ -905,8 +930,8 @@ function renderDests() { } function destRow() { const row = document.createElement('div'); - row.innerHTML = ''; - row._name = row.children[1]; row._meta = row.children[2]; + row.innerHTML = ''; + row._glyph = row.children[0].firstChild; row._name = row.children[1]; row._meta = row.children[2]; row._sw = document.createElement('button'); row._sw.setAttribute('role', 'switch'); row._sw.onclick = () => onDestToggle(row); @@ -917,6 +942,7 @@ function patchDestRow(row, d) { row._d = d; const st = destState(d), platform = destPlatforms[d.id] || ''; setClass(row, 'dest ' + st + destColor(row, d)); + setMark(row._glyph, platform, d.name || ''); setText(row._name, d.name || 'Destination'); const rate = cfg.w.dest.stats && st === 'alive' ? fmtRate(d.bitrate_kbps) : ''; setClass(row._meta, rate ? 'd-rate' : 'd-plat'); @@ -931,8 +957,8 @@ function patchDestRow(row, d) { if (row._sw.getAttribute('aria-checked') !== checked) row._sw.setAttribute('aria-checked', checked); if (row._sw.getAttribute('aria-label') !== (d.name || 'Destination')) row._sw.setAttribute('aria-label', d.name || 'Destination'); } -// Compact circular badge: platform initial, live-glow ring, hover shows the -// name (native title). Tap arms it (confirm), tap again commits. +// Compact circular badge: the platform's mark, live-glow ring, hover shows +// the name (native title). Tap arms it (confirm), tap again commits. function destIcon() { const b = document.createElement('button'); b.onclick = () => onDestIcon(b); @@ -944,7 +970,7 @@ function patchDestIcon(b, d) { const st = destState(d); const look = st === 'alive' ? ' alive' : st === 'wait' || st === 'held' ? ' wait' : ''; setClass(b, 'dico ' + (d.enabled ? 'on' : 'off') + look + destColor(b, d)); - setText(b, (destPlatforms[d.id] || d.name || '?').slice(0, 1).toUpperCase()); + setMark(b, destPlatforms[d.id] || '', d.name || ''); setTitle(b, `${d.name || 'Destination'} · ${d.enabled ? 'on' : 'off'}`); if (b.getAttribute('aria-label') !== b.title) b.setAttribute('aria-label', b.title); } @@ -955,7 +981,7 @@ function onDestIcon(b) { // Arm: pulse an amber ring and swap the badge to a check so the "tap again // to confirm" intent is unmistakable, not just a colour change. b._armed = true; - b.classList.add('arm'); b.textContent = '✓'; + b.classList.add('arm'); b.textContent = '✓'; b._mark = ''; b.title = d.enabled ? 'Tap again to turn OFF' : 'Tap again to go LIVE'; b._t = setTimeout(() => disarmDestIcon(b), 3000); } @@ -1205,6 +1231,85 @@ async function copyOverlay(slug) { if (await copyText(url)) toast('Overlay URL copied', 'ok'); else toast(url, 'info'); } +// Events: what integrations list here with a "Dock event" step (the Twitch +// alerts module lists each follow, sub, cheer and raid). Show one group, +// play an event's alert again, or skip the alert on stream now. +let eventCache = [], eventsSeen = -1; +// Not reached (integrations starting, a blip): the widget still shows, and +// asks again a moment later. +const EVENTS_RETRY_MS = 5000; +let eventsRetry = 0; +async function fetchEvents() { + clearTimeout(eventsRetry); + const r = await fetchJ('/dock/events'); + if (r.ok && Array.isArray(r.j)) eventCache = r.j; + else eventsRetry = setTimeout(() => { if (cfg.w.events.on) fetchEvents(); }, EVENTS_RETRY_MS); + renderEvents(); +} +// The row at the top of what's scrolled to, and how far past it: kept +// where it was when the list is drawn again (newest first, so new events +// would push it down). At the very top, the newest stay in view. +function eventsAnchor(list) { + if (!list || !list.scrollTop) return null; + const row = [...list.children].find(r => r.offsetTop - list.offsetTop + r.offsetHeight > list.scrollTop); + return row && { id: row.dataset.id, past: list.scrollTop - (row.offsetTop - list.offsetTop) }; +} +function renderEvents() { + const box = $('w-events'); if (box.hidden) return; + const o = cfg.w.events; + const groups = [...new Set(eventCache.map(e => e.group).filter(Boolean))]; + const only = groups.includes(o.only) ? o.only : ''; + const anchor = eventsAnchor(box.querySelector('.elist')); + box.innerHTML = ''; + const head = document.createElement('div'); head.className = 'ehead'; + if (groups.length > 1) { + const chips = document.createElement('div'); chips.className = 'echips'; + chips.setAttribute('role', 'group'); chips.setAttribute('aria-label', 'Show'); + for (const g of ['', ...groups]) { + const b = document.createElement('button'); b.type = 'button'; + b.className = 'echip' + (g === only ? ' on' : ''); b.textContent = g || 'All'; + b.setAttribute('aria-pressed', String(g === only)); + b.onclick = () => { cfg.w.events.only = g; saveAndApply(); }; + chips.appendChild(b); + } + head.appendChild(chips); + } + if (o.skip) head.appendChild(miniBtn('Skip alert', skipAlert, 'End the alert on stream now: the next one comes on')); + if (head.children.length) box.appendChild(head); + // Newest first; the list scrolls past a few. + const shown = eventCache.filter(e => !only || e.group === only).slice(-50).reverse(); + if (!shown.length) { + box.insertAdjacentHTML('beforeend', '
No events yet. The Twitch alerts module lists each one here; a "Dock event" step adds your own.
'); + return; + } + const list = document.createElement('div'); list.className = 'elist'; + for (const e of shown) list.appendChild(eventRow(e, !only)); + box.appendChild(list); + const kept = anchor && [...list.children].find(r => r.dataset.id === anchor.id); + if (kept) list.scrollTop = kept.offsetTop - list.offsetTop + anchor.past; +} +function eventRow(e, tagged) { + const row = document.createElement('div'); row.className = 'erow'; row.dataset.id = String(e.id); + if (cfg.w.events.times) { + const t = document.createElement('span'); t.className = 'e-time'; + t.textContent = new Date(e.at_ms).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); + row.appendChild(t); + } + const text = document.createElement('span'); text.className = 'e-text'; text.textContent = e.text; text.title = e.text; + row.appendChild(text); + if (tagged && e.group) { const tag = document.createElement('span'); tag.className = 'o-tag'; tag.textContent = e.group; row.appendChild(tag); } + if (e.replay) row.appendChild(miniBtn('Replay', () => replayEvent(e.id), 'Play its alert on stream again')); + return row; +} +async function replayEvent(id) { + const r = await fetchJ('/dock/events/replay', { method: 'POST', headers: FORM, body: 'id=' + id }); + if (r.ok) toast('Playing it again', 'ok'); else toast(failText(r, "Couldn't play it again"), 'err'); +} +async function skipAlert() { + const r = await fetchJ('/dock/alerts/skip', { method: 'POST', headers: FORM, body: '' }); + if (r.ok) toast('Alert skipped', 'ok'); else toast(failText(r, "Couldn't skip it"), 'err'); +} + // -------------------------------------------------------------- layout apply -- function applyCfg(c) { @@ -1229,7 +1334,7 @@ function applyCfg(c) { $('w-bar').hidden = !on('bar'); $('w-bar').classList.toggle('thick', !!c.w.bar.thick); $('w-bar').classList.toggle('mono', !!c.w.bar.mono); - for (const id of ['egress', 'tips', 'dest', 'profiles', 'stats', 'behavior', 'settings', 'overlays', 'activate']) $(WEL[id]).hidden = !on(id); + for (const id of ['egress', 'tips', 'dest', 'profiles', 'stats', 'behavior', 'settings', 'overlays', 'events', 'activate']) $(WEL[id]).hidden = !on(id); document.querySelectorAll('.cur .u').forEach(u => u.hidden = !c.w.number.units); // Apply the saved drag order by re-appending containers in sequence. const wrap = document.querySelector('.wrap'); @@ -1244,6 +1349,7 @@ function applyCfg(c) { if (on('profiles')) fetchProfiles(); if (on('behavior') || on('settings')) { renderBehavior(); renderSettings(); fetchServerCfg(); } if (on('overlays')) fetchOverlays(); + if (on('events')) { eventsSeen = s ? s.feed : -1; fetchEvents(); } if (s) applyState(s); else renderCta(); // re-render with the new options } diff --git a/web/index.html b/web/index.html index 1304bfa..702deb2 100644 --- a/web/index.html +++ b/web/index.html @@ -703,6 +703,54 @@ /* - Hero ------------------- */ .hero-card{padding:0;overflow:hidden;position:relative} +.cp-actions{display:flex;gap:8px;width:100%} +.cp-actions .cp-cta{flex:3;min-width:0} +.cp-cancel{flex:0 0 auto;padding:13px 18px} + +/* Compact: the panel folds into one row while you build overlays or + integrations. What runs the stream stays: its state, the delay, the delay + field, the profiles and the main button. Warnings take a row of their own + under it, and the hold banner shows above it as ever. It is not clipped + (a Cut after card can open over the tabs), so the light is clipped to the + card's corners instead. */ +#hero.compact{overflow:visible} +#hero.compact .hero-aurora{border-radius:inherit} +#hero.compact .hero{grid-template-columns:minmax(0,1fr) auto;align-items:center} +#hero.compact .hero-readout{flex-direction:row;align-items:center;gap:14px;padding:12px 18px 12px 20px;border-right:0} +#hero.compact .hero-delay-row{margin:0;flex:none} +#hero.compact .hero-delay-num{font-size:34px} +#hero.compact .hero-delay-unit{font-size:15px} +#hero.compact .hero-delay-of{font-size:13px} +#hero.compact .hero-delay-sub{flex:1;min-width:0;min-height:0;font-size:12.5px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} +#hero.compact .arm-bar{position:absolute;left:0;right:0;bottom:0;margin:0;height:3px;border-radius:0} +#hero.compact .hero-control{flex-direction:row;flex-wrap:wrap;align-items:center;justify-content:flex-end;gap:8px;padding:10px 14px} +#hero.compact .cp-head,#hero.compact .cp-hint,#hero.compact .cp-unit{display:none} +#hero.compact .cp-input-row{width:168px;padding:3px} +#hero.compact .cp-step{width:28px;height:28px;font-size:16px} +#hero.compact .cp-input{font-size:17px} +#hero.compact .cp-profiles{flex-wrap:nowrap} +#hero.compact .cp-profile-chip{padding:5px 10px} +#hero.compact .cp-cap-warn{order:9;flex-basis:100%;margin-top:0} +#hero.compact .cp-actions{width:auto} +/* One width whatever it says, so a state change never slides the row. */ +#hero.compact .cp-cta{flex:0 0 228px;width:228px;padding:9px 18px;font-size:13px} +#hero.compact .cp-cancel,#hero.compact .cp-cutafter{padding:9px 14px} +/* Opening below the row, since there is no room above it. */ +#hero.compact .cutafter-card{bottom:auto;top:calc(100% + 10px);transform:translateY(-5px)} +#hero.compact .cutafter-wrap:hover .cutafter-card,#hero.compact .cutafter-wrap:focus-within .cutafter-card{transform:none} +@media (max-width:1180px){#hero.compact .cp-profiles{display:none}} +@media (max-width:880px){ + #hero.compact .hero{grid-template-columns:minmax(0,1fr)} + #hero.compact .hero-readout{border-bottom:1px solid color-mix(in oklch,var(--line) 70%,transparent)} + #hero.compact .hero-control{justify-content:flex-start} +} +/* A phone: the state's name and the number, nothing beside them. */ +@media (max-width:560px){#hero.compact .state-desc,#hero.compact .hero-delay-sub{display:none}} +/* The change: the panel eases to its new height while each of its parts + travels to its new place (see foldHero). The readout lets them pass + over its edges on the way. */ +#hero.sizing{overflow:hidden} +#hero.sizing .hero-readout{overflow:visible} .hero{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1fr);align-items:stretch;position:relative;isolation:isolate} @media (max-width:880px){.hero{grid-template-columns:1fr}} @@ -743,25 +791,38 @@ Everything that moves animates transform alone, so the loop costs the compositor, not layout or paint. While buffering, the brightness follows the buffer fill (--fill, set by renderHero): the light grows as the delay - gets ready. */ + gets ready. + The pools move in steps, six a second and all on the same beat (--n and + --m are each pool's time times six, and a third of that). A smooth loop + asks for a new frame at every screen refresh, 165 a second on a fast + monitor, each one blending five blurred layers: with OBS and a game + holding the GPU, the browser could not keep up and the whole dashboard + stuttered. At this size and blur a step moves a pool about 2px, which + nobody can see. */ .hero-aurora{position:absolute;inset:0;z-index:0;pointer-events:none;overflow:hidden;--fill:1} .aurora-flow{position:absolute;inset:0;opacity:calc(var(--aurora-glow) * 1.3 * (.35 + var(--fill) * .65)); transition:opacity .45s linear} .aurora-flow b{position:absolute;left:var(--x);top:var(--y);width:0;height:0;mix-blend-mode:screen;will-change:transform; - animation:aurora-orbit var(--t) linear infinite;animation-direction:var(--dir,normal)} + animation:aurora-orbit var(--t) steps(var(--n)) infinite;animation-direction:var(--dir,normal)} .aurora-flow i{position:absolute;left:var(--r);width:var(--s);height:var(--s);margin:calc(var(--s) / -2) 0 0 calc(var(--s) / -2); border-radius:50%;filter:blur(var(--blur));background:radial-gradient(circle,var(--c),transparent 70%);will-change:transform; - animation:aurora-swell calc(var(--t) / 3) ease-in-out infinite alternate} -.aurora-flow b:nth-child(1){--x:18%;--y:30%;--t:34s;--r:70px;--s:520px;--blur:70px;--c:var(--aurora-a)} -.aurora-flow b:nth-child(2){--x:40%;--y:85%;--t:46s;--r:120px;--s:460px;--blur:64px;--c:var(--aurora-b);--dir:reverse} -.aurora-flow b:nth-child(3){--x:8%;--y:80%;--t:28s;--r:50px;--s:300px;--blur:44px; + animation:aurora-swell calc(var(--t) / 3) steps(var(--m)) infinite alternate} +.aurora-flow b:nth-child(1){--n:204;--m:68;--x:18%;--y:30%;--t:34s;--r:70px;--s:520px;--blur:70px;--c:var(--aurora-a)} +.aurora-flow b:nth-child(2){--n:276;--m:92;--x:40%;--y:85%;--t:46s;--r:120px;--s:460px;--blur:64px;--c:var(--aurora-b);--dir:reverse} +.aurora-flow b:nth-child(3){--n:168;--m:56;--x:8%;--y:80%;--t:28s;--r:50px;--s:300px;--blur:44px; --c:color-mix(in oklch,var(--aurora-a) 55%,var(--accent))} -.aurora-flow b:nth-child(4){--x:78%;--y:15%;--t:52s;--r:90px;--s:420px;--blur:66px;--dir:reverse; +.aurora-flow b:nth-child(4){--n:312;--m:104;--x:78%;--y:15%;--t:52s;--r:90px;--s:420px;--blur:66px;--dir:reverse; --c:color-mix(in oklch,var(--aurora-a) 50%,var(--aurora-b))} -.aurora-flow b:nth-child(5){--x:32%;--y:45%;--t:24s;--r:40px;--s:240px;--blur:40px;--c:var(--aurora-a)} +.aurora-flow b:nth-child(5){--n:144;--m:48;--x:32%;--y:45%;--t:24s;--r:40px;--s:240px;--blur:40px;--c:var(--aurora-a)} @keyframes aurora-orbit{to{transform:rotate(360deg)}} @keyframes aurora-swell{0%{transform:scale(.88)}100%{transform:scale(1.12)}} @media (prefers-reduced-motion: reduce){.aurora-flow b,.aurora-flow i{animation:none}} +/* Calm mode (countFrames sets it while the browser can't keep up): every + looping animation plays once and rests, so nothing asks for frames while + nothing happens. One-shot animations already play once and are left as + they are. Spinners keep turning: a still one reads as a hang. */ +html.calm *,html.calm *::before,html.calm *::after{animation-iteration-count:1 !important} +html.calm .wov-spinner,html.calm .ig-spin,html.calm .is-busy::after{animation-iteration-count:infinite !important} /* Film grain over the lights: big blurred gradients band into visible steps on 8-bit screens; a faint noise dithers them smooth. */ .hero-aurora::after{content:'';position:absolute;inset:0;opacity:.05;mix-blend-mode:overlay; @@ -808,7 +869,7 @@ } .hero-delay-row{display:flex;align-items:baseline;gap:4px;line-height:.9;margin-top:-4px} -.hero-delay-num{font-size:116px;font-weight:500;letter-spacing:-.045em;color:var(--fg); +.hero-delay-num{font-size:var(--hero-fit,116px);font-weight:500;letter-spacing:-.045em;color:var(--fg); /* Color rides the same 1.1 s sweep as the aurora behind it, so the number and the glow change hue together instead of the number snapping a second ahead of the background. */ @@ -937,7 +998,6 @@ .cp-cta::before{content:'';position:absolute;inset:0;border-radius:inherit;pointer-events:none;opacity:0; background:linear-gradient(180deg,rgba(255,255,255,.16),transparent 70%);transition:opacity .25s var(--ease-out)} .cp-cta.ic-btn-primary::before{opacity:1} -#cp-cancel{animation:pillIn .2s var(--ease-out)} .cp-cta-pulse{animation:cta-pulse 1.8s var(--ease-out) infinite} /* Waiting on OBS: a slow sweep and breathing dots say it's listening. */ .cp-cta-waiting::after{content:'';position:absolute;inset:0;pointer-events:none;transform:translateX(-100%); @@ -966,7 +1026,7 @@ width - the wrap itself is too narrow to host readable copy. The wrap never shrinks below its label: on a phone a quarter of the row is narrower than "Cut after", so Cut delay gives up the room. */ -.cutafter-wrap{position:relative;flex:1;min-width:max-content;animation:pillIn .2s var(--ease-out)} +.cutafter-wrap{position:relative;flex:1;min-width:max-content} /* display:none must survive the class's own display value - [hidden] loses to any author display rule otherwise. */ .cutafter-wrap[hidden]{display:none} @@ -1679,10 +1739,88 @@ .overlay-section{background:var(--bg-2);border:1px solid var(--line);border-radius:10px;padding:16px; display:flex;flex-direction:column;gap:6px} -/* - Overlay gallery --------------- */ -.ovg-grid{display:grid;grid-template-columns:minmax(0,1.55fr) minmax(0,1fr);gap:14px;margin-top:6px} -@media (max-width:980px){.ovg-grid{grid-template-columns:minmax(0,1fr)}} -.ovg-stage{position:relative;width:100%;aspect-ratio:16/9;margin-top:10px;overflow:hidden; +/* - Overlay gallery: as the Studio is laid out, every overlay on the left, + the chosen one's preview in the middle and what to do with it on the + right, all on one screen. The list is as tall as the preview beside it + and scrolls within (it is positioned out of the row's sizing for that). + Narrower, the details go under the preview, then everything stacks. ---- */ +.ovg-page{display:flex;flex-direction:column;gap:14px} +.ovg-top{display:grid;grid-template-columns:minmax(190px,240px) minmax(0,1fr) minmax(240px,300px);gap:14px;align-items:stretch} +.ovg-top>.ovg-detail{align-self:start} +/* The pages take all the card's height: the one row of columns grows into + it, the list shows as many as fit, and the details run the full height + with Duplicate and Delete at their foot. The preview is the largest 16:9 + its column holds, wide or tall (its box is a size container for that). + Narrower, where the columns stack, everything keeps its own height. */ +@media (min-width:561px){ + .tab-pane[data-pane="overlay"]{min-height:100%} + #ov-gallery,#ov-gallery>.sub-pane:not([hidden]),.ov-stage,.ovg-page{flex:1 0 auto} + .ovg-top{flex:1 0 auto} +} +@media (min-width:1241px){ + .ovg-top>.ovg-detail{align-self:stretch} + .ovg-detail>.ovg-more{margin-top:auto} + .ovg-fit{flex:1;min-height:200px;display:grid;place-items:start center;container-type:size} + .ovg-fit>.ovg-stage{width:min(100cqw,calc(100cqh * 16 / 9))} +} +@media (max-width:1240px){ + .ovg-top{grid-template-columns:minmax(190px,240px) minmax(0,1fr)} + .ovg-top>.ovg-detail{grid-column:2} + .ovg-top>.ovg-shelf{grid-row:1/3} +} +@media (max-width:760px){ + .ovg-top{grid-template-columns:minmax(0,1fr)} + .ovg-top>.ovg-detail,.ovg-top>.ovg-shelf{grid-column:auto;grid-row:auto} +} +.ovg-view{gap:10px;padding:12px} +/* Every page's preview has the same bar: "Preview", what it plays, then + what you can do with it, at the end. */ +.ovg-viewbar{display:flex;align-items:center;gap:8px 10px;flex-wrap:wrap;min-height:36px} +.ovg-viewbar>.ic-label{margin:0} +.ovg-viewbar>.ph-cap:empty{display:none} +.ovg-viewend{margin-left:auto} +/* The delay's states, one segmented control: one of them plays. */ +.ovg-viewbar .st-simbar{gap:2px;padding:3px} +.ovg-viewbar .st-sim,.ovg-viewbar .st-auto{padding:5px 10px;border-color:transparent;border-radius:7px;background:none} +.ovg-viewbar .st-sim:hover,.ovg-viewbar .st-auto:hover{transform:none;color:var(--fg);background:var(--surface)} +.ovg-viewbar .st-sim.on,.ovg-viewbar .st-auto.on{color:var(--accent-ink);background:var(--accent)} +/* A phone: its own row, three and three. */ +@media (max-width:560px){ + .ovg-viewbar .st-simbar{flex-basis:100%;display:grid;grid-template-columns:repeat(3,minmax(0,1fr))} + .ovg-viewbar .st-sim,.ovg-viewbar .st-auto{justify-content:center} +} +.ovg-detail{gap:14px} +.ovg-detail-head{display:flex;align-items:center;gap:12px;min-width:0} +.ovg-ic,.ovg-tile-ic{display:grid;place-items:center;flex:none;color:var(--tc,var(--accent)); + background:color-mix(in oklch,var(--tc,var(--accent)) 14%,var(--surface));border:1px solid color-mix(in oklch,var(--tc,var(--accent)) 30%,transparent)} +.ovg-ic{width:40px;height:40px;border-radius:11px} +.ovg-ic:empty{visibility:hidden} +.ovg-ic svg{width:19px;height:19px} +.ovg-detail-id{min-width:0} +.ovg-title{font-size:16px;font-weight:650;letter-spacing:-.01em;color:var(--fg);overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +.ovg-kind{font-size:12px;color:var(--fg-3);margin-top:2px} +.ovg-primary{display:flex;gap:8px;flex-wrap:wrap} +.ovg-primary:empty{display:none} +.ovg-primary .ic-btn{flex:1 1 auto;justify-content:center} +.ovg-urlbox{display:flex;flex-direction:column;gap:6px} +.ovg-urlbox .ic-label{margin:0} +.ovg-urlbox .copy-block{margin:0} +.ovg-urlbox .copy-block code{font-size:12px;line-height:1.45;white-space:normal;overflow-wrap:anywhere} +.ovg-hint{font-size:11px;color:var(--fg-4);line-height:1.5} +.ovg-more{margin-top:0;padding-top:12px;border-top:1px solid var(--line)} +.ovg-more:empty{display:none} +.ovg-shelf{position:relative;gap:10px;padding:12px;min-height:220px} +.ovg-shelf-head{display:flex;align-items:baseline;justify-content:space-between;gap:10px;padding:2px 2px 0} +.ovg-shelf-head .ic-label{margin:0} +.ovg-count{font-size:11px;color:var(--fg-4);font-variant-numeric:tabular-nums} +.ovg-folder>summary{cursor:pointer;list-style:none;display:flex;align-items:center;gap:8px;font-size:12.5px;font-weight:600;color:var(--fg-2);user-select:none} +.ovg-folder>summary::-webkit-details-marker{display:none} +.ovg-folder>summary::after{content:"";width:7px;height:7px;margin-left:auto;border-right:1.5px solid currentColor;border-bottom:1.5px solid currentColor; + transform:translateY(-2px) rotate(45deg);transition:transform .2s var(--ease-out);opacity:.7} +.ovg-folder[open]>summary::after{transform:translateY(1px) rotate(-135deg)} +.ovg-folder[open]{gap:10px} +.ovg-folder>summary:hover{color:var(--fg)} +.ovg-stage{position:relative;width:100%;aspect-ratio:16/9;overflow:hidden; border:1px solid var(--line);border-radius:10px;background: linear-gradient(45deg,#0a0c10 25%,transparent 25%,transparent 75%,#0a0c10 75%), linear-gradient(45deg,#0a0c10 25%,#0e1116 25%,#0e1116 75%,#0a0c10 75%); @@ -1695,22 +1833,93 @@ sits on the stage, exactly as it will over video in OBS. */ .ovg-scale iframe{width:1920px;height:1080px;border:0;background:transparent;display:block; color-scheme:normal} -.ovg-list{display:flex;flex-direction:column;gap:7px;padding:10px 14px 14px;overflow-y:auto;max-height:520px} +/* Every overlay, a tile each, grouped by what it is. */ +.ovg-list{display:flex;flex-direction:column;gap:6px} +/* (On the shelf: over [data-flip]'s position, which a list that animates + its rows carries.) */ +.ovg-shelf>.ovg-list{position:absolute;inset:40px 6px 8px 12px;padding-right:6px;overflow-y:auto;overscroll-behavior:contain} +@media (max-width:760px){.ovg-shelf>.ovg-list{position:static;display:grid;grid-template-columns:repeat(auto-fill,minmax(min(200px,100%),1fr));padding:0}} +.ovg-group{grid-column:1/-1;flex:none;margin:8px 2px 0;font-size:10.5px;font-weight:600;letter-spacing:.1em;text-transform:uppercase;color:var(--fg-4)} +.ovg-group:first-child{margin-top:0} +.ovg-tile{appearance:none;flex:none;display:flex;align-items:center;gap:10px;min-width:0;padding:9px;text-align:left;cursor:pointer; + background:var(--surface);border:1px solid var(--line);border-radius:12px;color:var(--fg);font:inherit; + transition:transform .15s var(--ease-out),border-color .15s var(--ease-out),background-color .15s var(--ease-out),box-shadow .15s var(--ease-out)} +.ovg-tile:hover{border-color:var(--line-2);transform:translateY(-1px)} +.ovg-tile:active{transform:scale(.97)} +.ovg-tile.sel{border-color:color-mix(in oklch,var(--tc) 60%,transparent);background:color-mix(in oklch,var(--tc) 8%,var(--surface)); + box-shadow:0 0 0 1px color-mix(in oklch,var(--tc) 30%,transparent)} +.ovg-tile-ic{width:32px;height:32px;border-radius:9px} +.ovg-tile-ic svg{width:16px;height:16px} +.ovg-tile-txt{flex:1;display:flex;flex-direction:column;gap:2px;min-width:0} +.ovg-tile-name{font-size:13px;font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +.ovg-tile-path{font-size:10.5px;color:var(--fg-4);overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +@media (prefers-reduced-motion:reduce){.ovg-tile{transition:none}} .ov-dir-row{display:flex;gap:8px;align-items:center;margin-top:8px} +/* - Overlay tab: its pages. The gallery is above; Alerts and Goals are + drawn by integrations.js and reuse its cards and rows. ---- */ +#ov-gallery{display:flex;flex-direction:column;gap:14px} +/* Three pages don't fill a strip the way System's seven do: sized to their + words, at the start of the row. */ +#ov-sub-tabs{align-self:flex-start;max-width:100%} +#ov-sub-tabs .sub-tab{flex:0 0 auto;padding:6px 14px} +.ov-head-acts{display:flex;flex-wrap:wrap;gap:8px} +.ov-head-acts[hidden]{display:none} +.ov-stage{display:flex;flex-direction:column;gap:14px} +.og-row{display:flex;flex-wrap:wrap;align-items:center;gap:8px} +.og-push{margin-left:auto} +/* Alerts: the one source that plays them all, across the top of the + gallery of alert designs (in OBS or not, and its address); the designs + as tiles, in the gallery and in an alert's Design chip. */ +#ov-alerts{flex:none} +.og-asource-url{display:flex;align-items:center;gap:10px;min-width:0;padding-left:12px;border-left:1px solid var(--line)} +.og-asource-url code{font-size:12px;color:var(--fg-2);overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +.og-dgrid{display:grid;grid-template-columns:repeat(auto-fill,minmax(min(128px,100%),1fr));gap:6px} +.og-dgrid>.ovg-tile{padding:7px} +/* A ready-made design's tile shows its card's colours: its words on its + background, under a line of its accent. */ +.og-dswatch{flex:none;display:grid;place-items:center;width:32px;height:32px;border-radius:9px; + font-size:12px;font-weight:700;color:var(--sf);background:var(--sb);box-shadow:inset 0 -3px 0 var(--sa),0 0 0 1px rgba(255,255,255,.08)} +/* Goals and timers: a tile says where each one stands; the details hold + the big number, its bar and what moves it. */ +.og-tbar{display:block;height:3px;margin-top:5px;border-radius:999px;background:var(--surface-3);overflow:hidden} +.og-tbar>i{display:block;height:100%;border-radius:999px;background:var(--tc);transition:transform .7s cubic-bezier(.16,1,.3,1)} +.og-tnone{display:flex;flex-direction:column;align-items:center;justify-content:center;gap:6px;padding:20px;text-align:center} +.og-tnone b{font-size:14px;color:var(--fg)} +.og-tnone span{max-width:44ch;font-size:12.5px;line-height:1.55;color:var(--fg-3);text-wrap:pretty} +.og-meter{display:flex;flex-direction:column;gap:12px} +.og-detail[data-key]{animation:ovg-in .24s var(--ease-out) both}.og-figure{display:flex;align-items:baseline;gap:8px;min-height:34px} +.og-value{font-size:32px;font-weight:650;letter-spacing:-.03em;line-height:1;color:var(--fg)} +.og-of{font-size:13px;color:var(--fg-3)} +.og-pct{margin-left:auto;font-size:12px;font-weight:600;color:var(--dc)} +.og-state{margin-left:auto;align-self:center} +/* The bar fills by sliding, as the one on stream does: a transform only, on + the same curve, brightest at its leading edge. Reached, it glows. */ +.og-bar{height:8px;border-radius:999px;background:var(--surface-3);overflow:hidden;transition:box-shadow .4s var(--ease-out)} +.og-bar>i{display:block;height:100%;border-radius:999px;transition:transform .7s cubic-bezier(.16,1,.3,1); + background:linear-gradient(90deg,color-mix(in oklch,var(--dc) 55%,transparent),var(--dc))} +.og-meter.done .og-bar{box-shadow:0 0 14px -2px color-mix(in oklch,var(--dc) 70%,transparent)} +.og-nudge{min-width:46px;font-variant-numeric:tabular-nums} +.og-note{max-width:86ch;line-height:1.6;text-wrap:pretty} +.og-seg{align-self:flex-start;max-width:100%} +.og-form-label{margin:6px 0 -4px} +.og-dfg3{grid-template-columns:repeat(3,minmax(0,1fr))} +.og-del{margin-right:auto} +@media (max-width:720px){.og-dfg3{grid-template-columns:minmax(0,1fr)}.og-value{font-size:28px}} +@media (prefers-reduced-motion:reduce){.og-bar>i,.og-tbar>i{transition:none}.og-detail[data-key]{animation:none}} .ov-dir-row input{flex:1;min-width:0} .ov-dir-row .ic-btn{flex:0 0 auto} -.ovg-card{appearance:none;text-align:left;background:var(--surface);border:1px solid var(--line); - border-radius:10px;padding:11px 13px;cursor:pointer;display:flex;flex-direction:column;gap:3px; - transition:border-color .12s,transform .12s;position:relative} -.ovg-card:hover{border-color:var(--line-2);transform:translateY(-1px)} -.ovg-card.sel{border-color:color-mix(in oklch,var(--accent) 60%,transparent); - box-shadow:0 0 0 1px color-mix(in oklch,var(--accent) 40%,transparent)} -.ovg-card-name{font-size:13px;font-weight:600;color:var(--fg)} -.ovg-card-hint{font-size:10.5px;color:var(--fg-4);line-height:1.4} -.ovg-card-tag{position:absolute;top:9px;right:11px;font-size:9px;font-weight:700;letter-spacing:.06em; - padding:2px 6px;border-radius:6px;background:rgba(255,255,255,.06);color:var(--fg-4)} -.ovg-card-tag:empty{display:none} -.ovg-empty{padding:26px 16px;text-align:center;color:var(--fg-4);font-size:13px;line-height:1.6} +/* What a page or a pick brings in arrives instead of snapping in: a page's + own buttons, the chosen overlay's, the browser or its empty page. */ +@keyframes ovg-in{from{opacity:0;transform:translateY(3px)}} +.ov-head-acts:not([hidden]),.ovg-top:not([hidden]),#ovg-primary>*,#ovg-actions>*{animation:ovg-in .24s var(--ease-out) both} +@media (prefers-reduced-motion:reduce){.ov-head-acts,.ovg-top,#ovg-primary>*,#ovg-actions>*,.ovg-blank{animation:none}} +/* A page with nothing on it yet: what it is for, and how to start. */ +.ovg-blank{display:flex;flex-direction:column;align-items:center;gap:10px;padding:44px 24px;text-align:center; + border:1px dashed var(--line-2);border-radius:14px;animation:st-fade-in .25s var(--ease-out)} +.ovg-blank[hidden]{display:none} +.ovg-blank b{font-size:15px;color:var(--fg)} +.ovg-blank span{max-width:62ch;font-size:13px;line-height:1.6;color:var(--fg-3);text-wrap:pretty} +.ovg-blank .og-row{justify-content:center;margin-top:6px} /* - Overlay Studio ---------------─ */ .st-toolbar{display:flex;align-items:center;gap:8px;flex-wrap:wrap} @@ -1723,6 +1932,9 @@ font-size:11.5px;font-weight:600;padding:9px 4px;cursor:pointer;letter-spacing:.02em} .st-tabs button.on{color:var(--fg);box-shadow:inset 0 -2px 0 var(--accent)} .st-left-body{overflow-y:auto;padding:10px;display:flex;flex-direction:column;gap:7px;flex:1} +/* Many layers scroll: a row never shrinks to fit (it clips its overflow, so + as a flex item it could be squeezed down to nothing). */ +.st-left-body>*{flex:none} .st-item{appearance:none;text-align:left;background:var(--surface);border:1px solid var(--line); border-radius:8px;padding:9px 11px;cursor:pointer;color:var(--fg-2);font-size:12.5px; display:flex;align-items:center;justify-content:space-between;gap:8px;transition:border-color .12s,transform .12s} @@ -1731,6 +1943,13 @@ border-radius:6px;background:var(--bg-2);border:1px solid var(--line);font-size:11px;color:var(--accent); font-family:'JetBrains Mono',monospace;line-height:1} .st-item-l{flex:1;text-align:left} +/* The Add tab: two tiles a row, icon over name. */ +.st-tiles{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:6px} +.st-tiles .st-item{position:relative;flex-direction:column;align-items:flex-start;justify-content:flex-start;gap:9px;padding:10px;min-height:72px;font-size:12px;line-height:1.25} +.st-tiles .st-item:active{transform:scale(.96)} +.st-tiles .st-item .st-glyph{width:28px;height:28px;border-radius:8px;color:var(--accent); + background:color-mix(in oklch,var(--accent) 12%,var(--bg-2));border-color:color-mix(in oklch,var(--accent) 28%,transparent)} +.st-tiles .st-item .st-cost{position:absolute;top:8px;right:8px} .st-item .st-cost{font-size:9px;font-weight:700;letter-spacing:.06em;padding:1px 5px;border-radius:5px; background:color-mix(in oklch,var(--warn) 16%,transparent);color:var(--warn)} .st-canvas-wrap{display:flex;flex-direction:column;gap:10px;min-width:0;min-height:0} @@ -1773,9 +1992,17 @@ .st-menu-pop button:hover{background:rgba(255,255,255,.06);color:var(--fg)} .st-menu-pop button.st-danger:hover{background:color-mix(in oklch,var(--danger) 14%,transparent)} .st-modal-body{flex:1;min-height:0;padding:12px;display:grid; - grid-template-columns:208px minmax(0,1fr) 290px;grid-template-rows:minmax(0,1fr);gap:12px} + grid-template-columns:224px minmax(0,1fr) 300px;grid-template-rows:minmax(0,1fr);gap:12px} @media (max-width:1120px){.st-modal-body{grid-template-columns:180px minmax(0,1fr) 250px}} -.st-modal-foot{padding:9px 16px;border-top:1px solid var(--line);font-size:11.5px;color:var(--fg-3);display:flow;align-items:center} +/* Too narrow for three columns: the canvas keeps the width, layers go + under it, and the properties run down the right. */ +@media (max-width:900px){ + .st-modal-body{grid-template-columns:minmax(0,1fr) 250px;grid-template-rows:minmax(0,3fr) minmax(0,2fr)} + .st-canvas-wrap{grid-area:1/1} + .st-left{grid-area:2/1} + .st-right{grid-area:1/2/3/3} +} +.st-modal-foot{padding:9px 16px;border-top:1px solid var(--line);font-size:11.5px;color:var(--fg-3)} .st-perf{font-size:9.5px;font-weight:700;letter-spacing:.05em;padding:2px 8px;border-radius:6px;text-transform:uppercase;cursor:help} .st-perf.ok{background:color-mix(in oklch,var(--live) 15%,transparent);color:var(--live)} .st-perf.warn{background:color-mix(in oklch,var(--warn) 16%,transparent);color:var(--warn)} @@ -1786,13 +2013,15 @@ .st-statebar2{display:flex;align-items:center;justify-content:space-between;gap:10px;flex-wrap:wrap; background:var(--bg-2);border:1px solid var(--line);border-radius:10px;padding:7px 9px} .st-states{display:flex;gap:5px;flex-wrap:wrap} +/* What the bar says an overlay that isn't about the delay does. */ +.st-bar-note{font-size:11.5px;color:var(--fg-4);margin-left:6px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} .st-st{appearance:none;display:inline-flex;align-items:center;gap:7px;background:var(--surface); border:1px solid var(--line);border-radius:8px;color:var(--fg-3);font-size:12px;font-weight:600; padding:7px 12px;cursor:pointer;transition:border-color .12s,background .12s,color .12s} .st-st:hover{color:var(--fg);border-color:var(--line-2)} .st-st .dot{width:9px;height:9px;border-radius:50%;flex:none} .st-st.on{color:var(--fg);border-color:var(--accent);background:color-mix(in oklch,var(--accent) 13%,var(--surface))} -.st-state-actions{display:flex;align-items:center;gap:6px} +.st-state-actions{display:flex;align-items:center;gap:6px;flex-wrap:wrap} /* Left panel: layers + add */ .st-sec{font-size:10px;letter-spacing:.12em;text-transform:uppercase;color:var(--fg-4);margin:10px 2px 4px} @@ -1801,11 +2030,24 @@ border-radius:8px;background:var(--surface);cursor:pointer;transition:border-color .12s} .st-layer:hover{border-color:var(--line-2)} .st-layer.sel{border-color:var(--accent);box-shadow:inset 0 0 0 1px var(--accent)} -.st-layer-sw{width:12px;height:12px;border-radius:4px;flex:none;box-shadow:0 0 0 1px rgba(255,255,255,.14),0 0 8px -2px currentColor} +.st-layer-sw,.st-props-ic{width:24px;height:24px;border-radius:7px;flex:none;display:grid;place-items:center;color:var(--sw); + background:color-mix(in oklch,var(--sw) 16%,var(--surface-3));border:1px solid color-mix(in oklch,var(--sw) 38%,var(--line-2))} +/* A layer coloured near black (a dark card) keeps its hue, lifted to read. */ +@supports (color:oklch(from red l c h)){.st-layer-sw,.st-props-ic{color:oklch(from var(--sw) max(l,.72) c h)}} +.st-layer-sw svg{width:13px;height:13px} +.st-props-ic{width:30px;height:30px;border-radius:8px} +.st-props-ic svg{width:16px;height:16px} .st-layer-name{flex:1;font-size:12.5px;color:var(--fg-2);overflow:hidden;text-overflow:ellipsis;white-space:nowrap} .st-layer.sel .st-layer-name{color:var(--fg)} .st-layer-btn{appearance:none;background:transparent;border:0;color:var(--fg-4);cursor:pointer; - font-size:13px;line-height:1;padding:3px 5px;border-radius:5px} + line-height:0;padding:5px;border-radius:6px;opacity:0;transition:opacity .15s var(--ease-out),color .12s,background .12s} +.st-layer-btn svg{width:13px;height:13px} +/* A row's buttons show where they can be used: on the row under the pointer, + the selected one, or from the keyboard. */ +.st-layer-btn:focus-visible,.st-animrow .st-layer-btn{opacity:1} +.st-layer .st-layer-btn{display:none;opacity:1} +.st-layer:hover .st-layer-btn,.st-layer.sel .st-layer-btn,.st-layer:focus-within .st-layer-btn{display:inline-block} +@media (hover:none){.st-layer .st-layer-btn{display:inline-block}} .st-layer-btn:hover{color:var(--fg);background:rgba(255,255,255,.07)} .st-empty-hint{padding:18px 12px;text-align:center;color:var(--fg-4);font-size:12px;line-height:1.6} @@ -1813,7 +2055,14 @@ .st-pgroup{border-top:1px solid var(--line);padding-top:12px;margin-top:4px;display:flex;flex-direction:column;gap:11px} .st-pgroup:first-of-type{border-top:0;padding-top:0;margin-top:0} .st-pgroup-h{font-size:10.5px;letter-spacing:.1em;text-transform:uppercase;color:var(--fg-3); - display:flex;align-items:center;gap:8px;font-weight:700} + display:flex;align-items:center;gap:8px;font-weight:700;cursor:pointer;user-select:none;border-radius:6px;margin:-4px -6px;padding:4px 6px} +.st-pgroup-h:hover{color:var(--fg)} +.st-pgroup-h:focus-visible{outline:2px solid color-mix(in oklch,var(--accent) 60%,transparent)} +.st-pgroup-h::after{content:"";margin-left:auto;width:7px;height:7px;flex:none;border-right:1.5px solid currentColor;border-bottom:1.5px solid currentColor; + transform:translateY(-2px) rotate(45deg);transition:transform .2s var(--ease-out);opacity:.7} +.st-pgroup.folded .st-pgroup-h::after{transform:rotate(-45deg)} +.st-pgroup.folded>:not(.st-pgroup-h){display:none} +.st-pgroup-h>span:first-child{display:flex;align-items:center;gap:8px} .st-pgroup-h .dot{width:9px;height:9px;border-radius:50%;flex:none} .st-vischips{display:flex;gap:5px;flex-wrap:wrap} .st-vischip{appearance:none;background:var(--surface);border:1px solid var(--line);border-radius:7px; @@ -1825,19 +2074,38 @@ .st-vischip.on::before{opacity:1} .st-allbtn{appearance:none;background:transparent;border:0;color:var(--accent);font-size:10px;font-weight:600;cursor:pointer;padding:1px 2px;letter-spacing:.01em} .st-allbtn:hover{text-decoration:underline} -.st-hint{font-size:10.5px;color:var(--fg-4);line-height:1.5;margin-top:-2px} +.st-hint{font-size:11px;color:var(--fg-3);line-height:1.5;margin-top:-2px;text-wrap:pretty} +.st-css{min-height:120px;font-size:11.5px;resize:vertical} .st-hint code{font-family:'JetBrains Mono',monospace;color:var(--accent);font-size:10px} -.st-swatches{display:flex;gap:5px;margin-top:6px} +.st-swatches{display:flex;flex-wrap:wrap;gap:5px;margin-top:6px} .st-sw{width:22px;height:22px;border-radius:6px;border:1px solid var(--line-2);cursor:pointer;padding:0} .st-sw:hover{transform:scale(1.1)} .st-animrow{padding:7px;border:1px solid var(--line);border-radius:8px;background:var(--surface);margin-bottom:6px;display:flex;flex-direction:column;gap:5px} .st-animrow-top{display:flex;gap:5px;align-items:center} .st-animrow-top select{flex:1} -.st-animrow-bot{display:grid;grid-template-columns:1fr 1fr 1fr;gap:5px} +.st-animrow-bot{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:5px} +.st-animrow-bot label{display:flex;flex-direction:column;gap:3px;font-size:10.5px;color:var(--fg-4);min-width:0} +.st-range{display:flex;align-items:center;gap:10px} +.st-amprow{display:flex;align-items:center;gap:8px;margin-top:5px;font-size:11px;color:var(--fg-3)} +.st-amprow span{white-space:nowrap} +.st-amprow input{flex:1;min-width:0} +.st-amprow b{min-width:30px;text-align:right;color:var(--fg)} +.st-range input{flex:1;min-width:0} +.st-range output{min-width:2.6em;text-align:right;font:500 11.5px 'JetBrains Mono',monospace;color:var(--fg-2);font-variant-numeric:tabular-nums} +.st-mini{padding:5px 10px;font-size:11.5px} +.st-say{display:flex;flex-direction:column;gap:8px;padding:10px;border:1px solid var(--line);border-radius:8px;background:var(--surface)} +.st-say-h{font-size:11px;font-weight:600;color:var(--fg-2)} +.st-say-row{display:flex;flex-direction:column;gap:4px;font-size:11px;color:var(--fg-3)} +.st-say-row>div{display:flex;flex-wrap:wrap;gap:4px} +.st-withic{display:inline-flex;align-items:center;gap:6px} +.st-withic svg,.st-icobtn svg{width:15px;height:15px;flex:none} +.st-glyph svg{width:15px;height:15px} .st-align{display:grid;grid-template-columns:repeat(3,1fr);gap:4px;max-width:120px} -.st-alignb{appearance:none;background:var(--surface);border:1px solid var(--line);border-radius:6px; - color:var(--fg-3);font-size:13px;padding:6px;cursor:pointer;line-height:1} -.st-alignb:hover{border-color:var(--accent);color:var(--fg)} +/* Each button is the screen in small, with a dot where the widget goes. */ +.st-alignb{appearance:none;display:flex;height:28px;background:var(--surface);border:1px solid var(--line);border-radius:6px; + color:var(--fg-4);padding:5px;cursor:pointer;transition:border-color .12s,color .12s} +.st-alignb i{width:7px;height:7px;border-radius:2px;background:currentColor} +.st-alignb:hover{border-color:var(--accent);color:var(--accent)} .st-chip{appearance:none;background:color-mix(in oklch,var(--accent) 12%,transparent);border:1px solid color-mix(in oklch,var(--accent) 30%,transparent); color:var(--accent);font-family:'JetBrains Mono',monospace;font-size:10px;padding:2px 6px;border-radius:5px;cursor:pointer;margin:2px 3px 0 0} .st-chip:hover{background:color-mix(in oklch,var(--accent) 22%,transparent)} @@ -1855,7 +2123,7 @@ /* Auto-preview toggle (clearly a mode, not an animation pause) */ .st-auto{appearance:none;display:inline-flex;align-items:center;gap:8px;background:var(--surface); border:1px solid var(--line);border-radius:8px;color:var(--fg-2);font-size:11.5px;font-weight:600; - padding:7px 13px;cursor:pointer;transition:background .14s,border-color .14s,color .14s} + padding:7px 13px;cursor:pointer;white-space:nowrap;transition:background .14s,border-color .14s,color .14s} .st-auto:hover{border-color:var(--line-2);color:var(--fg)} .st-auto .ic{width:9px;height:9px;border-radius:50%;background:currentColor;flex:none;opacity:.6} .st-auto.on{background:var(--accent);border-color:transparent;color:var(--accent-ink)} @@ -1868,9 +2136,10 @@ .st-modal:not([hidden]) .st-modal-card{animation:st-card-in .26s cubic-bezier(.2,.8,.2,1)} /* button + chip microinteractions */ -.st-sim,.st-st,.st-item,.ovg-card,.st-layer,.st-layer-btn,.st-auto{transition:transform .12s var(--ease-out),border-color .14s,background .14s,color .14s,box-shadow .14s} +.st-sim,.st-st,.st-item,.st-layer,.st-layer-btn,.st-auto{transition:transform .12s var(--ease-out),border-color .14s,background .14s,color .14s,box-shadow .14s} .st-sim:hover,.st-st:hover,.st-auto:hover{transform:translateY(-1px)} -.st-sim:active,.st-st:active,.st-item:active,.ovg-card:active,.st-auto:active{transform:translateY(0) scale(.985)} +.st-sim:active,.st-st:active,.st-auto:active{transform:scale(.96)} +.st-item:active{transform:scale(.985)} .st-st.on .dot{animation:ph-pulse 1.8s ease-in-out infinite} /* selected layer accent stripe */ @@ -1899,28 +2168,42 @@ .st-sim{appearance:none;background:var(--surface);border:1px solid var(--line);border-radius:7px; color:var(--fg-3);font-size:11.5px;font-weight:600;padding:6px 11px;cursor:pointer} .st-sim.on{color:var(--accent-ink);background:var(--accent);border-color:transparent} -.st-sim-sep{width:1px;height:20px;background:var(--line);margin:0 3px} -.st-props-head{padding:12px 14px;border-bottom:1px solid var(--line)} +.st-props-head{padding:12px 14px;border-bottom:1px solid var(--line);display:flex;align-items:center;gap:10px} .st-props-title{font-size:13px;font-weight:600;color:var(--fg)} .st-props-sub{font-size:11px;color:var(--fg-4);font-family:'JetBrains Mono',monospace} .st-props-body{overflow-y:auto;padding:12px 14px;flex:1;display:flex;flex-direction:column;gap:11px; animation:st-fade-in .2s var(--ease-out)} +/* Redrawn for the same widget (a toggle, a swatch): it stays put, no fade. */ +.st-props-body.st-quiet{animation:none} .st-field{display:flex;flex-direction:column;gap:5px} .st-field-row{display:flex;gap:8px} -.st-field-row .st-field{flex:1} +/* Side by side, each gets half: never the width its input would like. */ +.st-field-row>*{flex:1 1 0;min-width:0} +.st-field-row>.ic-btn{flex:none} .st-field label{font-size:11px;color:var(--fg-3);font-weight:500} +.st-field>label:has(>.st-allbtn){display:flex;justify-content:space-between;align-items:center} .st-field .ic-input,.st-field select{padding:8px 10px;font-size:12.5px;line-height:1.35;border-radius:8px} .st-field select.ic-input{padding-right:30px;background-position:right 10px center} @supports (appearance:base-select){.st-field select.ic-input{padding-right:10px}} .st-field textarea.ic-input{height:auto;min-height:60px;font-family:'JetBrains Mono',monospace;font-size:11.5px;line-height:1.5} .st-color{display:flex;align-items:center;gap:8px} -.st-color input[type=color]{width:34px;height:30px;border:1px solid var(--line);border-radius:6px;background:transparent;padding:2px;cursor:pointer} +.st-color .ic-input{flex:1;min-width:0} +.st-color input[type=color]{flex:none;width:34px;height:30px;border:1px solid var(--line);border-radius:6px;background:transparent;padding:2px;cursor:pointer} .st-check{display:inline-flex;align-items:center;gap:8px;font-size:12.5px;color:var(--fg-2);cursor:pointer} .st-check input{width:15px;height:15px;cursor:pointer} .st-cheat{background:var(--surface);border:1px solid var(--line);border-radius:8px;padding:8px 10px; font-size:11px;color:var(--fg-3);line-height:1.6} .st-cheat code{font-family:'JetBrains Mono',monospace;color:var(--accent);font-size:10.5px} -.st-props-empty{padding:40px 18px;text-align:center;color:var(--fg-4);font-size:12.5px;line-height:1.6} +.st-props-empty{margin:auto;padding:28px 22px;display:flex;flex-direction:column;align-items:center;gap:8px; + text-align:center;color:var(--fg-3);font-size:12.5px;line-height:1.55;text-wrap:pretty;animation:st-fade-in .25s var(--ease-out)} +.st-props-empty b{color:var(--fg);font-size:13px;font-weight:600} +.st-props-empty .ic-btn{margin-top:6px} +.st-empty-ic{width:40px;height:40px;border-radius:12px;display:grid;place-items:center;margin-bottom:4px;color:var(--accent); + background:color-mix(in oklch,var(--accent) 12%,var(--surface));border:1px solid color-mix(in oklch,var(--accent) 26%,transparent)} +.st-empty-ic svg{width:18px;height:18px} +/* A layer just added (or duplicated) arrives in the list. */ +@keyframes st-layer-in{from{opacity:0;transform:translateY(-6px) scale(.98)}} +.st-layer.arrived{animation:st-layer-in .32s var(--ease-out)} .st-rowbtns{display:flex;gap:8px;margin-top:2px;flex-wrap:wrap;align-items:center} .ovg-switch{display:inline-flex;align-items:center;gap:7px;font-size:12px;color:var(--fg2,#c5cad3);cursor:pointer; background:var(--bg2,#0c0e12);border:1px solid var(--line,rgba(255,255,255,.1));border-radius:8px;padding:7px 11px;user-select:none} @@ -2159,12 +2442,13 @@ height:clamp(420px,calc(100vh - 330px),900px);overflow-y:auto; scrollbar-width:thin;scrollbar-color:var(--line-2) transparent} /* Beside a phone's width the pane card is fitted to the window and scrolls - on its own. The Logs tab fills it instead: the console takes the room the - filters leave, so it is the one thing that scrolls. A fixed-height console - in a scrolling card meant two scrollbars, and the wheel over the console - never reached the card. */ + on its own, and every tab gets all of it: switching tabs, or folding the + delay panel, never moves the card's bottom edge. The Logs tab fills it: + the console takes the room the filters leave, so it is the one thing that + scrolls. A fixed-height console in a scrolling card meant two scrollbars, + and the wheel over the console never reached the card. */ @media (min-width:561px){ - .pane-card:has(> .tab-pane[data-pane="logs"]:not([hidden])){height:var(--pane-max,70vh)} + .pane-card{height:var(--pane-max,70vh)} .tab-pane[data-pane="logs"]{height:100%;min-height:0} .tab-pane[data-pane="logs"] .logs-wrap{flex:1;min-height:160px} /* "No events yet" fills the same room; its 420 px floor overflowed a @@ -2373,6 +2657,18 @@ /* First-run "New here?" pulse around the (?) button. Auto-dismisses on first interaction OR after they've explicitly seen the tour. */ +/* The round header buttons: compact mode and help. */ +.hdr-round{position:relative;width:30px;height:30px;border-radius:999px;border:1px solid var(--line);background:var(--surface-3); + color:var(--fg-3);font-weight:700;font-size:14px} +.hdr-round svg{width:15px;height:15px} +#hdr-compact{transition:color .2s var(--ease-out),border-color .2s var(--ease-out),background .2s var(--ease-out),transform .15s var(--ease-out)} +#hdr-compact:hover{color:var(--fg);border-color:var(--line-2)} +#hdr-compact:active{transform:scale(.92)} +#hdr-compact[aria-pressed="true"]{color:var(--accent);border-color:color-mix(in oklch,var(--accent) 45%,transparent); + background:color-mix(in oklch,var(--accent) 12%,var(--surface-3))} +/* Folding the panel up points the chevron up; folded, it points down. */ +.hdr-compact-chev{transform-box:fill-box;transform-origin:center;transition:rotate .35s var(--ease-out)} +#hdr-compact[aria-pressed="true"] .hdr-compact-chev{rotate:180deg} #hdr-help{transition:transform .15s var(--ease-spring),background .15s,color .15s} #hdr-help:hover{background:var(--accent);color:var(--accent-ink);border-color:transparent;transform:scale(1.06)} .help-hint{ @@ -2615,7 +2911,11 @@ .ig-card .dcard-name{font-size:13px} .ig-card .dcard-host{font-size:10.5px;font-family:inherit;font-variant-numeric:tabular-nums} .ig-flag{min-width:0;max-width:none} -.ig-card .ig-flag{position:absolute;right:6px;top:6px;backdrop-filter:blur(6px)} +/* No backdrop blur here: the badge's background is solid, so a blur behind + it shows nothing, and it made each card a blurred layer of its own for + the browser to redo on every frame. Twenty-odd integrations were enough + to make a browser with a weak or limited GPU path crawl on every tab. */ +.ig-card .ig-flag{position:absolute;right:6px;top:6px} .ig-flag.warn{color:var(--warn);border-color:color-mix(in oklch,var(--warn) 35%,var(--line)); background:color-mix(in oklch,var(--warn) 10%,var(--surface-3))} .ig-flag.warn .d-dot{background:var(--warn)} @@ -2857,7 +3157,7 @@ .ig-stage{position:relative;aspect-ratio:16/9;overflow:hidden;border-radius:12px;border:1px solid var(--line); background:radial-gradient(90% 120% at 30% 20%,#33415a 0%,#1a2130 50%,#0e1219 100%)} .ig-stage-short{aspect-ratio:auto;height:180px} -.ig-stage-frame,.ig-theme-frame{position:absolute;left:0;top:0;border:0;transform-origin:0 0;pointer-events:none;background:transparent;color-scheme:normal} +.ig-stage-frame{position:absolute;left:0;top:0;border:0;transform-origin:0 0;pointer-events:none;background:transparent;color-scheme:normal} /* Connections: one card each, opened in place. */ .ig-cxs{display:flex;flex-direction:column;gap:8px} .ig-cx{border:1px solid var(--line);border-radius:12px;background:var(--bg-1);overflow:hidden; @@ -2879,118 +3179,22 @@ .ig-look-sum{display:flex;align-items:center;flex-wrap:wrap;gap:8px 10px;padding:10px 12px;border-radius:10px;border:1px solid var(--line);font-size:13px} .ig-look-dot{width:14px;height:14px;flex:none;border-radius:50%;background:var(--sw);box-shadow:0 0 0 3px color-mix(in oklch,var(--sw) 25%,transparent)} .ig-look-sum-t{flex:1;min-width:12em} -/* The theme designer: the stage stays in view while the controls scroll. */ -.ig-look-body{display:grid;grid-template-columns:minmax(0,1.1fr) minmax(0,1fr);gap:20px;align-items:start} -.ig-look-stagecol{position:sticky;top:0;display:flex;flex-direction:column;gap:10px} -.ig-look-acts{display:flex;flex-wrap:wrap;gap:8px} -.ig-look-controls{display:flex;flex-direction:column;gap:18px;min-width:0} -.ig-look-sec{display:flex;flex-direction:column;gap:10px} -.ig-look-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:10px} -.ig-look-grid .ig-seg,.ig-look-sec>.dff .ig-seg{width:100%} -.ig-look-grid .sub-tab,.ig-look-sec>.dff .sub-tab{flex:1} -.ig-look-css{min-height:120px} -@media (max-width:860px){.ig-look-body{grid-template-columns:1fr}.ig-look-stagecol{position:static}} -.ig-themes{display:grid;grid-template-columns:repeat(auto-fill,minmax(132px,1fr));gap:8px} -.ig-theme{position:relative;display:flex;flex-direction:column;gap:2px;padding:6px 6px 8px;border-radius:12px;border:1px solid var(--line); - background:var(--bg-1);color:inherit;font:inherit;text-align:left;cursor:pointer; - transition:border-color .2s var(--ease-out),transform .25s var(--ease-spring),box-shadow .25s var(--ease-out)} -.ig-theme:hover{border-color:var(--line-2);transform:translateY(-2px);box-shadow:0 10px 22px -14px rgba(0,0,0,.7)} -.ig-theme:active{transform:scale(.97);transition-duration:.08s} -.ig-theme.on{border-color:var(--accent);box-shadow:0 0 0 2px color-mix(in oklch,var(--accent) 30%,transparent)} -.ig-theme::after{content:"";position:absolute;top:12px;right:12px;width:18px;height:18px;border-radius:50%; - background:var(--accent) url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%230b0d12' stroke-width='3.4' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M20 6L9 17l-5-5'/%3E%3C/svg%3E") center/11px no-repeat; - box-shadow:0 2px 8px rgba(0,0,0,.45);opacity:0;transform:scale(.4);transition:opacity .15s var(--ease-out),transform .3s var(--ease-spring)} -.ig-theme.on::after{opacity:1;transform:none} -.ig-theme-stage{position:relative;display:block;aspect-ratio:16/9;overflow:hidden;border-radius:8px;margin-bottom:4px; - background:radial-gradient(90% 140% at 30% 10%,#33415a 0%,#1a2130 55%,#0e1219 100%)} -.ig-theme-name{font-size:13px;font-weight:600;padding:0 2px} -.ig-theme-sub{font-size:11px;color:var(--fg-3);padding:0 2px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} -.ig-range{display:flex;align-items:center;gap:10px} -.ig-range input{flex:1;min-width:0} -.ig-range .mono{min-width:3.4em;text-align:right;font-size:12px;color:var(--fg-3)} -:is(.ig-modal input[type=range],.crash-range){-webkit-appearance:none;appearance:none;height:22px;margin:0;background:transparent;cursor:pointer;--p:50%} -:is(.ig-modal input[type=range],.crash-range)::-webkit-slider-runnable-track{height:6px;border-radius:99px; +:is(.ig-modal input[type=range],.crash-range,.st-modal input[type=range]){-webkit-appearance:none;appearance:none;height:22px;margin:0;background:transparent;cursor:pointer;--p:50%} +:is(.ig-modal input[type=range],.crash-range,.st-modal input[type=range])::-webkit-slider-runnable-track{height:6px;border-radius:99px; background:linear-gradient(90deg,var(--accent) 0 var(--p),var(--surface-3) var(--p) 100%)} -:is(.ig-modal input[type=range],.crash-range)::-moz-range-track{height:6px;border-radius:99px;background:var(--surface-3)} -:is(.ig-modal input[type=range],.crash-range)::-moz-range-progress{height:6px;border-radius:99px;background:var(--accent)} -:is(.ig-modal input[type=range],.crash-range)::-webkit-slider-thumb{-webkit-appearance:none;width:16px;height:16px;margin-top:-5px;border-radius:50%; +:is(.ig-modal input[type=range],.crash-range,.st-modal input[type=range])::-moz-range-track{height:6px;border-radius:99px;background:var(--surface-3)} +:is(.ig-modal input[type=range],.crash-range,.st-modal input[type=range])::-moz-range-progress{height:6px;border-radius:99px;background:var(--accent)} +:is(.ig-modal input[type=range],.crash-range,.st-modal input[type=range])::-webkit-slider-thumb{-webkit-appearance:none;width:16px;height:16px;margin-top:-5px;border-radius:50%; background:#fff;box-shadow:0 0 0 1px rgba(0,0,0,.25),0 2px 6px rgba(0,0,0,.45); transition:transform .2s var(--ease-spring),box-shadow .2s var(--ease-out)} -:is(.ig-modal input[type=range],.crash-range)::-moz-range-thumb{width:16px;height:16px;border:0;border-radius:50%;background:#fff; +:is(.ig-modal input[type=range],.crash-range,.st-modal input[type=range])::-moz-range-thumb{width:16px;height:16px;border:0;border-radius:50%;background:#fff; box-shadow:0 0 0 1px rgba(0,0,0,.25),0 2px 6px rgba(0,0,0,.45);transition:transform .2s var(--ease-spring),box-shadow .2s var(--ease-out)} -:is(.ig-modal input[type=range],.crash-range):hover::-webkit-slider-thumb{transform:scale(1.12)} -:is(.ig-modal input[type=range],.crash-range):active::-webkit-slider-thumb{transform:scale(1.25);box-shadow:0 0 0 6px color-mix(in oklch,var(--accent) 25%,transparent),0 2px 6px rgba(0,0,0,.45)} -:is(.ig-modal input[type=range],.crash-range):hover::-moz-range-thumb{transform:scale(1.12)} -:is(.ig-modal input[type=range],.crash-range):active::-moz-range-thumb{transform:scale(1.25);box-shadow:0 0 0 6px color-mix(in oklch,var(--accent) 25%,transparent),0 2px 6px rgba(0,0,0,.45)} -:is(.ig-modal input[type=range],.crash-range):focus-visible{outline:0} -:is(.ig-modal input[type=range],.crash-range):focus-visible::-webkit-slider-thumb{box-shadow:0 0 0 3px color-mix(in oklch,var(--accent) 55%,transparent)} -/* Colors: a round swatch that opens the picker, its value, and a way back. */ -.ig-colors{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:8px} -.ig-colorf{display:flex;align-items:center;gap:10px;min-width:0;padding:7px 8px 7px 7px;border-radius:12px;border:1px solid var(--line); - background:var(--bg-1);transition:border-color .2s var(--ease-out),background-color .2s var(--ease-out)} -.ig-colorf:hover{border-color:var(--line-2)} -.ig-colorf.set{border-color:color-mix(in oklch,var(--sw) 45%,var(--line))} -.ig-colorf .ig-swatch{position:relative;flex:none;width:30px;height:30px;margin:0;border:0;border-radius:50%;cursor:pointer;background:var(--sw); - box-shadow:inset 0 0 0 1px rgba(255,255,255,.18),0 0 0 3px color-mix(in oklch,var(--sw) 22%,transparent); - transition:transform .25s var(--ease-spring),background-color .2s var(--ease-out),box-shadow .2s var(--ease-out)} -.ig-colorf .ig-swatch:hover{transform:scale(1.08)} -.ig-colorf .ig-swatch:active{transform:scale(.94)} -.ig-colorf .ig-swatch:focus-within{box-shadow:inset 0 0 0 1px rgba(255,255,255,.18),0 0 0 3px color-mix(in oklch,var(--accent) 60%,transparent)} -.ig-colorf .ig-swatch input{position:absolute;inset:0;width:100%;height:100%;opacity:0;cursor:pointer;border:0;padding:0} -.ig-colorf-t{flex:1;min-width:0;display:flex;flex-direction:column;line-height:1.25} -.ig-colorf-t b{font-size:12.5px;font-weight:600;color:var(--fg)} -.ig-colorf-t .mono{font-size:11px;color:var(--fg-3);text-transform:lowercase} -.ig-colorf .ig-reset{display:grid;place-items:center;width:24px;height:24px;padding:0;animation:igPopIn .3s var(--ease-spring)} -.ig-colorf .ig-reset svg{width:12px;height:12px} -@keyframes igPopIn{from{opacity:0;transform:scale(.5)}} -/* Where alerts sit: a little screen with nine spots; the card slides. */ -.ig-place-row{display:flex;align-items:center;gap:16px;flex-wrap:wrap} -.ig-place{position:relative;flex:none;width:208px;aspect-ratio:16/9;display:grid;grid-template:repeat(3,1fr)/repeat(3,1fr); - border-radius:10px;border:1px solid var(--line);overflow:hidden; - background:radial-gradient(90% 120% at 30% 20%,#33415a 0%,#1a2130 50%,#0e1219 100%)} -.ig-spot{position:relative;z-index:1;display:grid;place-items:center;padding:0;border:0;background:none;cursor:pointer} -.ig-spot::before{content:"";width:5px;height:5px;border-radius:50%;background:rgba(255,255,255,.28); - transition:transform .25s var(--ease-spring),background-color .2s var(--ease-out),opacity .2s var(--ease-out)} -.ig-spot:hover::before{transform:scale(1.9);background:rgba(255,255,255,.75)} -.ig-spot.on::before{opacity:0} -.ig-spot:focus-visible{outline:2px solid var(--accent);outline-offset:-4px;border-radius:6px} -.ig-place-card{--ax:.5;--ay:1;position:absolute;left:calc(7% + 86%*var(--ax));top:calc(9% + 82%*var(--ay)); - translate:calc(-100%*var(--ax)) calc(-100%*var(--ay));width:36%;height:22%;display:flex;flex-direction:column;justify-content:center;gap:3px; - padding:0 8px;box-sizing:border-box;border-radius:5px;background:rgba(10,12,16,.85);pointer-events:none; - box-shadow:inset 3px 0 0 var(--sw),0 4px 12px rgba(0,0,0,.5); - transition:left .45s var(--ease-spring),top .45s var(--ease-spring),translate .45s var(--ease-spring)} -.ig-place-card i{display:block;height:3px;border-radius:2px;background:rgba(255,255,255,.75)} -.ig-place-card i:first-child{width:45%;background:var(--sw)} -.ig-place-card i:last-child{width:80%} -.ig-place[data-x=left] .ig-place-card{--ax:0}.ig-place[data-x=right] .ig-place-card{--ax:1} -.ig-place[data-y=top] .ig-place-card{--ay:0}.ig-place[data-y=middle] .ig-place-card{--ay:.5} -.ig-place-side{flex:1;min-width:180px;display:flex;flex-direction:column;gap:10px} -.ig-place-now{display:flex;align-items:baseline;gap:6px;font-size:13px} -.ig-place-now b{font-weight:600} -/* Fonts: each tile written in its own font. */ -.ig-fonts{display:grid;grid-template-columns:repeat(auto-fill,minmax(84px,1fr));gap:6px} -.ig-font{display:flex;flex-direction:column;align-items:center;gap:3px;min-width:0;padding:8px 4px 7px;border-radius:10px;border:1px solid var(--line); - background:var(--bg-1);color:var(--fg-2);font:inherit;cursor:pointer; - transition:border-color .2s var(--ease-out),color .2s var(--ease-out),transform .25s var(--ease-spring),background-color .2s var(--ease-out)} -.ig-font:hover{border-color:var(--line-2);color:var(--fg);transform:translateY(-1px)} -.ig-font:active{transform:scale(.95);transition-duration:.08s} -.ig-font.on{color:var(--fg);border-color:var(--accent);background:color-mix(in oklch,var(--accent) 10%,var(--bg-1))} -.ig-font-aa{font-size:22px;line-height:1.1;font-weight:600;color:var(--fg);transition:transform .3s var(--ease-spring)} -.ig-font:hover .ig-font-aa{transform:scale(1.1)} -.ig-font-n{max-width:100%;font-size:10.5px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} -/* The save line: a dot that breathes while saving, then settles. */ -.ig-saved{display:flex;align-items:center;gap:8px} -.ig-saved-dot{width:7px;height:7px;flex:none;border-radius:50%;background:var(--live,#3fcf8e); - box-shadow:0 0 0 3px color-mix(in oklch,var(--live,#3fcf8e) 22%,transparent);transition:background-color .2s var(--ease-out)} -.ig-saved.busy .ig-saved-dot{background:var(--accent);animation:igBreathe .8s ease-in-out infinite alternate} -@keyframes igBreathe{from{transform:scale(.7);opacity:.6}to{transform:scale(1.15);opacity:1}} -/* The designer's sections come in one after another. */ -.ig-look-controls>*{animation:igFadeUp .35s var(--ease-out) both} -.ig-look-controls>:nth-child(2){animation-delay:.04s}.ig-look-controls>:nth-child(3){animation-delay:.08s} -.ig-look-controls>:nth-child(4){animation-delay:.12s}.ig-look-controls>:nth-child(5){animation-delay:.16s} -.ig-look-controls>:nth-child(n+6){animation-delay:.2s} -.ig-reset{padding:0 6px;border-radius:999px;border:1px solid var(--line);background:transparent;color:var(--fg-3);font:inherit;font-size:11px;cursor:pointer} -.ig-reset:hover{color:var(--fg);border-color:var(--line-2)} +:is(.ig-modal input[type=range],.crash-range,.st-modal input[type=range]):hover::-webkit-slider-thumb{transform:scale(1.12)} +:is(.ig-modal input[type=range],.crash-range,.st-modal input[type=range]):active::-webkit-slider-thumb{transform:scale(1.25);box-shadow:0 0 0 6px color-mix(in oklch,var(--accent) 25%,transparent),0 2px 6px rgba(0,0,0,.45)} +:is(.ig-modal input[type=range],.crash-range,.st-modal input[type=range]):hover::-moz-range-thumb{transform:scale(1.12)} +:is(.ig-modal input[type=range],.crash-range,.st-modal input[type=range]):active::-moz-range-thumb{transform:scale(1.25);box-shadow:0 0 0 6px color-mix(in oklch,var(--accent) 25%,transparent),0 2px 6px rgba(0,0,0,.45)} +:is(.ig-modal input[type=range],.crash-range,.st-modal input[type=range]):focus-visible{outline:0} +:is(.ig-modal input[type=range],.crash-range,.st-modal input[type=range]):focus-visible::-webkit-slider-thumb{box-shadow:0 0 0 3px color-mix(in oklch,var(--accent) 55%,transparent)} .ig-sounds,.ig-pics{display:flex;flex-wrap:wrap;gap:6px} .ig-sound{display:inline-flex;align-items:center;gap:5px;padding:5px 11px;border-radius:999px;border:1px solid var(--line); background:transparent;color:var(--fg-2);font:inherit;font-size:12.5px;cursor:pointer; @@ -3015,6 +3219,16 @@ .ig-pic svg{width:16px;height:16px} .ig-pic.on{border-color:var(--accent);box-shadow:0 0 0 2px color-mix(in oklch,var(--accent) 30%,transparent)} .ig-volume{display:flex;align-items:center;flex-wrap:wrap;gap:10px;font-size:12.5px} +.ig-tiers{display:flex;flex-direction:column;gap:6px} +.ig-step-meta{display:flex;align-items:center;gap:6px;margin-top:3px;font-size:11.5px;color:var(--fg-3)} +.ig-step-meta i{width:9px;height:9px;border-radius:50%;background:var(--sw);box-shadow:0 0 0 1px rgba(255,255,255,.18)} +.ig-bld-alert .ig-live-row{justify-content:space-between} +.ig-tier{display:flex;align-items:center;gap:10px;min-height:40px;padding:4px 6px 4px 12px;border-radius:10px; + background:var(--surface-2);box-shadow:inset 0 0 0 1px var(--line)} +.ig-tier.on{box-shadow:inset 0 0 0 1px var(--accent)} +.ig-tier-what{display:flex;align-items:center;gap:6px;flex:1;min-width:0;font-size:13px} +.ig-tier-from{width:84px;padding:4px 8px;font-variant-numeric:tabular-nums} +.ig-tier-look{font-size:12px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:30%} .ig-volume input[type=range]{flex:1;min-width:120px} .ig-volume .mono{min-width:3.2em;color:var(--fg-3)} .ig-recipe-alerts{display:flex;flex-direction:column;gap:10px} @@ -3649,6 +3863,39 @@ .ig-menu button.danger svg{color:var(--danger)} .ig-menu button.danger:hover,.ig-menu button.danger:focus-visible,.ig-menu button.ig-armed{background:color-mix(in oklch,var(--danger) 14%,transparent)} .ig-menu-sep{height:1px;margin:4px 6px;background:var(--line)} +.ig-menu-label{padding:6px 10px 4px;font-size:10.5px;font-weight:700;letter-spacing:.08em;text-transform:uppercase;color:var(--fg-4)} +.ig-menu [aria-current="true"]{color:var(--accent)} +.ig-menu [aria-current="true"] svg{color:var(--accent)} +.ig-menu-new{margin:2px 4px 4px;width:auto;font-size:13px;padding:7px 10px} +/* Folders on the Integrations tab: a heading over each group. */ +.ig-folder{display:flex;align-items:center;gap:6px;margin:14px 0 8px;min-height:40px} +.ig-folder:first-child{margin-top:2px} +.ig-folder-head{display:flex;align-items:center;gap:8px;min-height:40px;padding:0 10px 0 4px;border:0;border-radius:9px;background:transparent; + color:var(--fg);font:inherit;font-size:13.5px;cursor:pointer;transition:background-color .15s var(--ease-out)} +.ig-folder-head:hover,.ig-folder-head:focus-visible{background:var(--surface-2);outline:none} +.ig-folder-head .muted{font-size:12px} +.ig-folder-chev{width:14px;height:14px;color:var(--fg-3);transition:transform .2s var(--ease-out)} +.ig-folder.folded .ig-folder-chev{transform:rotate(-90deg)} +.ig-folder-ic{width:15px;height:15px;color:var(--fg-3)} +.ig-folder .ic-btn-tiny{opacity:0;transition:opacity .15s var(--ease-out)} +.ig-folder:hover .ic-btn-tiny,.ig-folder .ic-btn-tiny:focus-visible{opacity:1} +.ig-folder-in{max-width:280px;font-size:13.5px;padding:7px 10px} +@media (hover:none){.ig-folder .ic-btn-tiny{opacity:1}} +/* Dragging a card to a folder: where it can go lights up. */ +.ig-items [draggable="true"]{-webkit-user-drag:element} +.ig-dragged{opacity:.4;transition:opacity .15s var(--ease-out)} +.ig-drop-new{display:flex;align-items:center;justify-content:center;gap:8px;height:0;margin-bottom:0;overflow:hidden;opacity:0; + border:0 dashed var(--line-2);border-radius:12px;color:var(--fg-3);font-size:13px; + transition:height .22s var(--ease-out),margin-bottom .22s var(--ease-out),opacity .18s var(--ease-out),border-width .22s var(--ease-out)} +.ig-drop-new svg{width:16px;height:16px} +.ig-dragging .ig-drop-new{height:56px;margin-bottom:10px;opacity:1;border-width:1.5px} +.ig-folder-empty{display:flex;align-items:center;justify-content:center;min-height:64px;border:1.5px dashed var(--line);border-radius:12px; + color:var(--fg-4);font-size:12.5px} +.ig-dragging [data-drop-folder]{transition:background-color .15s var(--ease-out),box-shadow .15s var(--ease-out)} +[data-drop-folder].drop-on{border-radius:12px;background:color-mix(in oklch,var(--accent) 8%,transparent); + box-shadow:inset 0 0 0 1.5px color-mix(in oklch,var(--accent) 70%,transparent);color:var(--fg)} +.ig-new-folder{white-space:nowrap} +@media (max-width:560px){.ig-new-folder span{display:none}.ig-new-folder .ig-btn-ic{margin:0}} .ic-toast.ig-undo{pointer-events:auto;align-items:center} .ig-undo-btn{margin-left:10px;padding:4px 11px;border-radius:7px;border:1px solid color-mix(in oklch,var(--accent) 45%,transparent); background:color-mix(in oklch,var(--accent) 12%,transparent);color:var(--accent);font:inherit;font-size:12.5px;font-weight:600;cursor:pointer; @@ -4026,10 +4273,8 @@ .ig-pick-row:active .ig-pick-mark{transform:scale(.85);transition-duration:.08s} @keyframes igTick{0%{transform:scale(.7)}60%{transform:scale(1.15)}100%{transform:none}} @media (prefers-reduced-motion: reduce){ - .ig-look-controls>*,.ig-colorf .ig-reset,.ig-saved.busy .ig-saved-dot,.ig-sound.ig-playing .ig-btn-ic, - .ig-pick-row.on .ig-pick-mark.box{animation:none} - .ig-place-card,.ig-theme,.ig-font,.ig-swatch,.ig-pick-mark svg path{transition:none} - .ig-theme:hover,.ig-font:hover{transform:none} + .ig-sound.ig-playing .ig-btn-ic,.ig-pick-row.on .ig-pick-mark.box{animation:none} + .ig-swatch,.ig-pick-mark svg path{transition:none} } .ig-pick-none{padding:10px 8px;font-size:12px;color:var(--fg-4)} /* The Smart light step: the device picker, a bulb in the step's color, @@ -4108,7 +4353,11 @@ - +
@@ -4369,9 +4618,9 @@

Welcome to InstantClone

-
- - +
+ + +
+ + +
+ + -
- - +
-
-
-
- Preview - -
-
-
-
-
- - - - - - - - -
-
- select an overlay - + + +
+
+ +
+
+
Delay overlays
+
+
+ +
+
+ Preview + +
+ + + + + + +
+
+
+
+
+
+
+
+ + +
+ +
+ Overlays folder +
Drop your own .html files here and they appear on this page.
+
+ + +
-
Paste this into OBS as a Browser Source, sized 1920 × 1080 (Width 1920, Height 1080) so it lines up with your canvas.
-
-
- -
-
Choose overlay
-
-
+ +
To control the delay from inside OBS, the control dock is on the OBS tab.
+
Which design each alert plays is picked in Integrations: open the alert, then its Design.
+
+ -
-
Want to control the delay from inside OBS? The control dock is on the OBS tab.
-
Overlays folder
-
Drop your own .html files here and they appear in the picker above.
-
- - - -
-
+ + + '; h += '
'; h += ''; p.innerHTML = h; + const body = p.querySelector('.st-props-body'); + p.querySelectorAll('input[type=range]').forEach(fillStRange); + wireGroups(p); + if(same && body){ body.classList.add('st-quiet'); body.scrollTop = scrolled; } } // ---- property setters ---- @@ -10864,15 +11929,33 @@

New destination

// so it's clear the preview reflects the change (not the previous cycle). let syncedT=0; function flashSynced(){ const b=$('st-synced'); if(!b) return; b.classList.add('on'); clearTimeout(syncedT); syncedT=setTimeout(function(){ b.classList.remove('on'); }, 900); } - function ensureState(){ const w=widget(sel); if(!w.states) w.states={}; if(!w.states[editState]) w.states[editState]={}; return w.states[editState]; } - function set(k,v){ const w=widget(sel); w[k]=v; if(k==='unit'||k==='icon'||k==='shape'||k==='src'){ rebake(); } else softRebake(); } + // Where the Look controls write: the widget's own look when it is the same + // in every state, else the edited state's. + function ensureState(){ const w=widget(sel); if(w.always) return w.base; if(!w.states) w.states={}; if(!w.states[editState]) w.states[editState]={}; return w.states[editState]; } + // One look for every state: the edited state's becomes the widget's own, + // and what the states held for themselves goes (only showing stays). + function setAlways(on){ const w=widget(sel); if(!w) return; + if(on) IC().oneLook(w, editState); else w.always=false; + rebake(); renderProps(); } + function set(k,v){ const w=widget(sel); w[k]=v; if(k==='unit'||k==='icon'||k==='shape'||k==='src'||k==='font_family'){ rebake(); } else softRebake(); } + function refresh(){ renderProps(); } function setNum(path,v){ const w=widget(sel); const n=parseFloat(v)||0; if(path.indexOf('.')>=0){const[a,b]=path.split('.'); w[a][b]=n;} else w[path]=n; softRebake(); } // LiquidFill: switch between buffer-following and a fixed level (number). function setLiquidLevel(v){ const w=widget(sel); w.level = (v==='buffer') ? 'buffer' : (typeof w.level==='number'?w.level:60); softRebake(); renderProps(); } // Boolean widget flag that changes structure (e.g. BufferRing centre text) - // needs a full rebake since liveStyle only diffs attributes. function setFlag(k,on){ const w=widget(sel); if(on) w[k]=true; else delete w[k]; rebake(); } + // What a Text layer does with words longer than its width: run past it, + // shrink to fit, or wrap. + function setLong(v){ + const w=widget(sel); + delete w.fit; delete w.wrap; + if(v==='fit' || v==='wrap') w[v]=true; + rebake(); refresh(); + } function setBase(k,v){ widget(sel).base[k]=v; if(k==='text'&&left==='layers') renderLeft(); softRebake(); } + // A layer's own name, for the layer list (the page never shows it). + function setName(v){ const w=widget(sel); v=v.trim(); if(v) w.name=v; else delete w.name; if(left==='layers') renderLeft(); recordSoon(); } // CustomHTML lives inside a sandboxed iframe (the widget's inner content), // which the in-place live update can't touch - so debounce a full rebake. let fullT=0; function softFull(){ clearTimeout(fullT); fullT=setTimeout(rebake,300); } @@ -10903,7 +11986,7 @@

New destination

// Per-state gradient (a second colour). function setGrad(v){ const sp=ensureState(); if(!v) delete sp.grad; else sp.grad=v; softRebake(); } function toggleGrad(on){ const sp=ensureState(); if(on){ sp.grad=sp.color||widget(sel).base.color||'#54e38e'; } else { delete sp.grad; delete sp.grad_anim; } softRebake(); renderProps(); } - function setGradAnim(on){ const sp=ensureState(); if(on) sp.grad_anim=true; else delete sp.grad_anim; softRebake(); } + function setGradAnim(motion){ const sp=ensureState(); if(motion) sp.grad_anim=motion; else delete sp.grad_anim; softRebake(); renderProps(); } // Alignment helpers - set anchor + offset to a known spot. function align(where){ const w=widget(sel); if(!w) return; const S=64; const map={ 'center':['center',0,0], 'top':['top',0,S], 'bottom':['bottom',0,S], @@ -10955,7 +12038,7 @@

New destination

['color','opacity','text','anim','hide_after_ms','offset'].forEach(k=>{ if(src[k]!==undefined) look[k]=clone(src[k]); }); IC().STATES.forEach(s=>{ if(s.key!==editState){ if(!w.states[s.key]) w.states[s.key]={}; Object.assign(w.states[s.key], clone(look)); } }); softRebake(); renderProps(); renderLeft(); toast('Look copied to all states','ok'); } - function dupWidget(){ const w=clone(widget(sel)); w.id=nextId(); w.offset={x:w.offset.x+20,y:w.offset.y+20}; doc.widgets.push(w); sel=w.id; renderLeft(); rebake(); renderProps(); } + function dupWidget(){ const w=clone(widget(sel)); w.id=nextId(); w.offset={x:w.offset.x+20,y:w.offset.y+20}; doc.widgets.push(w); sel=w.id; arrived=w.id; renderLeft(); rebake(); renderProps(); } function delWidget(){ delWidgetById(sel); } function delWidgetById(id){ doc.widgets=doc.widgets.filter(w=>w.id!==id); if(sel===id) sel=null; renderLeft(); rebake(); renderProps(); } function moveWidget(id,dir){ const i=doc.widgets.findIndex(w=>w.id===id); if(i<0) return; const j=i+dir; if(j<0||j>=doc.widgets.length) return; const t=doc.widgets[i]; doc.widgets[i]=doc.widgets[j]; doc.widgets[j]=t; renderLeft(); rebake(); } @@ -10987,28 +12070,42 @@

New destination

} function copyUrl(btn){ if(!slug){ toast('Save the overlay first to get a URL','info'); return; } copyText(btn, location.origin+'/overlay/'+slug+'.html','Overlay URL'); } // ---- overflow menu: save-as / rename / export / import / delete ---- - function toggleMenu(ev){ ev.stopPropagation(); const p=$('st-menu-pop'); if(p) p.hidden=!p.hidden; } + function toggleMenu(ev){ + ev.stopPropagation(); const p=$('st-menu-pop'); if(!p) return; p.hidden=!p.hidden; + const purpose=IC().docPurpose(doc), mv=$('st-menu-move'); + mv.hidden = purpose==='goals' || purpose==='alerts'; + mv.textContent = purpose==='custom' ? 'Move to Delay' : 'Move to Custom'; + } // Save a separate copy under a new slug (so it never overwrites the current // overlay). The user can rename afterwards. // A copy that fails to save leaves the original as it was: on its own // file and under its own name (it used to forget its file, so the next // Save made a second overlay instead of updating it). async function saveAsNew(){ - const was = slug, name = doc.name; + const was = slug, name = doc.name, purpose = doc.purpose; slug=null; doc.name=(doc.name||'Overlay')+' copy'; $('st-name').value=doc.name; + if(IC().docPurpose(doc)==='goals') doc.purpose='custom'; if(await save()) return true; - slug=was; doc.name=name; $('st-name').value=name; + slug=was; doc.name=name; $('st-name').value=name; doc.purpose=purpose; return false; } function menu(action){ const p=$('st-menu-pop'); if(p) p.hidden=true; if(action==='delete') return deleteOverlay(); if(action==='rename') return renameOverlay(); + if(action==='move'){ + doc.purpose = IC().docPurpose(doc)==='custom' ? 'delay' : 'custom'; updateDirty(); + // What the Studio offers follows: the delay's states, or none. + enterMode(); renderLeft(); renderProps(); studioDrive(); + toast('It moves to the '+(doc.purpose==='custom'?'Custom':'Delay')+' page when you save','info'); + return; + } if(action==='export') return exportDoc(); if(action==='import') return importDoc(); } async function renameOverlay(){ const nn=(prompt('Rename overlay', doc.name)||'').trim(); if(!nn) return; - const old=slug; doc.name=nn; $('st-name').value=nn; slug=freeSlug(nn, old); + // A goal's or a timer's overlay keeps its file, which its card finds it by. + const old=slug; doc.name=nn; $('st-name').value=nn; slug=IC().docPurpose(doc)==='goals' ? old : freeSlug(nn, old); // A save that failed: the overlay keeps its old file (it used to be // deleted here, losing the overlay with the rename). if(!(await save())){ slug=old; return; } @@ -11021,7 +12118,7 @@

New destination

function importDoc(){ const inp=document.createElement('input'); inp.type='file'; inp.accept='.json,application/json'; inp.onchange=()=>{ const f=inp.files&&inp.files[0]; if(!f) return; const rd=new FileReader(); rd.onload=()=>{ try{ const d=JSON.parse(rd.result); if(!d||!Array.isArray(d.widgets)) throw 0; - doc=d; slug=null; sel=null; doc.widgets.forEach((w,n)=>{ if(!w.id) w.id='w'+(n+1); }); + doc=IC().upgradeDoc(d); slug=null; sel=null; doc.widgets.forEach((w,n)=>{ if(!w.id) w.id='w'+(n+1); }); $('st-name').value=doc.name||'Imported overlay'; renderStates(); renderLeft(); rebake(); renderProps(); resetHistory(); toast('Imported. Save to keep it','ok'); @@ -11037,6 +12134,51 @@

New destination

} async function loadSaved(){ try{ savedList = await (await fetch('/overlays')).json(); if(!Array.isArray(savedList)) savedList=[]; }catch(e){ savedList=[]; } + return savedList; + } + // Overlays an older Studio baked (another build, or none written down) + // are baked again from their own doc, so a fix to how layers draw reaches + // them without anyone saving. Never while OBS is live: a saved overlay + // reloads in OBS. Run once per visit; a failure tries again next visit. + // Off air: OBS isn't sending, nothing goes out, and no hold or tail is + // keeping a stream up (an overlay rebuilt then reloads in OBS mid-show). + function offAir(s){ return !!s && !s.ingest_alive && !s.egress_alive && !s.hold && !(s.tail_ms > 0); } + // Each visit tries once: going live stops it, to finish the next time + // the stream is off; a failure waits for the next visit. + let upgrading=false, upgraded=false; + async function upgradeSaved(){ + if(upgrading || upgraded || !IC() || !offAir(lastState)) return; + upgrading=true; + let wrote=false, wentLive=false; + try{ + const old=(await loadSaved()).filter(o=>o.studio && o.build!==IC().BUILD); + for(const o of old){ + if(!offAir(lastState)){ wentLive=true; break; } + const html=await fetch('/overlay/'+encodeURIComponent(o.slug)+'.html').then(r=>r.ok?r.text():'', ()=>''); + const doc=IC().readDoc(html); + if(!doc) continue; + const r=await fetch('/overlays/'+encodeURIComponent(o.slug),{method:'POST',headers:{'Content-Type':'text/html'}, + body:IC().bake(o.build ? doc : layeredCard(o.slug, doc))}).then(x=>x.json(),()=>null); + if(!r || !r.ok) break; + wrote=true; + } + upgraded=!wentLive; + }catch(e){ upgraded=true; console.error('saved overlays not updated:', e); } + finally{ upgrading=false; } + if(wrote){ await loadSaved(); if($('ovg-browser').offsetParent) renderGallery(true); } + } + // An alert design InstantClone made as one Alert card, before designs + // were layers ("My alerts", the ready-made ones then): the same look, in + // layers. One that layers can't draw (custom CSS for the card, the + // picture on top) stays as it is. + function layeredCard(slugId, doc){ + const w=doc.widgets || []; + if(!/^(alert-[a-z]+|my-alerts(-\d+)?)$/.test(slugId) || w.length!==1 || w[0].kind!=='AlertCard') return doc; + const layered=IC().alertLookDoc(Object.assign({theme:readyTheme(slugId) || 'island'}, w[0].card || {})); + if(!layered) return doc; + layered.name=doc.name; + if(doc.sound) layered.sound=doc.sound; + return layered; } // First-run seeding: bake the built-in presets to real overlay files so the // gallery is one uniform list (every item has a working URL). Gated by the @@ -11048,20 +12190,16 @@

New destination

try{ await loadSaved(); for(let i=0;io.slug===slug)) continue; - const d=clone(p); d.widgets.forEach((w,n)=>{ w.id='w'+(n+1); }); - await fetch('/overlays/'+encodeURIComponent(slug),{method:'POST',headers:{'Content-Type':'text/html'},body:IC().bake(d)}); + const p=IC().PRESETS[i]; + if(savedList.some(o=>o.slug===slugify(p.name))) continue; + await savePreset(p); } await fetch('/overlays/seeded',{method:'POST'}); await loadSaved(); renderGallery(); }catch(e){}finally{ seeding=false; } } - window.addEventListener('resize', ()=>{ - if($('ov-studio') && !$('ov-studio').hidden) fitScale(); - else if($('ov-gallery')) fitPreview(); - }); + new ResizeObserver(fitPreview).observe($('ovg-stage')); document.addEventListener('keydown', (e)=>{ const studioOpen = $('ov-studio') && !$('ov-studio').hidden; if(e.key==='Escape' && studioOpen){ backToGallery(); return; } @@ -11088,12 +12226,13 @@

New destination

document.addEventListener('click', ()=>{ const p=$('st-menu-pop'); if(p && !p.hidden) p.hidden=true; }); return { show, openStudio, newOverlay, duplicateOverlay, backToGallery, previewSim, deleteFromGallery, - seedPresets, restoreDefaults, toggleAutohide, copyGallery, undo, redo, toggleMenu, menu, + seedPresets, upgradeSaved, restoreDefaults, toggleAutohide, copyGallery, undo, redo, toggleMenu, menu, leftTab, selectState, play, save, saveAsNew, copyUrl, deleteOverlay, loadSaved, set, setNum, setBase, setCustom, setState, setStateNum, setStateText, setAnim, setAnimNum, setHideAfter, toggleHide, setLS, setStateOffset, resetStateOffset, toggleVis, visOnly, visAll, applyColorAll, applyAnimAll, copyLookAll, setTheme, setGrad, toggleGrad, setGradAnim, align, animAdd, animDel, animSet, uploadImage, insertVar, - dupWidget, delWidget, isDirty, setLiquidLevel, setFlag }; + dupWidget, delWidget, isDirty, setLiquidLevel, setFlag, refresh, setAlways, addGalleryToObs, + setCard, setCardNum, setCardColor, resetCard, setName, setLong, editDesign, duplicateDesign, useDesign, resetDesign, alertsChanged }; })(); // Existing init paths call loadOverlays() before the `const Studio` @@ -11242,7 +12381,7 @@

New destination

const bracket = message.match(/^\[([^\]]+)\]\s*/); const source = bracket ? bracket[1] : /^ingest:|^Enhanced Broadcasting/.test(message) ? 'OBS' - : /^pre-arm|delay/i.test(message) ? 'Delay' + : /^pre-arm/i.test(message) || /delay/i.test(message) ? 'Delay' : 'App'; // Something that failed but is being tried again is a warning. const level = LOG_ERROR.test(message) && !LOG_RETRY.test(message) ? 'err' @@ -11624,10 +12763,10 @@

New destination

}, { target: '.tab[data-tab="overlay"]', side: 'bottom', tab: 'overlay', - title: 'Overlay Studio', + title: 'Everything viewers see', body: ` -

Pick a built-in overlay from the gallery, preview it in any delay state (Idle / Arming / Ready / Active), and copy its URL straight into OBS as a Browser Source. Each overlay auto-dims when nothing's happening so it doesn't compete for attention.

-

Hit + New overlay to open Overlay Studio, a visual editor where you can design your own overlay from scratch, tweak colours, layout, and widgets, then save it back to the gallery. Hand-written .html files dropped into the overlays folder still appear here automatically.

+

Four pages, by what they show: Delay (telling viewers you're on a delay), Alerts (follows, subs, raids and more), Goals and timers (a sub goal, a subathon) and Custom (anything else, like a starting-soon screen).

+

Each one is a browser source: Add to OBS puts it in your scene in one click. Overlay Studio changes how any of them look, layer by layer, and previews it in every delay state.

`, }, { @@ -11645,8 +12784,12 @@

New destination

let tourResizeHandler = null; let tourScrollHandler = null; +let tourFoldedHero = false; function openTour(idx){ tourIdx = idx || 0; + // The tour points at the delay panel's parts: it shows them all, and + // folds it back up after if it was folded. + if (heroCompact()){ tourFoldedHero = true; setHeroCompact(false, false); } $('tour').hidden = false; $('hdr-help-hint').hidden = true; try { localStorage.setItem('instantclone_onboarded','1'); } catch(_){} @@ -11662,6 +12805,7 @@

New destination

function closeTour(){ $('tour').hidden = true; + if (tourFoldedHero){ tourFoldedHero = false; setHeroCompact(true, true); } if (tourResizeHandler){ window.removeEventListener('resize', tourResizeHandler); window.removeEventListener('scroll', tourScrollHandler, true); @@ -12217,9 +13361,12 @@

New destination

} if (name === 'obs') showObsTab(); if (window.Integrations){ if (name === 'integrations') Integrations.show(); else Integrations.hide(); } - if (name === 'overlay' && window.ICOverlay) Studio.show(); + if (name === 'overlay') overlaySubShown(overlaySub()); // The Studio modal lives on , so close it when leaving the tab. - else { const m = $('ov-studio'); if (m) m.hidden = true; } + else { + const m = $('ov-studio'); if (m) m.hidden = true; + if (window.Integrations) Integrations.stage(null); + } // Sub-tab indicators only have a real width after the pane unhides, // so reposition any sub-tab indicator inside the freshly shown pane. const pane = document.querySelector(`.tab-pane[data-pane="${name}"]`); @@ -12556,13 +13703,25 @@

New destination

}); })(); -// - Sub-tabs (currently only the System pane uses these) ------- +// - Sub-tabs (the System pane and the Overlay pane) ------- +// The Overlay tab's pages: the gallery is this file's (Studio), Alerts and +// Goals are drawn by integrations.js, which only reads their data while +// one of them shows. +function overlaySub(){ + const on = document.querySelector('.tab-pane[data-pane="overlay"] .sub-tab.on'); + return on ? on.dataset.subtab : 'delay'; +} +function overlaySubShown(sub){ + document.querySelectorAll('[data-ovact]').forEach(el => { el.hidden = el.dataset.ovact !== sub; }); + if ((sub === 'delay' || sub === 'custom' || sub === 'alerts') && window.ICOverlay) Studio.show(sub); + if (window.Integrations) Integrations.stage(sub); +} // Jump straight to System -> Access (used by the "set an ingest key" pointer // on the OBS tab; the key control lives under Access). function goNetworkSettings(){ showTab('system'); showSubTab('system', 'access'); } // Sub-tabs that were renamed or merged, so a remembered tab or an old link // still lands in the right place. -const SUBTAB_MOVED = {behavior:'general', buffer:'general', network:'access', notifications:'alerts', diagnostics:'alerts'}; +const SUBTAB_MOVED = {behavior:'general', buffer:'general', network:'access', notifications:'alerts', diagnostics:'alerts', overlays:'delay'}; function showSubTab(paneName, subName){ const pane = document.querySelector(`.tab-pane[data-pane="${paneName}"]`); @@ -12573,6 +13732,7 @@

New destination

moveSubTabInd(pane); staggerIn(pane.querySelector(`.sub-pane[data-subpane="${subName}"]`)); try { localStorage.setItem('ic-subtab-' + paneName, subName); } catch(e){} + if (paneName === 'overlay') overlaySubShown(subName); if (paneName !== 'system') return; updateSaveBar(); if (subName === 'about') aboutMaybeAutoCheck(); @@ -12650,6 +13810,7 @@

New destination

// the per-tab hooks in showSubTab wait for the first real switch. pane.querySelectorAll('.sub-tab').forEach(t => t.classList.toggle('on', t.dataset.subtab === saved)); pane.querySelectorAll('.sub-pane').forEach(p => p.hidden = p.dataset.subpane !== saved); + pane.querySelectorAll('[data-ovact]').forEach(el => { el.hidden = el.dataset.ovact !== saved; }); }); })(); @@ -13043,6 +14204,7 @@

New destination

toast('OBS reconnected, timeline realigned','info'); } lastState = s; + if (window.ICOverlay) Studio.upgradeSaved(); // The server compares the running buffer with the saved one, so the // notice is right after a reload too, and gone once a restart applied it. const banner = $('restart-banner'); diff --git a/web/integrations.js b/web/integrations.js index efc1ada..927f3f3 100644 --- a/web/integrations.js +++ b/web/integrations.js @@ -19,6 +19,11 @@ const store = { set(k, v){ try { localStorage.setItem(k, v); } catch(_){} }, }; +// Stored JSON, or `fallback` when there is none or it doesn't read. +function readJson(text, fallback){ + try { return text ? JSON.parse(text) : fallback; } catch(_){ return fallback; } +} + async function api(path, body){ try { const opts = body === undefined ? {} : { @@ -44,6 +49,7 @@ const PATHS = { steps:'M4 6h16M4 12h10M4 18h7', plus:'M12 5v14M5 12h14', check:'M20 6L9 17l-5-5', + folder:'M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z', search:'M11 19a8 8 0 1 0 0-16 8 8 0 0 0 0 16zM21 21l-4.3-4.3', rewind:'M3 12a9 9 0 1 0 3-6.7L3 8M3 3v5h5', chev:'M6 9l6 6 6-6', @@ -87,6 +93,8 @@ const PATHS = { gem:'M6 3h12l4 6-10 12L2 9zM2 9h20M12 21L8 9l4-6 4 6z', users:'M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2M9 11a4 4 0 1 0 0-8 4 4 0 0 0 0 8zM23 21v-2a4 4 0 0 0-3-3.9M16 3.1a4 4 0 0 1 0 7.8', tv:'M2 7h20v13H2zM17 2l-5 5-5-5', + target:'M12 22a10 10 0 1 0 0-20 10 10 0 0 0 0 20zM12 18a6 6 0 1 0 0-12 6 6 0 0 0 0 12zM12 14a2 2 0 1 0 0-4 2 2 0 0 0 0 4z', + pause:'M8 5v14M16 5v14', }; const svg = (name, extra) => ``; @@ -110,6 +118,7 @@ const KINDS = { counter: {label:'Counter', tag:'VALUE', c:'#93c5fd', icon:'steps', hint:'Counts up or down, and keeps the count between runs.'}, overlay: {label:'Show on stream', tag:'STREAM', c:'#5ac8fa', icon:'screen', text:'text', hint:'Shows a card on your stream, through the alerts browser source.'}, edit_text: {label:'Edit text', tag:'VALUE', c:'#93c5fd', icon:'type', hint:'Reshapes text: a line, a part, a rounded number, a random pick.'}, + dock: {label:'Dock event', tag:'DOCK', c:'#5ac8fa', icon:'list', text:'text', hint:"Lists it in your OBS dock's Events, to filter, and to replay the alert this run showed."}, timeline: {label:'Timeline', tag:'LOG', c:'#5ac8fa', icon:'list', text:'text', hint:'Adds a line, a chapter or a highlight to this stream\'s timeline, at its place in the VOD.'}, light: {label:'Smart light', tag:'LIGHT', c:'#fde68a', icon:'bulb', hint:'Turns a light or plug on your network on or off, or colors it. Found by a scan: no hub or account.'}, viewer: {label:'Viewer value', tag:'VALUE', c:'#fbbf24', icon:'gem', hint:'Gives, takes or spends what a viewer holds: coins, points. Kept between streams.'}, @@ -117,6 +126,8 @@ const KINDS = { wait_chat: {label:'Wait for chat', tag:'WAIT', c:'#c3cad1', icon:'bubble', hint:'Waits for someone to type something, up to a time limit.'}, call: {label:'Run a block', tag:'FLOW', c:'#f0a53a', icon:'layers', hint:'Runs a block of steps this integration keeps under a name.'}, reward: {label:'Channel points reward', tag:'TWITCH', c:'#a78bfa', icon:'gift', hint:'Switches a channel points reward on or off, so viewers can only redeem it when it makes sense.'}, + channel: {label:'Stream title and category', tag:'TWITCH', c:'#a78bfa', icon:'tv', hint:'Sets your stream\'s title, its category, or both, on Twitch.'}, + goal: {label:'Goal or timer', tag:'STREAM', c:'#86efac', icon:'target', hint:'Adds to a goal, or starts, pauses or adds time to a timer.'}, obs: {label:'OBS', tag:'OBS', c:'#86efac', icon:'layers', hint:'Switches a scene, shows or hides a source, records, saves a replay, mutes, switches a filter, plays media or presses an OBS hotkey.'}, }; // The icon a catalog module wears: what it is about, not how it sends. @@ -126,9 +137,10 @@ const PRESET_ICON = { vod_markers:'bookmark', webhook:'link', crash_counter:'file', stream_alerts:'bell', api_command:'globe', clip_button:'film', delay_status:'clock', smart_alerts:'pulse', stream_timeline:'list', chapters:'bookmark', highlight:'star', hype_clip:'flame', on_air_light:'bulb', scene_delay:'layers', twitch_alerts:'heart', + hype_alerts:'flame', stream_log:'list', }; // Which step a card previews: the first one that says something. -const PREVIEW_ORDER = ['discord','chat','phone','overlay','marker','http','light','file','timeline','clip','delay_action','program']; +const PREVIEW_ORDER = ['discord','chat','phone','overlay','marker','http','light','file','timeline','dock','clip','delay_action','program']; // A Discord card's colors: the dashboard's palette, readable on Discord. const CARD_COLORS = [['#5ac8fa', 'Blue'], ['#3fcf8e', 'Green'], ['#f0a53a', 'Amber'], ['#f2665a', 'Red'], ['#fcd34d', 'Gold'], ['#a78bfa', 'Purple'], ['#ff7eb6', 'Pink'], ['#8b949e', 'Grey']]; @@ -250,6 +262,60 @@ function primaryStep(handler){ } return null; } +// ---- An alert's bigger ones +// From an amount up, a moment's alert can play a bigger one instead (its +// own design, sound and words). Kept as If steps in the moment's own +// steps, "if {bits} is less than 1000: the alert, otherwise: the bigger +// one", each inside the last, so the builder shows them as they are. +const TIER_VARS = {twitch_cheer:'bits', twitch_raid:'viewers', twitch_gift:'count', twitch_resub:'months'}; +const TIER_UNITS = {bits:'bits', viewers:'viewers', count:'subs', months:'months'}; +const TIER_FIRST = {bits:1000, viewers:50, count:5, months:12}; +// The alert and three bigger ones: three checks inside each other, as deep +// as steps go (model::MAX_DEPTH). +const MAX_TIERS = 4; +function tierVar(h){ return h && h.trigger.type === 'event' ? TIER_VARS[h.trigger.event] || '' : ''; } +// A link of the chain: a check on the amount with an alert on its yes side. +function isTierCheck(s, name){ + const p = s.params || {}; + return s.type === 'if' && s.enabled !== false && p.left === `{${name}}` && p.op === 'less' && +p.right > 0 && !p.match + && (s.then || []).length === 1 && s.then[0].type === 'overlay' && (s.else || []).length === 1; +} +// The moment's alert and its bigger ones, smallest first ({from, step}), +// where the chain is in its steps, and the amount it goes by. Null for a +// moment with no amount, or whose alert is somewhere else (in a check of +// its own, built by hand). +function alertTiers(h){ + const name = tierVar(h); + if (!name) return null; + const at = h.steps.findIndex(s => s.type === 'overlay' || isTierCheck(s, name)); + if (at < 0) return null; + const tiers = []; + let s = h.steps[at], from = 0; + while (isTierCheck(s, name)){ + tiers.push({from, step:s.then[0]}); + from = +s.params.right; + s = s.else[0]; + } + if (s.type !== 'overlay') return null; + tiers.push({from, step:s}); + return {at, name, tiers}; +} +// Write a chain back into the moment's steps. +function setTiers(h, chain){ + const t = chain.tiers; + let s = t[t.length - 1].step; + for (let n = t.length - 2; n >= 0; n--){ + s = {type:'if', params:{left:`{${chain.name}}`, op:'less', right:String(t[n + 1].from)}, then:[t[n].step], else:[s]}; + } + h.steps[chain.at] = s; +} +// "From 1000 bits", or what the first one covers. +function tierLabel(chain, n){ + const unit = TIER_UNITS[chain.name]; + if (n) return `From ${chain.tiers[n].from} ${unit}`; + return chain.tiers.length > 1 ? `Below ${chain.tiers[1].from} ${unit}` : 'Every one'; +} + function presetOf(i){ const cat = S.data && S.data.catalog; return cat ? cat.presets.find(p => p.id === i.preset) : null; @@ -333,10 +399,11 @@ const VAR_LABEL = { delay_ms:'delay (ms)', delay_max:'longest delay now', phase:'delay phase', hold_active:'reconnect screen on?', hold_left:'time left on hold', destinations_live:'platforms live', destinations_total:'platforms on', obs_live:'OBS streaming?', bitrate:'bitrate', channel:'your channel', time:'time now', date:'date', user:'viewer\'s name', - user_login:'viewer\'s login', user_role:'viewer\'s role', message:'their message', args:'text after the command', + user_login:'viewer\'s login', user_role:'viewer\'s role', message:'their message', words:'their words (no cheers)', args:'text after the command', target:'who it\'s about', arg1:'1st word', arg2:'2nd word', arg3:'3rd word', body:'what was sent', query:'address query', source:'pressed from', 'obs.scene':'OBS scene', 'obs.streaming':'streaming now?', 'obs.recording':'recording now?', 'obs.muted':'muted?', 'obs.visible':'showing?', 'clip.url':'clip link', 'clip.ok':'clip made?', 'clip.error':'why no clip', + 'stream.ok':'title or category set?', 'stream.category':'category on Twitch', 'stream.error':'why it wasn\'t set', uptime:'stream time', vod_time:'VOD time', onair:'on-air state', timeline:'timeline', chapters:'chapter list', previous_chapter:'chapter before', duration:'stream length', crashes:'crashes', drops:'drops', highlights:'highlights', report:'health report', state:'on-air state', line:'timeline line', kind:'line kind', @@ -352,6 +419,7 @@ function varLabel(name){ if (VAR_LABEL[name]) return VAR_LABEL[name]; let m; if ((m = /^counter\.(.+)$/.exec(name))) return `${m[1]} count`; + if ((m = /^(goal|timer)\.([a-z0-9_]+)(?:\.(\w+))?$/.exec(name))) return `${m[2]} ${m[1]}${m[3] ? ' ' + m[3] : ''}`; if ((m = /^(.+)\.json\.(.+)$/.exec(name))) return m[2].split('.').pop().replace(/_/g, ' '); if ((m = /^(.+)\.(status|ok|body|output|code)$/.exec(name))) return `${m[1]} ${{status:'status', ok:'worked?', body:'answer', output:'answer', code:'exit code'}[m[2]]}`; return name.replace(/[._]/g, ' '); @@ -398,6 +466,9 @@ const VAR_HELP = { 'clip.url':'The link to the clip the Clip step made.', 'clip.ok':'yes when the Clip step made a clip.', 'clip.error':'Why the Clip step made no clip, like "connect Twitch first". Empty when it worked.', + 'stream.ok':'yes when the Stream title and category step set what it was given.', + 'stream.category':'The category as Twitch names it: a mod types "valo", this says VALORANT.', + 'stream.error':'Why the title or category was not set, like no category by that name. Empty when it worked.', source:'What pressed it: hotkey, midi, stream_deck (its address) or web.', previous_chapter:'The chapter before the latest one: what the stream goes back to after a "Technical difficulties" chapter.', uptime:'How long this stream has been on air, like 1:02:14. Empty between streams.', @@ -442,6 +513,9 @@ function varHelp(name){ visible:`yes when the source "${m[1]}" checked was showing.`}[m[2]]; if ((m = /^(.+)\.body$/.exec(name))) return `Everything the "${m[1]}" web request got back.`; if ((m = /^counter\.(.+)$/.exec(name))) return `The "${m[1]}" counter, kept between runs.`; + if ((m = /^goal\.([a-z0-9_]+)$/.exec(name))) return `Where your "${m[1]}" goal stands. Add .target, .left, .percent or .name for the rest.`; + if ((m = /^goal\.([a-z0-9_]+)\.target$/.exec(name))) return `What your "${m[1]}" goal counts to.`; + if ((m = /^timer\.([a-z0-9_]+)$/.exec(name))) return `The time left on your "${m[1]}" timer, as a clock. Add .seconds, .running or .name for the rest.`; if ((m = /^arg([4-9])$/.exec(name))) return `Word number ${m[1]} after the command.`; if ((m = /^body\.(.+)$/.exec(name))) return `The ${m[1].split('.').join(' › ')} field of what the caller sent.`; if ((m = /^query\.(.+)$/.exec(name))) return `The "${m[1]}" value in the address, like ?${m[1]}=red.`; @@ -496,6 +570,9 @@ window.addEventListener('scroll', () => VarTip.hide(), true); // What a chat command's previews use as the words after it: a catalog // entry's own example ("!chapter Boss fight"), set when it opens. const SAMPLE_ARGS = new WeakMap(); +// The amount a moment's samples show while one of its bigger alerts is +// edited ({name, value}): the least that plays it. +const TIER_SAMPLE = new WeakMap(); // Sample values for previews: the variables this handler can use, each // with its `group` (trigger, steps, live). With `upto`, only what a step @@ -506,8 +583,15 @@ function varsFor(handler, upto){ const t = handler && handler.trigger; if (t && t.type === 'event'){ const e = eventOf(t.event); - // A countdown sample should count down from now. - if (e) own.push(...e.vars.map(v => v.name === 'hold_ends_at' ? {name:v.name, sample:String(Math.floor(Date.now() / 1000) + 120)} : v)); + // A countdown sample should count down from now; an amount is at least + // the trigger's minimum (from 1000 bits up previews 1000). + const tier = TIER_SAMPLE.get(handler); + if (e) own.push(...e.vars.map(v => { + if (v.name === 'hold_ends_at') return {name:v.name, sample:String(Math.floor(Date.now() / 1000) + 120)}; + if (tier && tier.name === v.name) return {name:v.name, sample:tier.value}; + const least = +(t.filters || {})['min_' + v.name] || 0; + return least > +v.sample ? {name:v.name, sample:String(least)} : v; + })); } // A block runs inside whatever started it: most often a command. if (t && (t.type === 'chat_command' || t.type === 'chat_message' || t.type === 'block')){ @@ -550,6 +634,7 @@ function varsFor(handler, upto){ } if (s.type === 'clip') made.push({name:'clip.url', sample:'https://clips.twitch.tv/BraveSnipe'}, {name:'clip.id', sample:'BraveSnipe'}, {name:'clip.ok', sample:'yes'}, {name:'clip.error', sample:''}); + if (s.type === 'channel') made.push({name:'stream.ok', sample:'yes'}, {name:'stream.category', sample:'VALORANT'}, {name:'stream.error', sample:''}); if (s.type === 'counter' && fixed(s.params.name)) made.push({name:'counter.' + s.params.name, sample:'2'}); if (s.type === 'counter' && (s.params.save_as || '').trim() && fixed(s.params.save_as.trim())) made.push({name:s.params.save_as.trim(), sample:'2'}); if (s.type === 'set_var' && fixed(s.params.name)) made.push({name:s.params.name, sample:s.params.value || ''}); @@ -591,8 +676,9 @@ function varsFor(handler, upto){ .filter(v => !seen.has(v.name) && seen.add(v.name)); } // The streamer's own values as variables: {my.}, each with what it holds now. +// Goals and timers ride along: {goal.} and {timer.} are the streamer's too. function myValues(){ - return (S.data.values || []).map(v => ({name:'my.' + v.name, sample:v.value})); + return (S.data.values || []).map(v => ({name:'my.' + v.name, sample:v.value})).concat(trackerValues()); } function sampleMap(handler){ const m = {}; @@ -614,7 +700,7 @@ function unknownVars(handler, step){ // Families no list can hold: any counter (counters outlive a run), any // field of a web answer or of what a web call sent, any word of a command. // Viewer values, the leaderboard and kept texts are read by name too. - const prefixes = ['counter.', 'viewer.', 'target.', 'top.', 'saved.'].concat(names.filter(n => n.endsWith('.status')).map(n => n.slice(0, -6))); + const prefixes = ['counter.', 'viewer.', 'target.', 'top.', 'saved.', 'goal.', 'timer.'].concat(names.filter(n => n.endsWith('.status')).map(n => n.slice(0, -6))); if (t.type === 'webhook' || t.type === 'shortcut') prefixes.push('body.', 'query.'); const chat = t.type === 'chat_command' || t.type === 'chat_message' || t.type === 'block'; return [...used].filter(n => !names.includes(n) && !prefixes.some(p => n.startsWith(p)) && !(chat && /^arg[1-9]$/.test(n))) @@ -649,7 +735,8 @@ function unknownVarsHtml(list){ // Rename a variable in every field of every step of `handler`. function renameVar(handler, from, to){ // Not inside {{…}}: that is text written out as is. - const re = new RegExp(`(? Object.keys(s.params || {}).forEach(k => { if (typeof s.params[k] === 'string') s.params[k] = s.params[k].replace(re, (all, fallback) => `{${to}${fallback || ''}}`); })); @@ -991,10 +1078,14 @@ function listedNames(text){ } // What a check that reads several sources names each one's values by: // {obs.mic_aux.muted}. Mirrors obsws::source_keys. +// "Daily gives!" as a name a template can say: daily_gives, at most `max`. +function snakeName(text, max){ + return text.toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+/, '').slice(0, max).replace(/_+$/, ''); +} function sourceKeys(names){ const keys = []; names.forEach(name => { - const base = name.toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+/, '').slice(0, 24).replace(/_+$/, '') || 'source'; + const base = snakeName(name, 24) || 'source'; let key = base; for (let n = 2; keys.includes(key); n++) key = base + n; keys.push(key); @@ -1304,10 +1395,6 @@ function enterStagger(root){ const SEG_IND = ''; // A slider's track fills up to its thumb (--p, read by index.html's CSS). -function fillRange(el){ - const min = +el.min || 0, max = +el.max || 100; - el.style.setProperty('--p', ((+el.value - min) / (max - min || 1)) * 100 + '%'); -} function fillRanges(root){ root.querySelectorAll('input[type=range]').forEach(fillRange); } function placeIndicators(root){ root.querySelectorAll('.ig-seg').forEach(seg => { @@ -1454,13 +1541,14 @@ function q(sel){ } // A toast with Undo, for actions that are easy to regret. -function undoToast(text, undo){ actionToast(text, 'Undo', undo); } +// `adds`: the Undo takes away integrations just added (see dropAddUndos). +function undoToast(text, undo, adds){ actionToast(text, 'Undo', undo, adds); } // A toast with one button: Undo, or the obvious next step ("Edit copy"). -function actionToast(text, label, act){ +function actionToast(text, label, act, adds){ const rail = $('toast-rail'); if (!rail){ toast(text, 'ok'); return; } const el = document.createElement('div'); - el.className = 'ic-toast ok ig-undo'; + el.className = 'ic-toast ok ig-undo' + (adds ? ' ig-undo-adds' : ''); el.innerHTML = `✓ `; el.querySelector('.ig-undo-text').textContent = text; @@ -1471,6 +1559,7 @@ function actionToast(text, label, act){ el.classList.add('leaving'); setTimeout(() => el.remove(), 350); }; + el.icLeave = leave; el.querySelector('button').addEventListener('click', async () => { if (used) return; used = true; @@ -1484,6 +1573,11 @@ function actionToast(text, label, act){ el.addEventListener('pointerleave', () => { since = Date.now(); timer = setTimeout(leave, Math.max(left, 600)); }); } const UNDO_MS = 8000; +// An integration's editor opening: an Undo of an add still up could take +// away what it edits, so it goes. +function dropAddUndos(){ + document.querySelectorAll('#toast-rail .ig-undo-adds').forEach(el => { if (el.icLeave) el.icLeave(); }); +} // A secret field (topic, webhook link, headers, secret address): hidden // like a password until the user asks to see it. @@ -1645,6 +1739,7 @@ const Menu = { el.setAttribute('role', 'menu'); el.innerHTML = ` + ${svg('warn')}Report a problem @@ -1654,9 +1749,7 @@ const Menu = { // The report opens in a new tab; the menu has done its job. el.addEventListener('click', e => { if (e.target.closest('a[role="menuitem"]')) this.close(); }); el.addEventListener('keydown', e => this.keys(e)); - const r = btn.getBoundingClientRect(), w = el.offsetWidth, h = el.offsetHeight; - el.style.left = Math.max(8, Math.min(r.right - w, innerWidth - w - 8)) + 'px'; - el.style.top = (r.bottom + 6 + h > innerHeight - 8 ? r.top - h - 6 : r.bottom + 6) + 'px'; + this.place(el, btn); this.el = el; this.id = id; this.btn = btn; @@ -1666,6 +1759,35 @@ const Menu = { window.addEventListener('scroll', this.dismiss, true); window.addEventListener('resize', this.dismiss); }, + // Beside its button, inside the window. + place(el, btn){ + const r = btn.getBoundingClientRect(), w = el.offsetWidth, h = el.offsetHeight; + el.style.left = Math.max(8, Math.min(r.right - w, innerWidth - w - 8)) + 'px'; + el.style.top = (r.bottom + 6 + h > innerHeight - 8 ? r.top - h - 6 : r.bottom + 6) + 'px'; + }, + // The folders it can go to, in place of the menu: the one it's in ticked, + // out of any, and a new one typed in (Enter). + folders(id){ + const i = find(id); + if (!i || !this.el) return; + const cur = i.folder || ''; + const item = (v, label, n) => ``; + const names = folderNames(); + this.el.innerHTML = `
Move to
${names.map((f, n) => item(f, f, n)).join('')} + ${cur ? item('', 'Out of its folder', names.length) : ''} + ${names.length || cur ? '' : ''} + `; + this.place(this.el, this.btn); + const typed = this.el.querySelector('.ig-menu-new'); + typed.addEventListener('keydown', e => { + if (e.key !== 'Enter' || !typed.value.trim()) return; + e.preventDefault(); + this.close(); + moveToFolder([id], typed.value); + }); + (names.length ? this.el.querySelector('[role="menuitem"]') : typed).focus({preventScroll:true}); + }, close(){ if (!this.el) return; const el = this.el, btn = this.btn, hadFocus = el.contains(document.activeElement); @@ -1681,16 +1803,25 @@ const Menu = { } }, keys(e){ + // A new folder's Enter closed it already. + if (!this.el) return; const items = [...this.el.querySelectorAll('[role="menuitem"]')]; const at = items.indexOf(document.activeElement); + const step = {ArrowDown:1, ArrowUp:-1}[e.key]; if (e.key === 'Escape'){ e.preventDefault(); this.close(); } - else if (e.key === 'ArrowDown'){ e.preventDefault(); items[(at + 1) % items.length].focus(); } - else if (e.key === 'ArrowUp'){ e.preventDefault(); items[(at - 1 + items.length) % items.length].focus(); } + else if (step && items.length){ e.preventDefault(); items[(at + step + items.length) % items.length].focus(); } else if (e.key === 'Tab') this.close(); }, outside: e => { if (Menu.el && !Menu.el.contains(e.target) && !e.target.closest('[data-act="menu"]')) Menu.close(); }, + // Scrolling or resizing closes it, unless a new folder's name is being + // typed: a phone's keyboard opening does both, and the menu moves to fit. // Resize events target the window, which isn't a Node. - dismiss: e => { if (Menu.el && !(e && e.target instanceof Node && Menu.el.contains(e.target))) Menu.close(); }, + dismiss: e => { + if (!Menu.el || (e && e.target instanceof Node && Menu.el.contains(e.target))) return; + const typing = document.activeElement && document.activeElement.matches('.ig-menu-new'); + if (typing && Menu.el.contains(document.activeElement)) Menu.place(Menu.el, Menu.btn); + else Menu.close(); + }, }; // The clipboard API only exists on https or this PC; a phone on the LAN @@ -1748,6 +1879,9 @@ function previewHtml(step, handler, big){ return `
${svg('shield')} InstantCloneAPP
${discordTimes(esc(ping + text))}
`; } + case 'dock': + return `
${step.params.group ? `${esc(renderSample(step.params.group, vars))}` : ''} +
${esc(text || 'An event')}
`; case 'timeline': { const live = step.params.at !== 'aired'; const at = vars[live ? 'vod_time' : 'uptime'] || '1:02:14'; @@ -1799,12 +1933,25 @@ const S = { data:null, visible:false, filter:'all', search:'', peek:null, view: store.get('ig-view') === 'list' ? 'list' : 'grid', pollTimer:null, activityOpen:false, + // The page of the Overlay tab that is showing ('alerts' or 'goals'), and + // what its last redraw was drawn from. + stage:null, staged:'', + // What the last poll that redrew the tab brought, and when that was. + heard:'', drawnAt:0, // The Recent activity row showing its steps (its data-key), and whether // an editor shows its last run's steps. actOpen:null, lastRunOpen:false, // Secret fields the user chose to show (by field id). Hidden by default: // a dashboard is often on a stream. revealed:new Set(), + // Folders shown folded, kept between visits; the one being renamed; + // folders made with New folder that nothing is in yet (this visit only). + // Whatever the browser kept, a list or not, never stops the page. + folded:new Set([].concat(readJson(store.get('ig-folded'), []))), + renaming:null, pending:[], + // The integration being dragged to a folder, and a redraw that waits for + // the drop (a redraw mid-drag would drop the dragged card). + dragging:null, redraw:false, }; async function load(){ @@ -1818,6 +1965,9 @@ async function load(){ return false; } S.data = d; + // The alert designs, by name, for the alerts' Design chip (read quietly: + // the Studio may have saved or renamed one since). + Stage.loadDesigns().catch(() => {}); // The answer redraws the tab: the card around it eases to its new height. if (typeof easeCardHeight === 'function') easeCardHeight(render); else render(); // Opening the dashboard connects OBS: show it soon, not at the next poll. @@ -1825,8 +1975,21 @@ async function load(){ return true; } +// The most steps one integration holds: the app's own limit (model.rs). +const MAX_STEPS = 256; +// Whether `count` more steps fit in integration `d`; says why when not. +function stepsFit(d, count){ + let total = 0; + d.handlers.forEach(h => walk(h.steps, () => { total++; })); + if (total + count <= MAX_STEPS) return true; + toast(`That's the most steps one integration can have (${MAX_STEPS})`, 'info'); + return false; +} +// How long the tab may go without a redraw while nothing changes. +const IDLE_REDRAW_MS = 30000; + async function poll(){ - if (document.hidden || !S.data || (!S.visible && !modalOpen())) return; + if (document.hidden || !S.data || (!S.visible && !S.stage && !modalOpen())) return; const a = await api('/integrations/activity'); if (!S.data || !a || !a.activity) return; const fastBefore = !!S.data.twitch.login; @@ -1838,15 +2001,28 @@ async function poll(){ const alerts = Object.assign({}, a.alerts, {look:lookOf()}); const alertsChanged = JSON.stringify(alerts) !== JSON.stringify(S.data.alerts); S.data.alerts = alerts; + S.data.tracking = a.tracking || S.data.tracking; if (fastBefore !== !!a.twitch.login) schedulePoll(); - if (S.visible) render(); + // The Overlay tab's pages show less than this tab, and redraw for less. + const staged = JSON.stringify([a.tracking, alerts, a.twitch.main.login]); + if (S.stage && staged !== S.staged){ + S.staged = staged; + Stage.render(); + } + // Most polls bring nothing new. Redrawing every card for each one made + // the whole dashboard crawl once a setup had a few hundred steps, so the + // tab is redrawn when something changed, and now and then for its + // "2 min ago" lines. + const heard = JSON.stringify([a.activity, a.stats, a.twitch, a.obs, alerts]); + const stale = Date.now() - S.drawnAt > IDLE_REDRAW_MS; + if (S.visible && (heard !== S.heard || stale)){ + S.heard = heard; + S.drawnAt = Date.now(); + render(); + } // Pages that show live state. if (Modal.kind === 'connections' && ((Conn.tab === 'twitch' && twitchChanged) || (Conn.tab === 'stream' && alertsChanged))) Conn.render(); if (Modal.kind === 'editor' && alertsChanged && (primaryStep(Editor.handler()) || {}).type === 'overlay') Editor.render(); - if (Modal.kind === 'alerts' && alertsChanged){ - const status = q('.ig-look-body [data-key="alerts-status"]'); - if (status) status.outerHTML = alertsStatusHtml(false); - } } function schedulePoll(){ @@ -1871,7 +2047,7 @@ function find(id){ return S.data.integrations.find(i => i.id === id); } function needsTwitch(i){ if (S.data.twitch.main.login) return false; return (i.handlers || []).some(h => h.trigger && h.trigger.type.startsWith('chat')) - || allSteps(i).some(s => s.type === 'chat' || s.type === 'marker' || s.type === 'clip'); + || allSteps(i).some(s => ['chat', 'marker', 'clip', 'channel'].includes(s.type)); } // Run a program and Write a file. With no dashboard password, only the @@ -1948,7 +2124,8 @@ function visibleIntegrations(){ const q = S.search.trim().toLowerCase(); return S.data.integrations.filter(i => (S.filter === 'all' || categoryOf(i) === S.filter) - && (!q || i.name.toLowerCase().includes(q) || JSON.stringify(i.handlers).toLowerCase().includes(q))); + && (!q || i.name.toLowerCase().includes(q) || (i.folder || '').toLowerCase().includes(q) + || JSON.stringify(i.handlers).toLowerCase().includes(q))); } function skeletonHtml(){ @@ -1958,8 +2135,15 @@ function skeletonHtml(){ } function render(){ + // The Overlay tab's page follows the same data. While it shows, this tab + // is hidden and is drawn when it is opened instead. + if (S.stage){ + Stage.render(); + if (!S.visible) return; + } const root = $('ig-root'); if (!root || !S.data) return; + if (S.dragging){ S.redraw = true; return; } const d = S.data; const on = d.integrations.filter(i => i.enabled).length; const failing = d.integrations.filter(i => cardInfo(i).failing); @@ -1979,7 +2163,7 @@ function render(){
${noticesHtml(failing)}
${connectionsHtml()} - ${empty ? emptyHtml() : toolbarHtml() + `
${itemsHtml()}
`} + ${empty ? emptyHtml() : toolbarHtml() + `
${itemsHtml()}
`} ${activityHtml()} `); if (typeof fitPaneCard === 'function') fitPaneCard(); @@ -1994,7 +2178,11 @@ function noticesHtml(failing){ `); } const consent = twitchEventsIn('needs_consent'); - if (consent.length && d.twitch.main.login){ + if (twitchMissing()){ + out.push(`
⚠ + Log in to Twitch again once to allow ${esc(twitchMissing())}: your login is from before InstantClone asked for that. +
`); + } else if (consent.length && d.twitch.main.login){ out.push(`
⚠ Twitch needs your OK before InstantClone hears ${esc(twitchNouns(consent))}: your login is from before these events.
`); @@ -2043,6 +2231,7 @@ function toolbarHtml(){ ${tab('all', 'All')}${tab('alerts', 'Alerts')}${tab('chat', 'Chat')}${tab('auto', 'Automation')}${tab('own', 'Your own')}${SEG_IND} +
${view('grid', 'M4 4h7v7H4zM13 4h7v7h-7zM4 13h7v7H4zM13 13h7v7h-7z', 'Grid')}${view('list', 'M4 6h16M4 12h16M4 18h16', 'List')}${SEG_IND}
@@ -2052,7 +2241,87 @@ function toolbarHtml(){ function itemsHtml(){ const items = visibleIntegrations(); const none = items.length ? '' : `
Nothing here${S.search ? ' matches "' + esc(S.search) + '"' : ' yet'}.
`; - return S.view === 'grid' ? gridHtml(items, none) : listHtml(items, none); + const draw = S.view === 'grid' ? gridHtml : listHtml; + const folders = folderNames(); + // Shown while a card is dragged: drop it here for a folder of its own. + const fresh = `
${svg('folder')}Drop here for a new folder
`; + if (!folders.length || !items.length) return fresh + draw(items, none, true, ''); + // In folders, A to Z, then the rest. A search shows every match, and + // only folders with one. + const groups = folders.map(f => [f, items.filter(i => i.folder === f)]).concat([['', items.filter(i => !i.folder)]]) + .filter(([f, list]) => list.length || (f && !S.search && S.pending.includes(f))); + const last = groups[groups.length - 1][0]; + return fresh + groups.map(([f, list], n) => { + const folded = !S.search && S.folded.has(f); + const body = !list.length ? `
Drag integrations here
` + : draw(list, '', n === groups.length - 1, f); + return folderHeadHtml(f, list, folded) + (folded ? '' : body); + }).join('') + (!S.search && (S.folded.has(last) || !groups[groups.length - 1][1].length) ? draw([], '', true, last) : ''); +} + +// ---- Folders: a name an integration can carry. A folder is there while +// something is in it. +function folderNames(){ + return [...new Set(S.data.integrations.map(i => i.folder || '').concat(S.pending).filter(Boolean))].sort((a, b) => a.localeCompare(b)); +} +// "New folder", or "New folder 2" when that one is there. +function newFolderName(){ + const names = folderNames(); + let name = 'New folder'; + for (let n = 2; names.includes(name); n++) name = 'New folder ' + n; + return name; +} +// Rename a folder now, its name picked. +function renameNow(folder){ + S.renaming = folder; + render(); + // On the tab itself (q looks in the open window only). + const field = [...$('ig-root').querySelectorAll('.ig-folder-in')].find(el => !el.closest('[data-leaving]')); + if (field){ field.focus({preventScroll:true}); field.select(); field.scrollIntoView({block:'nearest', behavior:reduced() ? 'auto' : 'smooth'}); } +} +// A folder's heading: fold it, how many it holds and how many are on, and +// rename it (in place). The rest are "Not in a folder", with no rename. +function folderHeadHtml(folder, list, folded){ + const on = list.filter(i => i.enabled).length; + const key = 'fh-' + (folder || '~none'); + if (folder && S.renaming === folder){ + return `
${svg('folder', ' class="ig-folder-ic"')} +
`; + } + return `
+ + ${folder ? ` + ` : ''}
`; +} +// Put integrations in a folder ('' for none), in one save. +async function moveToFolder(ids, folder){ + const r = await api('/integrations/folder', {ids, folder}); + if (!r || r.ok === false){ toast((r && r.error) || 'That didn\'t save', 'err', 6000); return false; } + const kept = oneLine(folder).trim(); + ids.forEach(id => { const i = find(id); if (i) i.folder = kept; }); + render(); + return true; +} +// A folder renamed: everything in it moves, folded or not as it was. +async function renameFolder(was, name){ + S.renaming = null; + name = oneLine(name).trim().slice(0, 40); + const ids = S.data.integrations.filter(i => i.folder === was).map(i => i.id); + // A new folder with nothing in it yet is only here: renamed (or, left + // blank, gone) here. + if (!ids.length){ + const others = S.pending.filter(f => f !== was); + S.pending = others.concat(name && !others.includes(name) ? [name] : []); + render(); + return; + } + if (!name || name === was){ render(); return; } + if (await moveToFolder(ids, name) && S.folded.delete(was)){ + S.folded.add(name); + store.set('ig-folded', JSON.stringify([...S.folded])); + } } function switchHtml(i){ @@ -2068,10 +2337,10 @@ function flagHtml(flag){ return `${flag.label}`; } -function gridHtml(items, none){ - return `
${none}${items.map(i => { +function gridHtml(items, none, withAdd, folder){ + return `
${none}${items.map(i => { const c = cardInfo(i); - return `
+ return `
${previewHtml(c.step, c.h, false)}${flagHtml(c.flag)}${c.hist}
@@ -2084,14 +2353,14 @@ function gridHtml(items, none){
`; }).join('')} -
`; + ${withAdd ? `` : ''}
`; } -function listHtml(items, none){ - return `
${none}${items.map(i => { +function listHtml(items, none, withAdd, folder){ + return `
${none}${items.map(i => { const c = cardInfo(i); const open = S.peek === i.id; - return `
+ return `
${svg(c.icon)}
` : ''}
`; }).join('')} -
`; + ${withAdd ? `` : ''}
`; } function emptyHtml(){ @@ -2196,6 +2465,14 @@ function glow(id){ } // Add from the catalog. The integrations added, or none. +// The designs `added` integrations' alerts name, saved now: a ready-made +// one only becomes a file the first time it is used, and the alerts page +// needs the file. +async function saveNamedDesigns(added){ + const named = new Set(); + added.forEach(i => allSteps(i).forEach(s => { if (s.type === 'overlay' && s.params.design) named.add(s.params.design); })); + for (const slug of named) await Stage.ensureDesign(slug); +} async function addFrom(body, openAfter){ const fromCatalog = Modal.kind === 'catalog'; const r = await api('/integrations/add', body); @@ -2204,20 +2481,28 @@ async function addFrom(body, openAfter){ const added = (r.ids || []).map(find).filter(Boolean); if (!added.length){ toast('You already have everything in this pack', 'info'); return []; } const ids = added.map(i => i.id); + await saveNamedDesigns(added); + // Both sets of Twitch alerts on: every follow, sub or cheer plays twice. + const RIVAL = {twitch_alerts:'hype_alerts', hype_alerts:'twitch_alerts'}; + const rivalOn = i => S.data.integrations.find(x => x.enabled && x.preset === RIVAL[i.preset]); + const twice = added.find(i => RIVAL[i.preset] && rivalOn(i)); + if (twice) toast(`${rivalOn(twice).name} is on too: switch one off, or every Twitch event plays two alerts`, 'info', 8000); const unfinished = added.find(i => !i.enabled); - if (openAfter && unfinished){ - // Going back from here means "not this one": the draft goes again. + // One added opens, to see it or finish it (a pack's waiting draft too). + const opened = openAfter && (unfinished || (added.length === 1 ? added[0] : null)); + if (opened){ + // Going back from here means "not this one": it goes again. const back = fromCatalog ? {title:'Back to the catalog, without adding it', go:async () => { - await deleteIds([unfinished.id]); + await deleteIds([opened.id]); Catalog.reopen(); }} : null; - openEditor(unfinished, true, {justAdded:true, back}); + openEditor(opened, true, {justAdded:true, back}); return added; } undoToast(added.length > 1 ? `Added ${added.length} integrations` : `Added ${added[0] ? added[0].name : ''}`, async () => { await deleteIds(ids); if (Modal.kind === 'catalog') Catalog.render(); - }); + }, true); if (unfinished) toast(`${unfinished.name} needs one more detail before it can switch on`, 'info', 4500); if (Modal.kind === 'catalog' && body.preset) Catalog.flashAdded(body.preset); return added; @@ -2408,7 +2693,7 @@ const Modal = { el.inert = true; setTimeout(() => el.remove(), reduced() ? 0 : 180); if (opener && opener.isConnected && typeof opener.focus === 'function') opener.focus({preventScroll:true}); - if (!S.visible) clearInterval(S.pollTimer); + if (!S.visible && !S.stage) clearInterval(S.pollTimer); }, // Back to the view this one was opened from. Edits that travel back // with it skip the unsaved-changes question; with nowhere to go back to, @@ -2844,6 +3129,7 @@ function needsHtml(needs){ case 'web': return ['', 'An address', 'You paste the address it sends to']; case 'light': return ['', 'A light on your network', 'You pick it after adding: WiZ, LIFX, Govee, Yeelight, Kasa, Shelly or WLED']; case 'file': return ['', 'A file', 'You pick the file it writes']; + case 'dock': return ['', 'The OBS dock', "Switch on Events in your dock's layout to see them"]; case 'obs': { const o = S.data.obs || {}; return o.state === 'connected' ? ['ok', 'OBS WebSocket'] @@ -2868,6 +3154,7 @@ const Editor = { // it as a draft, which closing keeps and Back removes again. open(i, switchOn, opts){ opts = opts || {}; + dropAddUndos(); this.d = withHandler(clone(i)); this.d.handlers.forEach(h => sampleArgsFrom(h, presetOf(this.d))); this.back = opts.back || null; @@ -2880,6 +3167,7 @@ const Editor = { this.dirty = !!switchOn && !this.d.enabled && !this.justAdded; if (switchOn) this.d.enabled = true; this.sel = Math.max(0, this.d.handlers.findIndex(h => h.enabled)); + this.tier = 0; // Missing a detail: open straight on the chip that fixes it. const known = id => S.data.connections.discord.some(c => c.id === id); const steps = allSteps(this.d); @@ -2923,7 +3211,7 @@ const Editor = { // The picked value takes the whole answer's place in the reply, or // joins the end of it. useInReply(){ - const h = this.handler(), http = firstStep(h, 'http'), step = primaryStep(h); + const h = this.handler(), http = firstStep(h, 'http'), step = this.stepOf(h); const t = http && tryEntry(http), field = step && (KINDS[step.type] || {}).text; if (!t || !t.picked || !field) return; const token = '{' + t.picked.token + '}'; @@ -2945,7 +3233,7 @@ const Editor = { this.render(); return; } - const h = this.handler(), step = primaryStep(h), field = step && (KINDS[step.type] || {}).text; + const h = this.handler(), step = this.stepOf(h), field = step && (KINDS[step.type] || {}).text; allSteps(this.d).filter(s => s.type === 'http').forEach(s => { s.params.url = r.url; }); if (r.reply && field) step.params[field] = r.reply; if (r.command && h.trigger.type === 'chat_command') h.trigger.command = r.command; @@ -3012,9 +3300,32 @@ const Editor = { if (btn) btn.focus({preventScroll:true}); return true; }, + // The step it edits: the moment's main one, or the bigger alert picked + // under Bigger ones. + stepOf(h){ + const chain = alertTiers(h); + return chain ? (chain.tiers[this.tier] || chain.tiers[0]).step : primaryStep(h); + }, + // A bigger alert's amount, typed: a whole number between its neighbours'. + // False while it isn't (the field says so). + tierFrom(h, el){ + const chain = alertTiers(h), n = +el.dataset.n, v = +el.value.trim(); + if (!chain || !(n >= 1) || !chain.tiers[n]) return false; + const below = chain.tiers[n - 1].from, above = (chain.tiers[n + 1] || {}).from; + const ok = Number.isInteger(v) && v > below && (above === undefined || v < above); + el.setAttribute('aria-invalid', ok ? 'false' : 'true'); + el.title = ok ? '' : `A whole number above ${below}${above === undefined ? '' : ` and below ${above}`}`; + if (!ok) return false; + chain.tiers[n].from = v; + setTiers(h, chain); + return true; + }, render(){ const d = this.d, h = this.handler(); - const step = primaryStep(h); + const chain = alertTiers(h); + if (chain && this.tier) TIER_SAMPLE.set(h, {name:chain.name, value:String(chain.tiers[this.tier].from)}); + else TIER_SAMPLE.delete(h); + const step = this.stepOf(h); const k = step ? KINDS[step.type] : null; const p = presetOf(d); const head = modalHead(PRESET_ICON[d.preset] || (k ? k.icon : 'steps'), k ? k.c : 'var(--accent)', titleInput(d.name), @@ -3040,7 +3351,7 @@ const Editor = { Modal.form.querySelectorAll('.ig-msg').forEach(autosize); if (this.chip === 'detect') Meter.watch(); if (this.chip === 'scene') ObsLive.watch(); - if (step && step.type === 'overlay') this.syncStage(step, h, false); + if (step && step.type === 'overlay') alertStage(step, h, false); }, // The moments it acts on, each with its switch. A moment that still // misses something says so in its colour and its tooltip. @@ -3126,11 +3437,12 @@ const Editor = { box = `
ON STREAM · LIVE PREVIEW - ${esc(THEMES[themeOf(lookOf())].name)} theme
+ Looks like ${esc(designLabel(p.design))}
${tplHtml(``, p.title, vars, 'line')}
${editor}
+ ${this.messageFieldHtml(step, h, vars)}
${alertsStatusHtml(true)}`; } else if (step.type === 'phone'){ box = `
@@ -3194,6 +3506,17 @@ const Editor = { change(list); step.params.fields = fieldsText(list); }, + // The viewer's own words under the alert, where the moment brings some + // (a cheer, a resub, a redeem's input), or one already set. + messageFieldHtml(step, h, vars){ + const p = step.params; + // A cheer's words without its Cheer100s first. + const known = varsFor(h); + const said = ['words', 'message', 'input'].find(name => known.some(v => v.name === name)); + if (!said && !p.message) return ''; + return `
${tplHtml(``, p.message, vars, 'line')}
`; + }, // The chips shown for this moment, with their current value. chips(step, h){ const d = this.d, out = []; @@ -3212,7 +3535,13 @@ const Editor = { } } if (step && step.type === 'overlay'){ - const p = step.params; + const p = step.params, chain = alertTiers(h); + if (chain){ + const bigger = chain.tiers.length - 1; + out.push({id:'tiers', k:'Bigger ones', v:this.tier ? 'editing ' + lowerFirst(tierLabel(chain, this.tier)) + : bigger ? tierLabel(chain, 1).toLowerCase() + (bigger > 1 ? ` +${bigger - 1}` : '') : 'none'}); + } + out.push({id:'adesign', k:'Design', v:designLabel(p.design)}); out.push({id:'acolor', k:'Accent', v:alertColorName(p.color)}); out.push({id:'asound', k:'Sound', v:soundLabel(p.sound)}); out.push({id:'apic', k:'Picture', v:p.image ? (MEDIA_REF.test(p.image) ? p.image.slice(6) : 'a link') : 'none', @@ -3237,6 +3566,7 @@ const Editor = { } if (step && step.type === 'chat') out.push({id:'reply', k:'Reply as', v:step.params.as === 'bot' ? 'bot account' : 'your account'}); if (step && step.type === 'phone') out.push({id:'priority', k:'Priority', v:step.params.priority || 'normal'}); + if (step && step.type === 'dock') out.push({id:'dgroup', k:'Group', v:step.params.group || 'none'}); const light = firstStep(h, 'light'); if (light) out.push({id:'light', k:'Light', v:light.params.name || 'pick your light', warn:!light.params.device}); const http = firstStep(h, 'http'); @@ -3247,15 +3577,6 @@ const Editor = { out.push({id:'quiet', k:'Quiet hours', v:d.quiet ? `${d.quiet.from} to ${d.quiet.to}` : 'off'}); return out; }, - // The alert on the real page, in the page's look but in the middle of - // the box, so the short stage shows it whole. `replay` 'full' plays it - // coming in and going out (alerts.html showDemo). - syncStage(step, h, replay){ - const frame = q('.ig-ed-stage .ig-stage-frame'); - if (!frame) return; - const look = Object.assign({}, lookOf(), {position:'middle', align:'center'}); - stageShow(frame, frame.parentElement.clientWidth / 0.72, {look, card:alertCardOf(step, h), replay, sound:!!replay}); - }, // The other alerts in this integration: one click gives them all this // option, as each moment has its own. everyAlertHtml(names){ @@ -3285,8 +3606,6 @@ const Editor = { popHtml(step, h){ if (this.chip === '__test') return testPanelHtml(this); const d = this.d, t = h.trigger; - const seg = (items, cur, act) => `
${items.map(([id, label]) => - ``).join('')}${SEG_IND}
`; switch (this.chip){ case 'where': { const cur = (allSteps(d).find(s => s.type === 'discord') || {params:{}}).params.connection; @@ -3295,42 +3614,49 @@ const Editor = { #${esc(c.name)}${esc(c.hint)}`).join('')}
`; } + case 'tiers': { + const chain = alertTiers(h); + if (!chain) return ''; + const unit = TIER_UNITS[chain.name]; + const rows = chain.tiers.map((t, n) => { + const on = n === this.tier; + const what = n ? `From${unit}` : `${esc(tierLabel(chain, 0))}`; + return `
${what} + ${esc(designLabel(t.step.params.design))} + + ${n ? `` : ''}
`; + }).join(''); + const add = chain.tiers.length < MAX_TIERS + ? `` : ''; + return `
Bigger ones
${rows}${add}
+
From that many ${unit} up, its own alert plays instead: its design, sound and words. Edit picks the one the preview and the options above change.
`; + } + case 'adesign': return `
Design
${designTilesHtml(step.params.design)} +
Made and changed in the Studio, on the Overlay tab's .
${this.everyAlertHtml(['design'])}`; case 'acolor': { - const mine = /^#[0-9a-f]{6}$/i.test(step.params.color || '') ? step.params.color.toLowerCase() : ''; - const shown = alertColor(mine, lookOf()); - const known = !mine || CARD_COLORS.some(([c]) => c === mine); - const swatch = (c, label, style) => ``; - const swatches = [swatch('', 'The theme\'s', alertColor('', lookOf()))].concat(CARD_COLORS.map(([c, label]) => swatch(c, label, c))).join(''); - return `
Accent
-
${swatches} -
-
The first one is the theme's. Everything else about the look is the theme's: .
${this.everyAlertHtml(['color'])}`; + return `
Accent
${accentPickerHtml(step)} +
The first one is the design's. Everything else about the look is its design's: .
${this.everyAlertHtml(['color'])}`; } case 'asound': { - const sound = step.params.sound || ''; - const volume = step.params.volume === undefined || step.params.volume === '' ? 70 : +step.params.volume; - return `
Sound
${soundPickerHtml(sound, 'pick-sound', true)} -
- ${volume}% -
+ return `
Sound
${soundControlsHtml(step)}
Plays with the alert, through OBS, so viewers hear it too.
${this.everyAlertHtml(['sound', 'volume'])}`; } case 'apic': return `
Picture
${picturePickerHtml(step.params.image || '', 'pick-picture')} -
A PNG, JPG or GIF (it plays). Where it goes is the theme's.
${this.everyAlertHtml(['image'])}`; +
A PNG, JPG or GIF (it plays). Where it goes is its design's.
${this.everyAlertHtml(['image'])}`; case 'atime': - return `
On screen for
${seg(ALERT_SECONDS.map(n => [n, n + ' s']), step.params.seconds || '6', 'set-seconds')} + return `
On screen for
${segHtml(ALERT_SECONDS.map(n => [n, n + ' s']), step.params.seconds || '6', 'set-seconds')}
Alerts that come in together wait their turn, so none cuts another short.
${this.everyAlertHtml(['seconds'])}`; case 'look': - return `
Look
${seg([['', 'Plain message'], ['card', 'Card']], step.params.style || '', 'set-style')} + return `
Look
${segHtml([['', 'Plain message'], ['card', 'Card']], step.params.style || '', 'set-style')}
A card has a colored edge, a title, fields side by side and a timestamp. Write on it right in the preview.
`; case 'msg': { const edit = step.params.edit || ''; const key = step.params.key || ''; const perPlatform = /\{destination\}/.test(key); const canSplit = varsFor(h).some(v => v.name === 'destination'); - return `
Each time it runs
${seg([['', 'Post a new message'], ['last', 'Update the same message'], ['close', 'Update it, then start fresh']], edit, 'set-edit')} + return `
Each time it runs
${segHtml([['', 'Post a new message'], ['last', 'Update the same message'], ['close', 'Update it, then start fresh']], edit, 'set-edit')}
${{ last:'Edits the message it posted there before, so one alert goes from "down" to "back" instead of piling up. If someone deleted it, a new one goes out.', close:'Edits the message it posted before into its final state, then lets it go: the next alert starts a new message. With nothing to finish (a short blip never got a message), it stays quiet.', @@ -3348,7 +3674,7 @@ const Editor = { const field = /\{/.test(secs) ? `` : durInput('seconds-dur', (parseFloat(secs) || 30) * 1000, 's'); - return `
Does
${seg(['arm', 'cut_after', 'cut', 'activate', 'disarm'].map(x => [x, DELAY_ACTIONS[x]]), a.params.action, 'set-action')} + return `
Does
${segHtml(['arm', 'cut_after', 'cut', 'activate', 'disarm'].map(x => [x, DELAY_ACTIONS[x]]), a.params.action, 'set-action')} ${a.params.action === 'arm' ? `
${field}
` : ''}
${{ arm:'Arms this delay, or changes it while one is armed or on air. It goes on air once the buffer is full if Auto-activate is on; otherwise press Activate.', @@ -3362,15 +3688,18 @@ const Editor = { case 'scene': return sceneHtml(t, '', this.d); case 'tune': { const tune = tuneSummary(t); - return `
${esc(tune.k)}
${paramsHtml(t, '')}
Each moment keeps its own numbers: a phone push can wait longer than the Discord card.
`; + const amount = (eventOf(t.event).params || []).some(p => p.name.startsWith('min_')); + return `
${esc(tune.k)}
${paramsHtml(t, '')}
${amount + ? 'Smaller ones skip this moment. 1 is any amount.' + : 'Each moment keeps its own numbers: a phone push can wait longer than the Discord card.'}
`; } case 'key': return shortcutHtml(t); case 'quiet': return quietHtml(d); case 'ping': - return `
Ping
${seg([['', 'Nobody'], ['here', '@here'], ['everyone', '@everyone']], step.params.ping || '', 'set-ping')} + return `
Ping
${segHtml([['', 'Nobody'], ['here', '@here'], ['everyone', '@everyone']], step.params.ping || '', 'set-ping')}
${step.params.edit === 'last' ? 'Only when it posts a new message: an update never pings. ' : ''}Text from chat or a web service can never ping anyone: only this option can.
`; case 'send': - return `
When to send
${seg([['now', 'Right away'], ['w10000', '10 s'], ['w20000', '20 s'], ['w30000', '30 s'], ['w60000', '1 min'], ['aired', 'Once viewers see it']], + return `
When to send
${segHtml([['now', 'Right away'], ['w10000', '10 s'], ['w20000', '20 s'], ['w30000', '30 s'], ['w60000', '1 min'], ['aired', 'Once viewers see it']], this.sendValue(h).id, 'set-send')}
${t.type === 'event' && t.event === 'hold_opened' ? 'Waiting a bit means a 3-second blip never reaches anyone.' : '"Once viewers see it" waits out your delay, so the moment has reached them.'}
`; case 'only': @@ -3387,11 +3716,11 @@ const Editor = { case 'cmd': return `${commandClashHtml(d)}
-
${seg([['0', 'no limit'], ['10000', '10 s'], ['30000', '30 s'], ['60000', '1 min']], String(t.user_cooldown_ms || 0), 'set-ucd')}
`; +
${segHtml([['0', 'no limit'], ['10000', '10 s'], ['30000', '30 s'], ['60000', '1 min']], String(t.user_cooldown_ms || 0), 'set-ucd')}
`; case 'who': return `
Who can use it
${rolesHtml(t, 'role')}
You can always use your own commands.
`; case 'reply': - return `
Reply as
${seg([['', 'Your account'], ['bot', 'Bot account']], step.params.as || '', 'set-as')} + return `
Reply as
${segHtml([['', 'Your account'], ['bot', 'Bot account']], step.params.as || '', 'set-as')} ${t.type.startsWith('chat') ? `` : ''} ${S.data.twitch.bot.login ? '' : '
No bot account yet: connect one, or messages go out as you.
'}`; case 'light': { @@ -3401,8 +3730,11 @@ const Editor = {
${(S.data.catalog.light_starts || []).filter(x => x.id !== 'wled').map(x => ``).join('')}
` : ''; return lightPickerHtml(light ? light.params : {}) + web; } + case 'dgroup': + return `
+
The dock's Events show one group at a time: lines with the same group go together.
`; case 'priority': - return `
Priority
${seg([['', 'Normal'], ['high', 'High'], ['urgent', 'Urgent']], step.params.priority || '', 'set-priority')}`; + return `
Priority
${segHtml([['', 'Normal'], ['high', 'High'], ['urgent', 'Urgent']], step.params.priority || '', 'set-priority')}`; case 'url': { const http = firstStep(h, 'http'), chatty = t.type.startsWith('chat'); if (d.preset === 'on_air_light') return `` + this.lightHtml(http, t); @@ -3418,7 +3750,7 @@ const Editor = {
Point an OBS text source at this file to show it on stream.
`; case 'cooldown': - return `
Repeat at most
${seg([['0', 'Anytime'], ['10000', 'Every 10 s'], ['30000', '30 s'], ['60000', '1 min'], ['300000', '5 min'], ['600000', '10 min']], String(d.cooldown_ms || 0), 'set-cooldown')} + return `
Repeat at most
${segHtml([['0', 'Anytime'], ['10000', 'Every 10 s'], ['30000', '30 s'], ['60000', '1 min'], ['300000', '5 min'], ['600000', '10 min']], String(d.cooldown_ms || 0), 'set-cooldown')}
${durInput('cooldown-dur', d.cooldown_ms, 's')}
For every moment here, each counting on its own. A button pressed too soon shows as skipped in Recent activity.
`; } @@ -3446,12 +3778,15 @@ const Editor = {
What each state sends is in the preview: pick a moment (live, delayed…) and open "What it sends".
`; }, input(el){ - const h = this.handler(), step = primaryStep(h), t = h.trigger; + const h = this.handler(), step = this.stepOf(h), t = h.trigger; const b = el.dataset.bind; if (b === 'name') this.d.name = el.value; else if (b === 'enabled') this.d.enabled = el.checked; else if (b === 'text' && step){ step.params[KINDS[step.type].text] = el.value; autosize(el); } else if (b === 'title' && step) step.params.title = el.value; + else if (b === 'message' && step) step.params.message = el.value; + else if (b === 'dgroup' && step) step.params.group = el.value; + else if (b === 'tier-from'){ if (!this.tierFrom(h, el)) return; } else if (b === 'filter'){ t.filters = t.filters || {}; t.filters[el.dataset.name] = el.value.trim(); } else if (b === 'command') t.command = el.value.trim().toLowerCase(); else if (b === 'aliases') t.aliases = el.value.split(/[\s,]+/).map(x => x.trim().toLowerCase()).filter(Boolean); @@ -3524,30 +3859,42 @@ const Editor = { }); this.changed(); }, + // Whether it still shows `step`: an upload or a design picked a moment + // ago lands only there. + editing(step){ return Modal.kind === 'editor' && this.stepOf(this.handler()) === step; }, act(a, el){ - const h = this.handler(), step = primaryStep(h), v = el.dataset.v; + const h = this.handler(), step = this.stepOf(h), v = el.dataset.v; if (rulesAct(h.trigger, a, el) || sceneAct(h.trigger, a, el)){ this.changed(); return true; } + if (step && alertAct(a, el, step, h, this)) return true; switch (a){ case 'set-style': if (step) step.params.style = v; this.changed(); return true; - case 'set-color': if (step) step.params.color = v; this.changed(); return true; case 'set-ts': if (step) step.params.timestamp = step.params.timestamp === 'yes' ? '' : 'yes'; this.changed(); return true; - case 'pick-sound': - if (!step) return true; - step.params.sound = v; + case 'tier-pick': + this.tier = +el.dataset.n; + this.render(); + return true; + case 'tier-add': { + const chain = alertTiers(h); + // One more is a check on the amount and its own alert. + if (!chain || chain.tiers.length >= MAX_TIERS || !stepsFit(this.d, 2)) return true; + const last = chain.tiers[chain.tiers.length - 1]; + // A copy of the biggest so far, from a bigger amount: then its own + // design, sound and words. + chain.tiers.push({from:last.from ? last.from * 5 : TIER_FIRST[chain.name], step:clone(last.step)}); + setTiers(h, chain); + this.tier = chain.tiers.length - 1; this.changed(); - if (v){ playAlertSound(alertCardOf(step, h)); showPlaying('pick-sound', v); } return true; - case 'pick-picture': if (step) step.params.image = v; this.changed(); return true; - case 'media-upload': - uploadMedia(el.dataset.kind).then(ref => { - if (!ref || Modal.kind !== 'editor' || primaryStep(this.handler()) !== step) return; - step.params[el.dataset.kind === 'sound' ? 'sound' : 'image'] = ref; - this.changed(); - if (el.dataset.kind === 'sound') playAlertSound(alertCardOf(step, h)); - }); + } + case 'tier-del': { + const chain = alertTiers(h), n = +el.dataset.n; + if (!chain || !n) return true; + chain.tiers.splice(n, 1); + setTiers(h, chain); + if (this.tier >= n) this.tier = Math.max(0, this.tier - 1); + this.changed(); return true; - case 'alert-replay': if (step) this.syncStage(step, h, 'full'); return true; - case 'set-seconds': if (step) step.params.seconds = v; this.changed(); return true; + } case 'alert-all': { if (!step) return true; const names = el.dataset.names.split(' '); @@ -3562,8 +3909,7 @@ const Editor = { toast(`Done for ${plural(n, 'other alert')}. Save to keep it.`, 'ok'); return true; } - case 'sound-play': if (step) playAlertSound(alertCardOf(step, h)); return true; - case 'alert-show': if (step) showAlertNow(step, h, el); return true; + case 'f-add': { if (!step) return true; this.fields(step, list => list.push({name:'', value:'', inline:true})); @@ -3594,6 +3940,7 @@ const Editor = { } case 'moment': this.sel = +el.dataset.n; + this.tier = 0; this.testValues = {}; this.run = null; if (this.chip === '__test') this.chip = null; @@ -3740,6 +4087,8 @@ function stepMissing(s){ if (s.type === 'viewer' && !/^[A-Za-z0-9_]{1,32}$/.test((p.value || '').trim())) return 'name the value: letters, digits and _'; if (s.type === 'call' && !(p.block || '').trim()) return 'pick the block'; if (s.type === 'reward' && !(p.title || '').trim()) return 'pick the reward'; + if (s.type === 'goal' && !(p.id || '').trim()) return `pick the ${p.what === 'timer' ? 'timer' : 'goal'}`; + if (s.type === 'channel' && !(p.title || '').trim() && !(p.category || '').trim()) return 'write the new title or category'; if (s.type === 'wait' && !/\{|^\d+$/.test((p.ms || '').trim())) return 'set how long to wait'; if ([p.scene, p.source, p.filter, p.hotkey, s.type === 'light' ? p.device : '', s.type === 'program' || s.type === 'file' ? p.path : ''].some(v => /\{/.test(v || ''))) return 'names here can\'t use values'; if (s.type === 'light'){ @@ -3754,7 +4103,7 @@ function stepMissing(s){ if (need === 'filter' && !(p.source || '').trim()) return 'pick the source or scene'; if (need && !(p[need] || '').trim()) return {scene:'pick the scene', source:'pick the source', filter:'pick the filter', hotkey:'pick the hotkey'}[need]; } - if ((s.type === 'chat' || s.type === 'phone' || s.type === 'overlay' || s.type === 'timeline') && !(p.text || '').trim()) return 'write what it says'; + if ((s.type === 'chat' || s.type === 'phone' || s.type === 'overlay' || s.type === 'timeline' || s.type === 'dock') && !(p.text || '').trim()) return 'write what it says'; if (s.type === 'discord' && p.style !== 'card' && !(p.text || '').trim()) return 'write the message'; return ''; } @@ -3901,6 +4250,12 @@ function tuneSummary(t){ const f = t.filters || {}; const val = name => { const p = params.find(x => x.name === name); return f[name] || (p ? p.default : ''); }; const bad = params.some(p => f[p.name] !== undefined && f[p.name] !== '' && !(+f[p.name] > 0)); + // A Twitch amount's minimum: 1 is any amount. + const least = params.find(p => p.name.startsWith('min_')); + if (least){ + const n = +val(least.name); + return {k:'Only from', v:n > 1 ? `${n} ${paramUnit(least)}` : 'any amount', bad}; + } switch (t.event){ case 'destination_still_down': return {k:'Fires after', v:`${val('after_s')} s down`, bad}; case 'destination_unstable': return {k:'Fires at', v:`${val('drops')} drops in ${val('within_min')} min`, bad}; @@ -4170,8 +4525,6 @@ function meterHtml(t, r){ ${plural(r.messages, 'message')} · normal ${r.ready ? r.normal : '?'} · ${plural(r.chatters, 'person').replace('persons', 'people')}${r.top_word ? ` · top word "${esc(r.top_word)}"` : ''}`; } function rulesHtml(t, bind){ - const seg = (items, cur, act) => `
${items.map(([id, label]) => - ``).join('')}${SEG_IND}
`; const win = Math.round((t.window_ms || 10000) / 1000); const wins = [5, 10, 15, 20, 30, 45, 60, 90, 120]; if (!wins.includes(win)) wins.push(win); @@ -4190,7 +4543,7 @@ function rulesHtml(t, bind){
`; }).join(''); return `
-
Fires when${seg([['all', 'all'], ['any', 'any']], t.match === 'any' ? 'any' : 'all', 'r-match')}of these are true, over the last +
Fires when${segHtml([['all', 'all'], ['any', 'any']], t.match === 'any' ? 'any' : 'all', 'r-match')}of these are true, over the last
${rows || '
No rules yet: add one.
'}
@@ -4321,12 +4674,19 @@ function paramsHtml(t, bind){ const params = (e && e.params) || []; if (!params.length) return ''; return `
${params.map(p => { - const unit = /second/i.test(p.label) ? 's' : /minute/i.test(p.label) ? 'min' : ''; + const unit = paramUnit(p); const label = p.label.replace(/\s*\(.*\)/, ''); return `
`; }).join('')}
`; } +// What a setting counts in, from its label: "Still down after (seconds)" +// is in s, "At least (bits)" in bits. +function paramUnit(p){ + if (/second/i.test(p.label)) return 's'; + if (/minute/i.test(p.label)) return 'min'; + return (p.label.match(/\(([^)]*)\)/) || [])[1] || ''; +} // Keep a threshold typed in a params field. False when it isn't a number // above 0 (the field says so). function paramInput(t, el){ @@ -4550,7 +4910,7 @@ const PALETTE = [ ['Send', [['discord', 'Discord'], ['chat', 'Twitch chat'], ['phone', 'Phone'], ['http', 'Web request']]], ['Flow', [['if', 'If / otherwise'], ['wait', 'Wait'], ['wait_delay', 'Wait for the delay'], ['wait_chat', 'Wait for chat'], ['call', 'Run a block'], ['stop', 'Stop']]], ['Viewers', [['viewer', 'Viewer value'], ['math', 'Maths']]], - ['Stream', [['overlay', 'Show on stream'], ['reward', 'Channel points reward'], ['delay_action', 'Delay action'], ['obs', 'OBS'], ['marker', 'VOD marker'], ['clip', 'Clip'], ['timeline', 'Timeline']]], + ['Stream', [['overlay', 'Show on stream'], ['dock', 'Dock event'], ['reward', 'Channel points reward'], ['channel', 'Stream title and category'], ['goal', 'Goal or timer'], ['delay_action', 'Delay action'], ['obs', 'OBS'], ['marker', 'VOD marker'], ['clip', 'Clip'], ['timeline', 'Timeline']]], ['Home', [['light', 'Smart light']]], ['Anything', [['edit_text', 'Edit text'], ['set_var', 'Remember a value'], ['counter', 'Counter'], ['program', 'Run a program'], ['file', 'Write a file']]], ]; @@ -4639,9 +4999,17 @@ function twitchEventStatusHtml(t){ const TWITCH_NOUNS = {twitch_follow:'follows', twitch_sub:'subs', twitch_resub:'resubs', twitch_gift:'gifted subs', twitch_cheer:'bits', twitch_raid:'raids', twitch_redeem:'channel point rewards', twitch_ad:'ad breaks'}; function twitchNouns(ids){ - const words = ids.map(id => TWITCH_NOUNS[id] || id); + return andList(ids.map(id => TWITCH_NOUNS[id] || id)); +} +// "a, b and c". +function andList(words){ return words.length > 1 ? words.slice(0, -1).join(', ') + ' and ' + words[words.length - 1] : words.join(''); } +// What the Twitch login can't do until it logs in again (a login from +// before InstantClone asked for it), in words. +function twitchMissing(){ + return andList(S.data.twitch.main.missing || []); +} // The Twitch events, by id, in this state. function twitchEventsIn(state){ const subs = ((S.data.twitch.events || {}).subs) || {}; @@ -4680,6 +5048,7 @@ const Builder = { // Editor.open, {back} as in Modal.back (default: the catalog, if open). open(i, opts){ opts = opts || {}; + dropAddUndos(); this.back = opts.back !== undefined ? opts.back : Modal.kind === 'catalog' ? {title:'Back to the catalog', go:() => Catalog.reopen()} : null; TRIES.clear(); @@ -4765,7 +5134,11 @@ const Builder = { if (h.trigger.type === 'scene') ObsLive.watch(); } if (selected && selected.type === 'obs') ObsLive.watch(); + if (selected && selected.type === 'overlay') alertStage(selected, h, false); }, + // Whether it still shows `step`: an upload or a design picked a moment + // ago lands only there. + editing(step){ return Modal.kind === 'builder' && Array.isArray(this.sel) && this.has(this.sel) && this.stepAt(this.sel) === step; }, // Each trigger's tab label. Two alike get a number, so no two tabs read // the same and the one being edited is never in doubt. triggerTabs(){ @@ -4804,9 +5177,13 @@ const Builder = { const afterKey = this.after ? pathKey(this.after) : null; const tool = (act, path, icon, label, extra) => ``; + // A check on the alert's amount is one of its bigger ones (Bigger ones + // in the editor): it reads as amounts, not as Then and Otherwise. + const amount = tierVar(this.handler()); const out = (steps || []).map((s, n) => { const path = base.concat(n), pk = pathKey(path), id = uidOf(s); - const k = KINDS[s.type] || {label:s.type, tag:'?', c:'#888'}; + const tier = s.type === 'if' && amount && isTierCheck(s, amount) ? `${s.params.right} ${TIER_UNITS[amount]}` : ''; + const k = tier ? Object.assign({}, KINDS.if, {label:'Bigger alerts'}) : KINDS[s.type] || {label:s.type, tag:'?', c:'#888'}; const off = s.enabled === false; // Inside a switched-off check, nothing runs: nothing is missing. const missing = off || this.insideOff(path) ? '' : stepMissing(s); @@ -4815,7 +5192,7 @@ const Builder = { let html = `
+ ${svg(k.icon || 'steps')}${esc(k.label)}${tier ? `From ${esc(tier)} up, a bigger one plays` : cardSentence(s)}${off ? ', switched off' : ''}${s.type === 'overlay' ? alertMetaHtml(s) : ''} ${missing ? `${esc(missing)}` : unknown.length ? `${unknown.map(u => `{${esc(u.name)}}`).join(' ')}: nothing fills ${unknown.length > 1 ? 'these' : 'it'}` : ''} @@ -4827,8 +5204,8 @@ const Builder = {
`; if (s.type === 'if'){ html += `
-
${svg('check')}Then
${this.stepsHtml(s.then, path.concat('then'), id + 't')}
-
${svg('x')}Otherwise
${this.stepsHtml(s.else, path.concat('else'), id + 'e')}
+
${svg('check')}${tier ? 'Below ' + esc(tier) : 'Then'}
${this.stepsHtml(s.then, path.concat('then'), id + 't')}
+
${svg(tier ? 'up' : 'x')}${tier ? 'From ' + esc(tier) : 'Otherwise'}
${this.stepsHtml(s.else, path.concat('else'), id + 'e')}
`; } if (afterKey === pk){ @@ -4864,14 +5241,11 @@ const Builder = { const {list, index} = this.locate(path); return list[index]; }, - // Whether `count` more steps fit (64 in all) at a list `base` (checks - // nest 4 deep at most). Says why when they don't. // Whether `count` more steps, opening `levels` levels of lanes, fit in - // the list at `base`. + // the list at `base`: MAX_STEPS in all, checks 4 deep at most. Says why + // when they don't. roomFor(base, count, levels){ - let total = 0; - this.d.handlers.forEach(h => walk(h.steps, () => { total++; })); - if (total + count > 64){ toast('That\'s the most steps one integration can have (64)', 'info'); return false; } + if (!stepsFit(this.d, count)) return false; if (!nestFits(base, levels || 0)){ toast('Checks go 4 deep at most: add it a level up', 'info'); return false; } return true; }, @@ -5022,13 +5396,18 @@ const Builder = { + (edit === 'last' && s.params.ping ? note('Only when it posts a new message: an update never pings.') : '') : '') + note('<t:{hold_ends_at}:R> shows a live countdown in Discord.'); } + case 'dock': return field('text', 'Line', {ph:'{user} cheered {bits} bits'}) + + field('group', "Group, for the dock's filter", {ph:'Bits'}) + + field('replay', 'Replay', {select:[['', 'Plays the alert this run showed before it'], ['no', 'None']]}) + + note("Shows in every OBS dock with the Events widget on, newest first. It lasts until InstantClone restarts."); case 'timeline': return field('text', 'Line', {area:true, ph:'⭐ Highlight · [clip]({clip.url})'}) + field('kind', 'It is', {select:Object.entries(TIMELINE_KINDS)}) + field('at', 'It marks', {select:[['live', 'What you do now (lands after the delay)'], ['aired', 'What viewers see now']]}) + note('The timeline is this stream\'s, shared by every integration: {timeline} shows it, {chapters} lists the chapters for YouTube. A new stream starts a new one.') + note('A button or a scene is pressed live: "what you do now". A mod\'s chat command is about what they saw: "what viewers see now".'); case 'chat': return field('text', 'Message', {area:true}) + field('as', 'Send as', {select:[['', 'Your account'], ['bot', 'Bot account']]}) - + field('reply', 'Reply to the viewer', {select:[['', 'No'], ['yes', 'Yes, in their thread']]}); + + field('reply', 'Reply to the viewer', {select:[['', 'No'], ['yes', 'Yes, in their thread']]}) + + note('Start it with a Twitch command to run one: /timeout {user} 10m, /ban, /unban, /shoutout, /announce or /me. Your account runs it. What a viewer types can fill it in, never start one.'); case 'phone': return field('title', 'Title') + field('text', 'Message', {area:true}) + field('priority', 'Priority', {select:[['', 'Normal'], ['high', 'High'], ['urgent', 'Urgent']]}); case 'http': return field('method', 'Method', {select:['GET', 'POST', 'PUT', 'PATCH', 'DELETE'].map(m => [m, m])}) + field('url', 'Address', {mono:true, secret:true, ph:'https://api.example/rank/{arg1}'}) @@ -5090,6 +5469,24 @@ const Builder = { + note('Starts or changes the delay. A delay the buffer can\'t hold fails this step.') : '') + warnHtml('This changes your stream. Tests never run it.'); case 'marker': return field('description', 'Label', {ph:'Crash'}) + note('Only works while you are live on Twitch.'); + case 'channel': return field('title', 'New title (blank: leave it as it is)', {ph:'{args}'}) + + field('category', 'New category (blank: leave it as it is)', {ph:'{args}, or VALORANT'}) + + note('A category can be typed loosely: "valo" finds VALORANT. Later steps can use {stream.category} (its name on Twitch), {stream.ok} and, when it failed, {stream.error}.'); + case 'goal': { + const timer = s.params.what === 'timer', word = timer ? 'timer' : 'goal'; + const made = trackersOf(word).map(x => [x.id, x.name]); + // One that was deleted since still shows, so the step says what it lost. + const options = !s.params.id || made.some(([id]) => id === s.params.id) ? made : [[s.params.id, s.params.id + ' (deleted)']].concat(made); + const does = timer ? [['start', 'Start it'], ['pause', 'Pause it'], ['add', 'Add time'], ['subtract', 'Take time off'], ['set', 'Set the time left'], ['reset', 'Back to its start, stopped']] + : [['add', 'Add'], ['subtract', 'Subtract'], ['set', 'Set to'], ['reset', 'Reset to 0']]; + const takesAmount = ['add', 'subtract', 'set'].includes(s.params.action); + return field('what', 'Change', {select:[['', 'A goal'], ['timer', 'A timer']]}) + + (options.length ? field('id', timer ? 'Timer' : 'Goal', {select:[['', `Pick a ${word}`]].concat(options)}) + : note(`No ${word} yet. Make one in Overlay › Goals and timers.`)) + + field('action', 'Do', {select:does}) + + (takesAmount ? field('amount', timer ? 'Seconds, or a value like {arg1}' : 'How much, or a value like {arg1}', {mono:true, ph:timer ? '60' : '1'}) : '') + + note(`Any message can say where it stands: {${word}.${esc(s.params.id || 'name')}}${timer ? '' : `, and {goal.${esc(s.params.id || 'name')}.target}`}. Tests never change your goals and timers.`); + } case 'clip': return note('Clips the last moments of your stream. Later steps can use {clip.url}, {clip.ok} and, when it failed, {clip.error}.'); case 'program': return (localLocked() ? warnHtml(esc(LOCAL_LOCK_TEXT)) : '') + field('path', 'Program (a fixed path, no variables)', {mono:true, fixed:true, ph:'C:\\Tools\\thing.exe'}) + field('args', 'Arguments', {mono:true, ph:'--scene "Replay"'}) @@ -5103,16 +5500,24 @@ const Builder = { + field('keep', 'Keep it', {select:[['', 'For this run'], ['yes', 'Between runs and streams']]}) + note(s.params.keep === 'yes' ? `Later steps use it as ${MONO}{${esc(s.params.name || 'name')}}; any other run reads it as ${MONO}{saved.${esc(s.params.name || 'name')}}.` : 'Later steps use it as {name}. Lasts for this run.'); - case 'overlay': return field('title', 'Small title', {ph:'Clip'}) + field('text', 'Text', {area:true, ph:'{user} just clipped that'}) - + field('seconds', 'On screen for', {select:[['3', '3 s'], ['6', '6 s'], ['10', '10 s'], ['15', '15 s'], ['30', '30 s']] - .concat(['', '3', '6', '10', '15', '30'].includes(s.params.seconds || '') ? [] : [[s.params.seconds, s.params.seconds + ' s']])}) - + field('color', 'Accent, like #ff7eb6 (empty: the theme\'s)', {mono:true, fixed:true, ph:'the theme\'s'}) - + field('sound', 'Sound: theme, chime, pop, bell, rise, coin, sparkle, whoosh, fanfare, zap, blip, an upload (media:name) or a link', {mono:true, fixed:true, ph:'none'}) - + field('volume', 'Volume, 0 to 100', {mono:true, fixed:true, ph:'70'}) - + field('image', 'Picture: an upload (media:name) or a link to a PNG, JPG or GIF', {mono:true, fixed:true, ph:'https://…/party.gif'}) - + alertsStatusHtml(true) - + `` - + note('Tests never put anything on stream: "Show it on stream" does, with sample values.'); + case 'overlay': { + const p = s.params; + const seconds = ALERT_SECONDS.includes(p.seconds || '6') ? ALERT_SECONDS : ALERT_SECONDS.concat(p.seconds); + return `
+
ON STREAM · LIVE PREVIEW +
+
` + + field('title', 'Small title', {ph:'Clip'}) + field('text', 'Text', {area:true, ph:'{user} just clipped that'}) + + field('message', 'Viewer\'s message (optional)', {ph:'{message}'}) + + `
Design
${designTilesHtml(p.design)} +
Accent
${accentPickerHtml(s)} +
Sound
${soundControlsHtml(s)} +
Picture
${picturePickerHtml(p.image || '', 'pick-picture')} +
On screen for
${segHtml(seconds.map(n => [n, n + ' s']), p.seconds || '6', 'set-seconds')}` + + alertsStatusHtml(true) + + `` + + note('Tests never put anything on stream: "Show it on stream" does, with sample values.'); + } case 'edit_text': { const op = EDIT_OPS[s.params.op] || EDIT_OPS.first_line; const got = editTextSample(s, sampleMap(this.handler())); @@ -5331,10 +5736,14 @@ const Builder = { else if (b === 't-live') t.only_live = el.checked; else if (b === 't-param'){ if (!paramInput(t, el)) return; } else if (rulesInput(t, el, 't-') || sceneInput(t, el, 't-') || onlyLiveInput(t, el, 't-') || onlyLiveInput(t, el, '')){ /* changed below */ } + // An alert's volume slider and any-colour swatch. + else if ((b === 'volume' || b === 'color') && Array.isArray(this.sel)) this.stepAt(this.sel).params[b] = el.value.trim(); else if (b === 'p'){ const s = this.stepAt(this.sel); s.params[el.dataset.name] = el.value; if (el.dataset.name === 'action' || el.dataset.name === 'wait') nameAnswer(h, s); + // A goal and a timer don't share names or what can be done to them. + if (s.type === 'goal' && el.dataset.name === 'what') Object.assign(s.params, {id:'', action:'add', amount:el.value === 'timer' ? '60' : '1'}); } else if (b === 'p-dur'){ const ms = durationOf(0, 3600000); if (ms == null) return; this.stepAt(this.sel).params[el.dataset.name] = String(ms); } else if (b === 'bf-name' || b === 'bf-value'){ @@ -5372,9 +5781,10 @@ const Builder = { const d = this.d; if (rulesAct(this.handler().trigger, a, el) || sceneAct(this.handler().trigger, a, el)){ this.changed(); return true; } const selected = () => Array.isArray(this.sel) ? this.stepAt(this.sel) : null; + const alert = selected(); + if (alert && alert.type === 'overlay' && alertAct(a, el, alert, this.handler(), this)) return true; switch (a){ case 'b-color': { const s = selected(); if (s) s.params.color = el.dataset.v; this.changed(); return true; } - case 'alert-show': { const s = selected(); if (s) showAlertNow(s, this.handler(), el); return true; } case 'b-obs-pick': { const s = selected(); if (!s) return true; @@ -5770,11 +6180,12 @@ function newStep(kind, trigger){ wait:{ms:'10000'}, wait_delay:{}, if:{left:'', op:'is', right:''}, stop:{}, delay_action:{action:'cut'}, marker:{description:'Highlight'}, clip:{}, program:{path:'', args:''}, file:{path:'', text:'', mode:''}, set_var:{name:'', value:''}, counter:{name:'count', op:'', by:'1'}, - overlay:{title:'', text:'', seconds:'6'}, edit_text:{input:'', op:'first_line', a:'', b:'', save_as:'text'}, + overlay:{title:'', text:'', seconds:'6'}, dock:{text:'', group:''}, edit_text:{input:'', op:'first_line', a:'', b:'', save_as:'text'}, obs:{action:'scene', scene:'', source:'', text:'', filter:'', hotkey:''}, light:{device:'', name:'', plug:'', action:'color', color:'red', brightness:''}, viewer:{value:'coins', who:'', op:'', by:'10'}, math:{a:'', op:'multiply', b:'2', save_as:'result'}, - wait_chat:{text:'', from:'', seconds:'30', save_as:'reply'}, call:{block:''}, reward:{title:'', action:'off'}, + wait_chat:{text:'', from:'', seconds:'30', save_as:'reply'}, call:{block:''}, reward:{title:'', action:'off'}, channel:{title:'', category:''}, + goal:{what:'', id:(tracking().goals[0] || {}).id || '', action:'add', amount:'1'}, }[kind] || {}; return {type:kind, params:clone(p), then:[], else:[]}; } @@ -6087,6 +6498,7 @@ function stepSentence(s){ const q = v => `“${highlightVars(v || '')}”`; switch (s.type){ case 'discord': return `Discord ${p.style === 'card' ? 'card ' : ''}${esc(channelName(p.connection))} ${q(p.style === 'card' ? p.title || p.text : p.text)}${p.edit === 'last' ? ' · updates the same message' : p.edit === 'close' ? ' · updates it, then starts fresh' : p.ping ? ' · @' + esc(p.ping) : ''}`; + case 'dock': return `Dock ${p.group ? esc(p.group) + ' ' : ''}${q(p.text)}`; case 'timeline': return `Timeline ${esc((TIMELINE_KINDS[p.kind] || TIMELINE_KINDS.note).toLowerCase())} ${q(p.text)}`; case 'chat': return `Chat ${q(p.text)}${p.as === 'bot' ? ' · as bot' : ''}${p.reply === 'yes' ? ' · as a reply' : ''}`; case 'phone': return `Phone ${q(p.text)}`; @@ -6115,7 +6527,15 @@ function stepSentence(s){ case 'math': return `${highlightVars(p.a || '0')} ${esc((MATH_OPS[p.op || ''] || '').toLowerCase())} ${highlightVars(p.b || '0')} → {${esc((p.save_as || '').trim() || 'result')}}`; case 'wait_chat': return `Wait up to ${highlightVars(p.seconds || '30')} s for ${p.text ? q(p.text) : 'a message'} from ${{viewer:'the viewer', target:'who they named'}[p.from] || 'anyone'}`; case 'call': return `Run the block ${esc(p.block || '…')}`; + case 'goal': { + const timer = p.what === 'timer', found = trackersOf(timer ? 'timer' : 'goal').find(x => x.id === p.id); + const name = q(found ? found.name : p.id || '…'), n = q(p.amount || '1'); + if (timer) return {start:`Start the timer ${name}`, pause:`Pause the timer ${name}`, reset:`Reset the timer ${name}`, + subtract:`Take ${n} s off the timer ${name}`, set:`Set the timer ${name} to ${n} s`}[p.action] || `Add ${n} s to the timer ${name}`; + return {subtract:`Take ${n} from the goal ${name}`, set:`Set the goal ${name} to ${n}`, reset:`Reset the goal ${name}`}[p.action] || `Add ${n} to the goal ${name}`; + } case 'reward': return `Switch the reward ${q(p.title || '…')} ${p.action === 'off' ? 'off' : 'on'}`; + case 'channel': return [p.title && `Set the title to ${q(p.title)}`, p.category && `${p.title ? 'the' : 'Set the'} category to ${q(p.category)}`].filter(Boolean).join(', ') || 'Set the title or category'; case 'overlay': return `On stream ${q(p.text)}`; case 'edit_text': return `${esc((EDIT_OPS[p.op] || EDIT_OPS.first_line)[0])}: ${highlightVars(p.input || '…')} → {${esc((p.save_as || '').trim() || 'text')}}`; case 'counter': return `Counter ${esc(p.name || '…')} ${p.op === 'reset' ? 'reset' : p.op === 'set' ? '= ' + esc(p.by || '0') : (p.op === 'subtract' ? '-' : '+') + esc(p.by || '1')}`; @@ -6206,14 +6626,14 @@ const Conn = {
${cards}
`); if (this.tab === 'obs') ObsLive.watch(); }, - // The alerts page: in OBS or not, its look in a line, and the designer. + // The alerts page: in OBS or not, the design alerts play by default, and + // the way to the Alerts page, where every alert's look is. alertsHtml(){ - const a = S.data.alerts || {}, look = lookOf(), own = THEMES[themeOf(look)]; + const a = S.data.alerts || {}, look = lookOf(); const url = a.url || `${location.origin}/alerts`; - const font = look.font ? (look.font === 'system' ? 'this PC\'s font' : look.font) : own.font; return `
${alertsStatusHtml(false)}
- ${esc(own.name)} · ${esc(font)} · ${esc(soundLabel(look.sound || own.sound))}${look.css ? ' · custom CSS' : ''} + Alerts play ${esc(designLabel(''))} unless they have a design of their own
Add it to OBS by hand @@ -6258,7 +6678,11 @@ const Conn = { + `VOD markers and clips go to ${esc(t.main.login)}'s channel, and fail while it's offline. ` + 'To use them on the channel you stream to, disconnect and log in with that account.') : ''; - return (t.notice ? warnHtml(esc(t.notice)) : '') + other + const flow = t.login, logging = flow && flow.which === 'main'; + const missing = twitchMissing() ? `
⚠ + Your login is from before InstantClone asked for ${esc(twitchMissing())}: until you log in again, those fail. Same account; everything else keeps working. + ${logging ? '' : ''}
` : ''; + return (t.notice ? warnHtml(esc(t.notice)) : '') + other + missing + this.accountHtml('main', t.main, 'Your Twitch account', 'Log in once: InstantClone keeps it fresh.') + this.twitchEventsHtml(t) + this.accountHtml('bot', t.bot, 'Bot account (optional)', 'A second account that posts in chat instead of you.') @@ -6279,8 +6703,8 @@ const Conn = {
Twitch events
${hearing.length ? `
Listening for ${esc(twitchNouns(hearing))}
` : ''} ${ev.state === 'error' ? `
${esc(ev.problem)}
` : ''} - ${consent.length ? `
⚠ - Twitch needs your OK for ${esc(twitchNouns(consent))}. Your login is from before these events: Allow logs you in again with the same account, asking Twitch only to read them. + ${consent.length && !twitchMissing() ? `
⚠ + Twitch needs your OK for ${esc(twitchNouns(consent))}. Your login is from before these events: Allow logs you in again with the same account. ${logging ? '' : ''}
` : ''}
`; }, @@ -6425,11 +6849,46 @@ const Conn = { // ---------------------------------------------------------------- alerts page -// The alerts browser source: where "Show on stream" cards appear. How -// every card looks is the page's theme (S.data.alerts.look, edited in -// AlertsLook); each alert can bring its own accent, sound and picture. -// Every preview here is the real page (/alerts?demo) drawing a sample, so -// what you see is what OBS shows. +// The alerts browser source: where "Show on stream" cards appear. Each +// alert plays a design (an overlay made in the Studio): its own, or the +// default (S.data.alerts.look.design). The built-in designs are the +// themes' cards, as layers. Every preview here is the real page +// (/alerts?demo) drawing a sample, so what you see is what OBS shows. + +// The name of the design an alert plays: its own (`slug`), or the default +// when it has none or its own is gone. +function designLabel(slug){ + const d = Stage.designNamed(slug || '') || Stage.designNamed(lookOf().design || ''); + return d ? d.name : slug || lookOf().design || 'the default'; +} +// The designs an alert can play, as tiles: yours, then the ready-made ones +// in their card's colours. The one it plays (`slug`, or the default) is lit. +function designTilesHtml(slug){ + const cur = Stage.designNamed(slug || '') || Stage.designNamed(lookOf().design || ''); + const tile = d => { + const t = THEMES[d.theme], on = !!cur && cur.slug === d.slug; + return ``; + }; + const list = Stage.allDesigns(), mine = list.filter(d => !d.theme), ready = list.filter(d => d.theme); + return (mine.length ? `
Yours
${mine.map(tile).join('')}
` : '') + + `
Ready-made
${ready.map(tile).join('')}
`; +} +// A design's icon: a ready-made one's card colours (its words on its +// background, under a line of its accent), or layers for one of yours. +function designIconHtml(theme){ + const t = THEMES[theme]; + return t ? `Aa` + : `${svg('layers')}`; +} +// How alerts look is the Overlay tab's Alerts page: there from anywhere, +// leaving an editor only if it can be left. +function openAlertsPage(){ + if (modalOpen()){ Modal.close(); if (modalOpen()) return; } + showTab('overlay'); + showSubTab('overlay', 'alerts'); +} // Each theme as web/alerts.html draws it: its name, colors, opacity (%), // corners (px), font, sound, its own entrance and where a picture goes. @@ -6449,8 +6908,6 @@ const ALERT_ANIMS = [['slide', 'Slide'], ['pop', 'Pop'], ['fade', 'Fade'], ['zoo const ALERT_SOUNDS = [['chime', 'Chime'], ['pop', 'Pop'], ['bell', 'Bell'], ['rise', 'Rise'], ['coin', 'Coin'], ['sparkle', 'Sparkle'], ['whoosh', 'Whoosh'], ['fanfare', 'Fanfare'], ['zap', 'Zap'], ['blip', 'Blip']]; const ALERT_SECONDS = ['3', '6', '10', '15', '30']; -// What the theme designer's "Back to ... as it comes" clears. -const LOOK_STYLE = ['font', 'text', 'background', 'accent', 'opacity', 'radius', 'animation', 'picture']; // A link the alerts page can load as it is (alerts::is_web_link), and an // upload (alert_media's `media:`). const WEB_LINK = /^https?:\/\/[^\s"'<>\\`{}]+$/; @@ -6463,7 +6920,7 @@ function mediaOf(kind){ return ((S.data.alerts || {}).media || []).filter(m => m function soundLabel(sound){ if (!sound) return 'none'; - if (sound === 'theme') return 'theme sound'; + if (sound === 'theme') return 'the design\'s'; const found = ALERT_SOUNDS.find(([id]) => id === sound); if (found) return found[1].toLowerCase(); return MEDIA_REF.test(sound) ? sound.slice(6) : 'a link'; @@ -6474,7 +6931,7 @@ function alertColor(value, look){ return /^#[0-9a-f]{6}$/i.test(look.accent || '') ? look.accent : THEMES[themeOf(look)].accent; } function alertColorName(value){ - if (!value) return 'theme\'s'; + if (!value) return 'design\'s'; const found = CARD_COLORS.find(([c]) => c === value.toLowerCase()); return found ? found[1].toLowerCase() : value.toLowerCase(); } @@ -6488,8 +6945,8 @@ function alertsPill(){ } // Is the alerts page in OBS, with one click to put it there. `compact` -// is the editor's line under an alert's preview. -function alertsStatusHtml(compact){ +// is the editor's line under an alert's preview; `after` ends the line. +function alertsStatusHtml(compact, after = ''){ const a = S.data.alerts || {}; const inObs = a.in_obs > 0; const line = inObs ? 'Your alerts page is open in OBS.' @@ -6499,27 +6956,101 @@ function alertsStatusHtml(compact){ ? `` : compact ? '' : ''; return `
- ${line}${action}
`; + ${line}${action}${after}
`; } -// The first alert set up, with sample values: what previews show. -function sampleAlert(){ - for (const i of S.data.integrations){ - for (const h of i.handlers || []){ - const step = (h.steps || []).find(s => s.type === 'overlay' && s.enabled !== false); - if (step) return alertCardOf(step, h); - } +// An alert step as the page shows it, filled in with sample values. +// ---- An alert's look, picked: the editor's chips and the builder share +// these. A row of segments (one picked), as the editor's options use. +function segHtml(items, cur, act){ + return `
${items.map(([id, label]) => + ``).join('')}${SEG_IND}
`; +} +// The accent swatches: the design's own first, then the palette and any +// colour. +function accentPickerHtml(step){ + const mine = /^#[0-9a-f]{6}$/i.test(step.params.color || '') ? step.params.color.toLowerCase() : ''; + const shown = alertColor(mine, lookOf()); + const known = !mine || CARD_COLORS.some(([c]) => c === mine); + const swatch = (c, label, style) => ``; + const swatches = [swatch('', "The design's", alertColor('', lookOf()))].concat(CARD_COLORS.map(([c, label]) => swatch(c, label, c))).join(''); + return `
${swatches} +
`; +} +// The sounds to pick from, its volume, and Play. +function soundControlsHtml(step){ + const sound = step.params.sound || ''; + const volume = step.params.volume === undefined || step.params.volume === '' ? 70 : +step.params.volume; + return `${soundPickerHtml(sound, 'pick-sound', true)} +
+ ${volume}% +
`; +} +// The alert on the real page, in the page's look but in the middle of +// the box, so a short stage shows it whole. `replay` 'full' plays it +// coming in and going out (alerts.html showDemo). +function alertStage(step, h, replay){ + const frame = q('.ig-ed-stage .ig-stage-frame'); + if (!frame) return; + const look = Object.assign({}, lookOf(), {position:'middle', align:'center'}); + stageShow(frame, frame.parentElement.clientWidth / 0.72, {look, card:alertCardOf(step, h), replay, sound:!!replay}); +} +// A click on those pieces, for the editor and the builder alike: `step` is +// the alert it changes; `owner` redraws (changed) and says whether it +// still shows that step (editing), as an upload or a design takes a moment. +function alertAct(a, el, step, h, owner){ + const v = el.dataset.v; + switch (a){ + case 'set-color': step.params.color = v; owner.changed(); return true; + case 'pick-sound': + step.params.sound = v; + owner.changed(); + if (v){ playAlertSound(alertCardOf(step, h)); showPlaying('pick-sound', v); } + return true; + case 'pick-picture': step.params.image = v; owner.changed(); return true; + // A ready-made design is saved first; it plays in as it is picked. + case 'pick-design': + Stage.ensureDesign(el.dataset.slug).then(ok => { + if (!ok || !owner.editing(step)) return; + step.params.design = el.dataset.slug; + owner.changed(); + alertStage(step, h, 'full'); + }); + return true; + case 'media-upload': + uploadMedia(el.dataset.kind).then(ref => { + if (!ref || !owner.editing(step)) return; + step.params[el.dataset.kind === 'sound' ? 'sound' : 'image'] = ref; + owner.changed(); + if (el.dataset.kind === 'sound') playAlertSound(alertCardOf(step, h)); + }); + return true; + case 'set-seconds': step.params.seconds = v; owner.changed(); return true; + case 'sound-play': playAlertSound(alertCardOf(step, h)); return true; + case 'alert-replay': alertStage(step, h, 'full'); return true; + case 'alert-show': showAlertNow(step, h, el); return true; } - return {title:'New follower', text:'Nat', sound:'theme', image:''}; + return false; +} + +// What an alert step's card says under its words: its design, its own +// accent (the design's needs no dot) and its sound. +function alertMetaHtml(step){ + const p = step.params; + const dot = /^#[0-9a-f]{6}$/i.test(p.color || '') ? `` : ''; + return `${dot}${esc(designLabel(p.design))} · ${esc(soundLabel(p.sound))}`; } -// An alert step as the page shows it, filled in with sample values. function alertCardOf(step, h){ const p = step.params, vars = sampleMap(h); - return {title:renderSample(p.title || '', vars), text:renderSample(p.text || '', vars), seconds:p.seconds || '6', - color:p.color || '', sound:p.sound || '', volume:p.volume || '', image:p.image || ''}; + return {title:renderSample(p.title || '', vars), text:renderSample(p.text || '', vars), message:renderSample(p.message || '', vars), seconds:p.seconds || '6', + color:p.color || '', sound:p.sound || '', volume:p.volume || '', image:p.image || '', design:p.design || ''}; } +// The canvas overlays are laid out for: the Overlay tab shows each one at +// this size, scaled into its preview, as OBS does. +const OVERLAY_CANVAS = 1920; // Fit a real alerts page (`/alerts?demo`, laid out `canvas` px wide) into // its box and show it `payload` ({look, card, replay, sound}); a frame // still loading gets it once it has. Kept as properties, not attributes: @@ -6559,17 +7090,6 @@ function showPlaying(act, v){ b.icPlaying = setTimeout(() => b.classList.remove('ig-playing'), 1200); } -// The font picker's samples: just "Aa" in each font, one small request. -function loadFontSamples(){ - if (document.getElementById('ig-font-samples')) return; - const link = document.createElement('link'); - link.id = 'ig-font-samples'; - link.rel = 'stylesheet'; - const families = ALERT_FONTS.map(f => 'family=' + encodeURIComponent(f).replace(/%20/g, '+')).join('&'); - link.href = `https://fonts.googleapis.com/css2?${families}&text=Aa&display=swap`; - document.head.appendChild(link); -} - // Play a sound as the alerts page would, from a hidden page of its own. function playAlertSound(card, look){ let frame = document.getElementById('ig-sound-frame'); @@ -6589,7 +7109,7 @@ function playAlertSound(card, look){ if (frame.icReady) stagePost(frame, frame.icPayload); } -// A sound to pick: for an alert, none or the theme's; for the theme, its +// A sound to pick: for an alert, none or its design's; for the theme, its // own. Then the built-in ones, the uploads (each with its delete), one // more upload, and a link. Picking one plays it. function soundPickerHtml(current, act, forAlert){ @@ -6597,7 +7117,7 @@ function soundPickerHtml(current, act, forAlert){ const opt = (id, label) => ``; const items = forAlert - ? [opt('', 'None'), opt('theme', `Theme sound (${soundLabel(look.sound || own.sound)})`)] + ? [opt('', 'None'), opt('theme', 'The design\'s sound')] : [opt('', `${own.name}'s own (${soundLabel(own.sound)})`)]; ALERT_SOUNDS.forEach(([id, label]) => items.push(opt(id, label))); mediaOf('sound').forEach(m => items.push(`${opt('media:' + m.name, m.name)}${mediaDelHtml(m.name)}`)); @@ -6684,6 +7204,14 @@ async function showAlertNow(step, h, btn){ }); } +// A browser source for an overlay, in the scene on air, in one click. +async function addOverlayToObs(btn, slug, name){ + await busy(btn, async () => { + const r = await api('/integrations/overlays/add-to-obs', {slug, name}); + const ok = r && r.ok !== false; + toast(ok ? 'Added to the scene on air in OBS' : (r && r.error) || 'OBS didn\'t add it', ok ? 'ok' : 'err', 6000); + }); +} async function addAlertsToObs(btn){ await busy(btn, async () => { const r = await api('/integrations/alerts/add-to-obs', {}); @@ -6694,228 +7222,6 @@ async function addAlertsToObs(btn){ }); } -// ---------------------------------------------------------------- alerts look - -// The theme designer: the real page on a stage, the themes to start from, -// and every part of the look. Changes save on their own a moment later -// and OBS follows at once. -const AlertsLook = { - saveTimer:null, saving:false, - open(){ - const from = {editor:Editor, builder:Builder, connections:Conn}[Modal.kind] || null; - Modal.open('alerts', 1060); - Modal.onClose = () => { this.flush(); return true; }; - if (from) Modal.after = () => from.resume(); - this.render(); - }, - resume(){ this.open(); }, - look(){ return S.data.alerts.look; }, - render(){ - if (Modal.kind !== 'alerts') return; - const look = this.look(), theme = themeOf(look), own = THEMES[theme]; - const styled = LOOK_STYLE.some(k => look[k] !== '' && look[k] != null) || look.sound || look.css; - const section = (title, body) => `
${title}
${body}
`; - Modal.body(`${modalHead('screen', 'var(--accent)', 'Alerts look', 'How every alert looks and sounds on stream. OBS follows as you change it.')} -
-
-
-
- - -
- ${alertsStatusHtml(false)} -
-
- ${section('Theme', this.themesHtml(theme))} - ${section('Place', this.placeHtml(look))} - ${section('Font', this.fontsHtml(look, own))} - ${section('Colors', this.colorsHtml(look, own))} - ${section('Motion and picture', this.motionHtml(look, own))} - ${section('Theme sound', `
Alerts set to "Theme sound" play this.
${soundPickerHtml(look.sound || '', 'look-sound', false)}`)} -
Custom CSS (advanced) - -
Comes after the theme, so it wins. Parts: .card .pic .title .text (its words .w, letters .c) .time .fx; this theme is body.theme-${theme}.
- -
-
-
- ${this.saving ? 'Saving…' : 'Saved: OBS shows it now.'}
-
`); - this.sync(true); - }, - themesHtml(theme){ - return `
${Object.entries(THEMES).map(([id, t]) => - ``).join('')}
`; - }, - seg(name, items, cur){ - return `
${items.map(([id, label]) => - ``).join('')}${SEG_IND}
`; - }, - range(name, label, value, min, max, unit, set){ - return `
-
- ${value}${unit}
`; - }, - // Where alerts sit, picked on a little screen: nine spots, and the card - // slides to the one picked (index.html places it from data-x/data-y). - placeHtml(look){ - const y = look.position || 'bottom', x = look.align || 'center'; - const name = (py, px) => py === 'middle' && px === 'center' ? 'Middle' : `${py[0].toUpperCase()}${py.slice(1)} ${px}`; - const spots = ['top', 'middle', 'bottom'].flatMap(py => ['left', 'center', 'right'].map(px => { - const on = py === y && px === x; - return ``; - })).join(''); - return `
-
${spots} -
-
${name(y, x)}of the screen
- ${this.range('scale', 'Size', look.scale || 100, 50, 200, '%', false)}
`; - }, - // Each font as a tile written in itself (only "Aa", fetched by - // loadFontSamples), the theme's own first. - fontsHtml(look, own){ - loadFontSamples(); - const cur = look.font || ''; - const tile = (id, family, name, sub) => ``; - const family = f => `'${f}',system-ui,sans-serif`; - return `
${tile('', family(own.font), 'Theme\'s', `${own.name}'s own: ${own.font}`)} - ${ALERT_FONTS.map(f => tile(f, family(f), f)).join('')}${tile('system', 'system-ui,sans-serif', 'This PC\'s', 'The font this PC uses')}
`; - }, - colorsHtml(look, own){ - const color = (name, label, fallback) => { - const value = look[name] || fallback; - return `
- - ${label}${look[name] ? esc(value) : 'theme\'s'} - ${look[name] ? `` : ''}
`; - }; - return `
${color('text', 'Text', own.fg)}${color('background', 'Background', own.bg)}${color('accent', 'Accent', own.accent)}
-
${this.range('opacity', 'Background opacity', look.opacity == null ? own.op : look.opacity, 0, 100, '%', look.opacity != null)} - ${this.range('radius', 'Corners', look.radius == null ? own.r : look.radius, 0, 48, 'px', look.radius != null)}
`; - }, - motionHtml(look, own){ - const cur = look.animation || ''; - const anims = [['', `Theme's (${own.motion.toLowerCase()})`]].concat(ALERT_ANIMS).map(([id, label]) => - ``).join(''); - return `
${anims}
-
${this.seg('picture', [['', `Theme's (${own.pic === 'top' ? 'above' : 'left'})`], ['left', 'Left'], ['top', 'Above']], look.picture || '')}
`; - }, - // Show the look on the stage, and each theme on its tile (`tiles`: only - // when redrawn; a slider moving changes none of them). Pointing at a - // tile plays that theme's entrance. - sync(tiles){ - const card = sampleAlert(), stage = q('.ig-look-body .ig-stage-frame'); - stageShow(stage, 1280, {look:this.look(), card}); - if (!tiles) return; - Modal.form.querySelectorAll('.ig-theme-frame').forEach(f => { - stageShow(f, 760, {look:{theme:f.dataset.theme, position:'middle', align:'center', scale:170}, card}); - f.closest('.ig-theme').onpointerenter = () => stagePost(f, {...f.icPayload, replay:true}); - }); - // Pointing at an entrance plays it on the stage, nothing saved. - Modal.form.querySelectorAll('[data-act="look-set"][data-name="animation"]').forEach(b => { - b.onpointerenter = () => stagePost(stage, {look:{...this.look(), animation:b.dataset.v}, card, replay:true}); - }); - }, - input(el){ - const look = this.look(), b = el.dataset.bind; - const out = (name, text) => { const o = q(`[data-out="${name}"]`); if (o) o.textContent = text; }; - if (b === 'look-scale'){ look.scale = +el.value; out('scale', el.value + '%'); } - else if (b === 'look-opacity'){ look.opacity = +el.value; out('opacity', el.value + '%'); } - else if (b === 'look-radius'){ look.radius = +el.value; out('radius', el.value + 'px'); } - else if (b === 'look-color'){ - const name = el.dataset.name, first = !look[name]; - look[name] = el.value; - el.closest('.ig-colorf').style.setProperty('--sw', el.value); - out('c-' + name, el.value); - // The first change brings the chip its way back to the theme's. - if (first){ this.changed(true); return; } - } - else if (b === 'look-css') look.css = el.value; - else if (b === 'sound-link'){ - const link = el.value.trim(); - if (link && !WEB_LINK.test(link)) return; - look.sound = link; - } - else return; - this.changed(false); - }, - changed(redraw){ - this.saving = true; - clearTimeout(this.saveTimer); - this.saveTimer = setTimeout(() => this.flush(), 400); - this.savedLine(); - if (redraw) this.render(); else this.sync(false); - }, - savedLine(){ - const box = q('[data-look-saved]'); - if (!box) return; - box.classList.toggle('busy', this.saving); - box.querySelector('[data-look-msg]').textContent = this.saving ? 'Saving…' : 'Saved: OBS shows it now.'; - }, - async flush(){ - if (!this.saveTimer) return; - clearTimeout(this.saveTimer); - this.saveTimer = null; - const r = await api('/integrations/alerts/look', this.look()); - this.saving = false; - if (!r.ok){ toast(r.error || 'Couldn\'t save the look', 'err'); return; } - this.savedLine(); - }, - playSound(sound){ playAlertSound({sound:sound || 'theme', volume:70}, this.look()); }, - async act(a, el){ - const look = this.look(), v = el.dataset.v; - switch (a){ - case 'look-theme': - // A theme as it comes: the last one's colors and font would clash. - look.theme = v; - LOOK_STYLE.forEach(k => { look[k] = k === 'opacity' || k === 'radius' ? null : ''; }); - this.changed(true); - stagePost(q('.ig-look-body .ig-stage-frame'), {look, card:sampleAlert(), replay:'full', sound:true}); - return true; - case 'look-place': - look.position = el.dataset.y; - look.align = el.dataset.x; - this.changed(true); - return true; - case 'look-set': - look[el.dataset.name] = v; - this.changed(true); - // A new entrance is seen by playing it. - if (el.dataset.name === 'animation') stagePost(q('.ig-look-body .ig-stage-frame'), {look, card:sampleAlert(), replay:true}); - return true; - case 'look-clear': { - const name = el.dataset.name; - look[name] = name === 'opacity' || name === 'radius' ? null : ''; - this.changed(true); - return true; - } - case 'look-sound': look.sound = v; this.changed(true); this.playSound(v); showPlaying('look-sound', v); return true; - case 'look-replay': - stagePost(q('.ig-look-body .ig-stage-frame'), {look, card:sampleAlert(), replay:'full', sound:true}); - return true; - case 'look-reset': - if (!armConfirm(el, 'Clears your changes, CSS too. Sure?')) return true; - LOOK_STYLE.forEach(k => { look[k] = k === 'opacity' || k === 'radius' ? null : ''; }); - look.sound = ''; - look.css = ''; - this.changed(true); - return true; - case 'media-upload': - uploadMedia('sound').then(ref => { - if (!ref || Modal.kind !== 'alerts') return; - look.sound = ref; - this.changed(true); - this.playSound(ref); - }); - return true; - } - return false; - }, -}; - // System > Twitch & OBS, with the Twitch login app section open. function openTwitchAppSettings(){ Modal.close(true); @@ -7154,7 +7460,7 @@ const Values = { else if (b === 'v-label'){ v.label = el.value; // Until a name is typed, it follows the label: "!daily gives" is daily_gives. - if (!v.named && !this.usedIn(v.name)) v.name = el.value.toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '').slice(0, 32); + if (!v.named && !this.usedIn(v.name)) v.name = snakeName(el.value, 32); } this.render(); }, @@ -7273,9 +7579,9 @@ const Recipes = {
+ It becomes an alert design, played by every alert not given another. You can change it any time on the Alerts page. ${al.media.length ? `
Comes with ${names(al.media)}: saved with your uploads.
` : ''} - ${al.left_out.length ? warnHtml(`Too big to come along: ${names(al.left_out)}. Those alerts play the theme sound, or show no picture, until you pick another.`) : ''} + ${al.left_out.length ? warnHtml(`Too big to come along: ${names(al.left_out)}. Those alerts play their design's sound, or show no picture, until you pick another.`) : ''}
`; }, // Their look on the stage, with their first alert. @@ -7311,7 +7617,9 @@ const Recipes = { if (!r.ok){ toast(r.error, 'err', 6000); return; } if (Modal.kind === 'import') Modal.close(true); await load(); - this.landed((r.ids || []).map(find).filter(Boolean), r.added); + const added = (r.ids || []).map(find).filter(Boolean); + await saveNamedDesigns(added); + this.landed(added, r.added); }); return true; } @@ -7377,16 +7685,597 @@ const Recipes = { // ---------------------------------------------------------------- events -const OWNERS = {editor:Editor, builder:Builder, connections:Conn, alerts:AlertsLook, import:Recipes, share:Recipes, catalog:Catalog, values:Values}; +// ---------------------------------------------------------------- on stream + +// The Overlay tab's Alerts and Goals pages. That tab is where everything +// viewers see is managed, but what these two show is integrations data, so +// they are drawn here and share this file's editors, modal and polling. + +// What a goal counts by itself (goals.rs `Counts`): how it reads and looks. +const COUNTS = { + subs: {label:'Subs', icon:'star', c:'#a78bfa', what:'Counts new subs, resubs and gifted subs'}, + follows: {label:'Follows', icon:'heart', c:'#5ac8fa', what:'Counts follows'}, + bits: {label:'Bits', icon:'gem', c:'#fcd34d', what:'Counts the bits viewers cheer'}, + own: {label:'Your own', icon:'target', c:'#86efac', what:'Moves only when a step or you change it'}, +}; +const TIMER_COLOR = '#fda4af'; +// The longest a timer holds (goals.rs `MAX_TIMER_S`), in ms. +const MAX_TIMER_MS = 30 * 24 * 3600 * 1000; +// How many of each there can be (goals.rs `MAX_GOALS`, `MAX_TIMERS`). +const TRACKER_MAX = {goal:16, timer:8}; +// What a new goal or timer can start from: [kind, name, what it is, fields]. +const TRACKER_STARTERS = [ + ['goal', 'Sub goal', 'A bar that fills with every sub.', {counts:'subs', target:50}], + ['goal', 'Follower goal', 'Counts follows toward a number.', {counts:'follows', target:100}], + ['goal', 'Bit goal', 'Fills with the bits viewers cheer.', {counts:'bits', target:5000}], + ['goal', 'Your own goal', 'Anything else: wins, deaths, a prize pot.', {counts:'own', target:10}], + ['timer', 'Subathon', 'A countdown that subs and bits make longer.', {start_s:3600, per_sub_s:300, per_100_bits_s:60}], + ['timer', 'Countdown', 'Starting soon, a break, a challenge.', {start_s:300}], +]; + +function tracking(){ return (S.data && S.data.tracking) || {goals:[], timers:[]}; } +function trackersOf(what){ return what === 'goal' ? tracking().goals : tracking().timers; } +function timerLeft(t){ return t.running ? Math.max(0, t.ends_at_ms - Date.now()) : t.left_ms; } +// How full a goal is, 0 to 1. +function goalShare(g){ return Math.min(1, g.value / (g.target || 1)); } +// A countdown as a clock: 4:05, or 1:02:09 from an hour up. Rounded up, so +// it reads 0:00 only once it is over (goals.rs `clock_text`). +function clockText(ms){ + const total = Math.ceil(ms / 1000), h = Math.floor(total / 3600), m = Math.floor(total / 60) % 60, sec = total % 60; + return `${h ? h + ':' + String(m).padStart(2, '0') : m}:${String(sec).padStart(2, '0')}`; +} +function countText(n){ return Number(n || 0).toLocaleString('en-US'); } +function showsAlerts(i){ return allSteps(i).some(s => s.type === 'overlay'); } +// The overlay a goal or timer gets when it is first put on stream. +function trackerSlug(what, id){ return `${what}-${id}`; } +// Goals and timers as variables, each with where it stands now. +function trackerValues(){ + const t = tracking(); + return t.goals.flatMap(g => [{name:`goal.${g.id}`, sample:String(g.value)}, {name:`goal.${g.id}.target`, sample:String(g.target)}]) + .concat(t.timers.map(x => ({name:`timer.${x.id}`, sample:clockText(timerLeft(x))}))); +} + +const Stage = { + // The saved overlays' slugs, to know which goals have their own. + slugs:null, + clockT:0, + // The goal or timer being edited: {what, isNew, d, was, value}. + form:null, + + // The Studio's list, which it fetches: one list for both. + async loadSlugs(){ + const list = typeof Studio !== 'undefined' ? await Studio.loadSaved() : []; + this.slugs = new Set(list.map(o => o.slug)); + }, + render(){ + const root = $(S.stage === 'alerts' ? 'ov-alerts' : 'ov-goals'); + if (!root || !S.data) return; + if (S.stage === 'alerts') morph(root, this.sourceHtml()); + else { + morph(root, this.goalsHtml()); + const frame = root.querySelector('.og-tframe'); + if (frame) stageShow(frame, OVERLAY_CANVAS, null); + this.tick(); + } + if (typeof fitPaneCard === 'function') fitPaneCard(); + }, + + // ---- the pages' shared parts + // The details panel's head: an icon in its colour, a name, what it is, + // and anything after (a button). + detailHeadHtml(icon, color, title, kind, after = ''){ + return `
${svg(icon)} +
${esc(title)}
${esc(kind)}
${after}
`; + }, + // Where OBS finds it: the path (it fits), and Copy URL for the address. + urlBoxHtml(url, hint = ''){ + const path = url.replace(/^https?:\/\/[^/]+/, ''); + return `
Browser source
+
${esc(path).replace('/overlay/', '/overlay/')} +
${hint}
`; + }, + + // ---- alerts + // The Overlay tab's Alerts page is the Studio's gallery of alert designs + // (index.html); this draws the bar above it: the one browser source in + // OBS that plays every alert. Which design an alert plays is its step's + // (the Design chip). The designs are kept here for both. + designs:[], saved:[], + async loadDesigns(){ + const list = typeof Studio !== 'undefined' ? await Studio.loadSaved() : []; + this.saved = list; + this.designs = list.filter(o => o.kind === 'alerts'); + await this.ensureDefault(); + }, + // Every design an alert can take: the ones saved, then the built-in ones + // (the themes' cards) not saved yet, which are made when first taken. + // A built-in one keeps its theme once saved, to be shown as ready-made. + allDesigns(){ + const themes = window.ICOverlay ? window.ICOverlay.ALERT_THEMES : []; + const saved = this.designs.map(d => ({slug:d.slug, name:d.name || d.slug, theme:themes.find(t => d.slug === 'alert-' + t) || ''})); + const built = themes.map(t => ({slug:'alert-' + t, name:THEMES[t].name, theme:t, builtin:true})); + return saved.concat(built.filter(b => !saved.some(d => d.slug === b.slug))); + }, + designNamed(slug){ return this.allDesigns().find(d => d.slug === slug) || null; }, + // A built-in design is written to the overlays folder the first time it + // is taken: from then on it is a file like any other, to edit. + async ensureDesign(slug){ + // The folder as it is now: one saved since (and changed) is never + // written over. + await this.loadDesigns(); + const d = this.designNamed(slug); + if (!d || !d.builtin) return true; + const doc = window.ICOverlay.alertPreset(d.theme); + return this.writeDesign(slug, doc); + }, + async writeDesign(slug, doc){ + let r = null; + try { + const sent = await fetch('/overlays/' + encodeURIComponent(slug), {method:'POST', headers:{'Content-Type':'text/html'}, body:window.ICOverlay.bake(doc)}); + r = await sent.json(); + } catch(_){} + if (!r || !r.ok){ toast((r && r.error) || 'Could not make that design', 'err', 6000); return false; } + await this.loadDesigns(); + return true; + }, + async setDefault(slug){ + const look = Object.assign({}, lookOf(), {design:slug}); + const r = await api('/integrations/alerts/look', look); + if (!r || r.ok === false){ toast((r && r.error) || 'That didn\'t save', 'err', 6000); return false; } + S.data.alerts = Object.assign({}, S.data.alerts, {look}); + return true; + }, + // Alerts from before designs had the alerts page's look: the first visit + // makes that look a design ("My alerts"), the default that every alert + // without one of its own plays. A look never changed is the built-in + // Island's. + async ensureDefault(){ + if (!S.data || this.defaulting || !window.ICOverlay) return; + const look = lookOf(); + if (look.design) return; + this.defaulting = true; + try { + const kept = ['font', 'text', 'background', 'accent', 'opacity', 'radius', 'animation', 'picture', 'css', 'scale', 'position', 'align']; + const changed = themeOf(look) !== 'island' || !!look.sound || kept.some(k => look[k] != null && look[k] !== '' + && !(k === 'scale' && +look[k] === 100) && !(k === 'position' && look[k] === 'bottom') && !(k === 'align' && look[k] === 'center')); + // Anyone with alerts keeps the look they had, in its theme's layers; + // a new install starts from the ready-made Island. + const mine = changed || this.alerts().length > 0; + let slug = 'alert-island'; + if (mine){ + slug = 'my-alerts'; + for (let n = 2; this.saved.some(o => o.slug === slug); n++) slug = 'my-alerts-' + n; + const was = {theme:themeOf(look), sound:look.sound}; + kept.forEach(k => { if (look[k] != null && look[k] !== '') was[k] = look[k]; }); + const doc = window.ICOverlay.alertLookDoc(was) || this.cardDoc(was); + doc.name = 'My alerts'; + if (!(await this.writeDesign(slug, doc))) return; + } else if (!(await this.ensureDesign(slug))) return; + if (await this.setDefault(slug) && mine) toast('Your alerts look is now a design, "My alerts": edit it like any overlay', 'ok', 7000); + } finally { this.defaulting = false; } + }, + // A look with custom CSS, written for the old card's markup: that card, + // as one Alert card layer where the page put it. + cardDoc(look){ + const doc = window.ICOverlay.alertCardDoc(look.theme), card = doc.widgets[0]; + if (look.sound) doc.sound = look.sound; + card.card = Object.assign({}, look); + delete card.card.sound; + const v = look.position === 'top' ? 'top' : look.position === 'middle' ? 'center' : 'bottom'; + const side = look.align === 'left' || look.align === 'right' ? look.align : ''; + card.anchor = v === 'center' ? side || 'center' : side ? v + '-' + side : v; + card.offset = {x:side ? 36 : -560, y:v === 'center' ? -180 : 36}; + return doc; + }, + alerts(){ + const out = []; + S.data.integrations.filter(showsAlerts).forEach(i => (i.handlers || []).forEach((h, n) => { + let step = null; + walk(h.steps, s => { if (!step && s.type === 'overlay') step = s; }); + if (step) out.push({i, h, step, key:`${i.id}:${n}`}); + })); + return out; + }, + // The design an alert plays: its own, or the default when it has none or + // its own is gone (as the alerts page does). + designOf(x){ return this.designNamed(x.step.params.design || '') || this.designNamed(lookOf().design || ''); }, + // The alerts that play a design (`names`, what starts each), of how many; + // whether it is the default (the one alerts added later get), every + // alert's too; and the default's name. + designUse(slug){ + if (!S.data) return null; + const all = this.alerts(), mine = all.filter(x => (this.designOf(x) || {}).slug === slug); + const isDefault = lookOf().design === slug; + // `own`: alerts given another design of their own in Integrations. + const own = all.filter(x => x.step.params.design && x.step.params.design !== slug).length; + return {names:mine.map(x => triggerSentence(x.h.trigger)), total:all.length, isDefault, own, + everywhere:isDefault && mine.length === all.length, defaultName:designLabel('')}; + }, + // A deleted design: the default, gone, is the ready-made Island again (its + // alerts play the default already). + async designGone(slug){ + await this.loadDesigns(); + if (lookOf().design === slug && await this.ensureDesign('alert-island')) await this.setDefault('alert-island'); + }, + // The one browser source that plays every alert: in OBS or not, and its + // address to copy. + sourceHtml(){ + const a = S.data.alerts || {}, url = a.url || `${location.origin}/alerts`; + return alertsStatusHtml(true, ` + ${esc(url.replace(/^https?:\/\/[^/]+/, ''))} + `); + }, + // One design for every alert: the default (what alerts added later play) + // becomes it, and the alerts' own designs are let go. + async useEverywhere(slug){ + if (!(await this.ensureDesign(slug)) || !(await this.setDefault(slug))) return false; + // Each integration's own designs go, kept if its save fails. + const own = new Map(); + this.alerts().forEach(a => { if (a.step.params.design) own.set(a.i, (own.get(a.i) || []).concat(a.step)); }); + for (const [i, steps] of own){ + const was = steps.map(s => s.params.design); + steps.forEach(s => { delete s.params.design; }); + const r = await api('/integrations/save', i); + if (!r || r.ok === false){ + steps.forEach((s, n) => { s.params.design = was[n]; }); + toast((r && r.error) || 'That didn\'t save', 'err', 6000); + return false; + } + } + toast('Every alert plays it now, and alerts you add later too', 'ok'); + return true; + }, + + // ---- goals and timers + // Laid out as the other pages are: every goal and timer on the left, each + // still saying where it stands; the chosen one's own overlay, live, in the + // middle; and on the right, what moves it and what puts it on stream. + sel:null, + // [what, item] for each one, goals first. + trackers(){ + const t = tracking(); + return t.goals.map(g => ['goal', g]).concat(t.timers.map(x => ['timer', x])); + }, + // What it is: its icon, colour, and what it counts or gains time from. + trackerLook(what, x){ + if (what === 'goal'){ + const c = COUNTS[x.counts] || COUNTS.own; + return {icon:c.icon, color:c.c, line:c.what + (x.each_stream ? ', from 0 each stream' : '')}; + } + const adds = [[x.per_sub_s, 'a sub'], [x.per_100_bits_s, '100 bits'], [x.per_follow_s, 'a follow']].filter(([s]) => s > 0) + .map(([s, from]) => `${from} +${fmtDur(s * 1000)}`); + return {icon:'clock', color:TIMER_COLOR, line:adds.length ? capFirst(adds.join(' · ')) : 'From ' + fmtDur(x.start_s * 1000)}; + }, + // A timer's state: [status class, word]. + timerWord(x){ + const left = timerLeft(x); + return left <= 0 ? ['s-wait', 'Over'] : x.running ? ['s-live', 'Running'] : ['', 'Paused']; + }, + tileHtml(what, x, on){ + const look = this.trackerLook(what, x); + const meta = what === 'goal' + ? `${countText(x.value)} of ${countText(x.target)} + ` + : `${clockText(timerLeft(x))} · ${this.timerWord(x)[1]}`; + return ``; + }, + // Its own overlay as viewers see it (it reads the real numbers), or what + // putting it on stream will make. + viewHtml(what, x){ + const slug = trackerSlug(what, x.id), live = this.onStream(what, x); + const stage = live + ? `
` + : `
Not on stream yet + Put it on stream to make its overlay: ${what === 'goal' ? 'its bar, name and numbers' : 'its name and clock'}, as layers you can restyle. It shows here as viewers will see it.
`; + return `
+
Preview${live ? 'Live: real numbers' : ''}
+
${stage}
`; + }, + onStream(what, x){ return !!this.slugs && this.slugs.has(trackerSlug(what, x.id)); }, + // A button that adds `n` (or takes it away), saying `label`; and Reset. + nudgeHtml(n, label, aria, at){ + return ``; + }, + resetHtml(at){ return ``; }, + goalMeterHtml(g, at){ + const done = g.value >= g.target, share = goalShare(g); + const step = n => this.nudgeHtml(n, Math.abs(n), `${n < 0 ? 'Take' : 'Add'} ${Math.abs(n)} ${n < 0 ? 'from' : 'to'} ${esc(g.name)}`, at); + return `
+
${countText(g.value)}of ${countText(g.target)} + ${done ? 'Reached' : Math.floor(share * 100) + '%'}
+
+
+
${step(-1)}${step(1)}${g.counts === 'bits' ? step(100) : ''}${this.resetHtml(at)}
`; + }, + timerMeterHtml(x, at){ + const over = timerLeft(x) <= 0, running = x.running && !over, word = this.timerWord(x); + const minute = n => this.nudgeHtml(n * 60, '1 min', `${n < 0 ? 'Take a minute from' : 'Add a minute to'} ${esc(x.name)}`, at); + return `
+
${clockText(timerLeft(x))} + ${word[1]}
+
+ + ${minute(-1)}${minute(1)}${this.resetHtml(at)}
`; + }, + detailHtml(what, x){ + const look = this.trackerLook(what, x), at = `data-what="${what}" data-id="${esc(x.id)}"`; + const slug = trackerSlug(what, x.id), url = `${location.origin}/overlay/${encodeURIComponent(slug)}.html`; + const stream = this.onStream(what, x) + ? `
+ +
+ ${this.urlBoxHtml(url)}` + : `
`; + const hears = what === 'goal' ? x.counts !== 'own' : !!(x.per_sub_s || x.per_100_bits_s || x.per_follow_s); + const deaf = hears && !S.data.twitch.main.login + ? `
+ Follows, subs and bits come from Twitch, and no Twitch account is connected: it won't move by itself yet. +
` : ''; + return ``; + }, + startersHtml(){ + return TRACKER_STARTERS.map(([what, name, note, fields], n) => { + const look = this.trackerLook(what, fields); + return ``; + }).join(''); + }, + goalsHtml(){ + const t = tracking(); + if (!t.goals.length && !t.timers.length){ + return `
+
What is the stream working toward?
+
A goal fills up as viewers follow, sub or cheer. A timer counts down, and can gain time with every sub. Pick one to start from: you can change everything about it.
+
${this.startersHtml()}
`; + } + const all = this.trackers(); + const [what, cur] = all.find(([w, x]) => `${w}:${x.id}` === this.sel) || all[0]; + this.sel = `${what}:${cur.id}`; + const group = (kind, label) => { + const list = all.filter(([w]) => w === kind); + return list.length ? `
${label}
` + list.map(([w, x]) => this.tileHtml(w, x, x === cur)).join('') : ''; + }; + return `
+
+
+
Goals and timers${all.length}
+
${group('goal', 'Goals')}${group('timer', 'Timers')}
+ ${this.viewHtml(what, cur)} + ${this.detailHtml(what, cur)} +
+
Each one gets an overlay of its own when you put it on stream, made of layers you can restyle in the Studio. Any overlay can show them too (a Goal bar, a Timer, or {{goal.name}}), and integrations move them with a Goal or timer step.
`; + }, + // Running clocks count down by themselves: one wake-up a second, just + // after the second shown ends, and none while no clock runs. + tick(){ + clearTimeout(this.clockT); + if (S.stage !== 'goals' || document.hidden) return; + let next = 0; + document.querySelectorAll('#ov-goals .og-clock').forEach(el => { + const t = tracking().timers.find(x => x.id === el.dataset.id); + if (!t) return; + const left = timerLeft(t), text = clockText(left); + if (el.textContent !== text) el.textContent = text; + if (t.running && left > 0) next = Math.min(next || Infinity, left % 1000 + 25); + }); + if (next) this.clockT = setTimeout(() => this.tick(), next); + }, + + // ---- the editor + // A short name for `name` that no other goal (or timer) has. + freeId(what, name, mine){ + const base = snakeName(name, 28) || what; + const taken = id => id !== mine && trackersOf(what).some(x => x.id === id); + if (!taken(base)) return base; + for (let n = 2; ; n++) if (!taken(`${base}_${n}`)) return `${base}_${n}`; + }, + edit(what, id, starter){ + const found = trackersOf(what).find(x => x.id === id); + const fresh = what === 'goal' ? {id:'', name:'', counts:'subs', target:50, each_stream:false} + : {id:'', name:'', start_s:3600, per_sub_s:0, per_100_bits_s:0, per_follow_s:0, tiers:true, max_s:0}; + const d = found ? clone(found) : Object.assign(fresh, starter || {}); + if (!found) d.id = this.freeId(what, d.name); + this.form = {what, isNew:!found, d, was:JSON.stringify(d), value:found && what === 'goal' ? found.value : 0}; + this.form.valueWas = this.form.value; + Modal.open('tracker', 580); + Modal.onClose = () => !this.dirty() || (Modal.guard(), false); + this.renderForm(); + focusField('tr-name'); + }, + dirty(){ + const f = this.form; + return !!f && (JSON.stringify(f.d) !== f.was || f.value !== f.valueWas); + }, + goalFormHtml(d){ + const c = COUNTS[d.counts] || COUNTS.own; + return `
+
${Object.entries(COUNTS).map(([id, k]) => + ``).join('')}${SEG_IND}
+
${c.what}.${d.counts === 'own' ? ' Add a "Goal or timer" step to any integration to move it.' : ''}
+
+
+
+
+ `; + }, + timerFormHtml(d){ + const dur = (name, label, unit) => `
${durInput('tr-dur', d[name] * 1000, unit, ` data-name="${name}"`)}
`; + return `
${dur('start_s', 'Starts at', 'm')}${dur('max_s', 'Never holds more than', 'm')}
+
Write a length like 5m, 1h or 1h 30m. For no limit, leave the second one at 0s.
+
Viewers add time
+
${dur('per_sub_s', 'Each sub adds', 's')}${dur('per_100_bits_s', 'Every 100 bits add', 's')}${dur('per_follow_s', 'Each follow adds', 's')}
+
0s adds nothing. A timer that ran out stays over until you start it again.
+ `; + }, + renderForm(){ + if (Modal.kind !== 'tracker') return; + const f = this.form, d = f.d, goal = f.what === 'goal'; + const look = this.trackerLook(f.what, d); + const title = f.isNew ? (goal ? 'New goal' : 'New timer') : (goal ? 'Goal' : 'Timer'); + const hint = goal ? 'A number your stream works toward. A Goal bar widget draws it.' : 'A clock that counts down. A Timer widget draws it.'; + Modal.body(`${modalHead(look.icon, look.color, title, hint)} +
+
+
Shown on stream. In messages and steps it is {${f.what}.${esc(d.id)}}${f.isNew ? '' : ', which stays as it is'}.
+ ${goal ? this.goalFormHtml(d) : this.timerFormHtml(d)} +
+
${f.isNew ? '' : ''}
+ +
`); + }, + // Typing never redraws the form: a field keeps its caret, and marks + // itself when what it holds can't be used. + input(el){ + const f = this.form, b = el.dataset.bind; + if (!f) return; + const d = f.d, mark = ok => { el.setAttribute('aria-invalid', ok ? 'false' : 'true'); return ok; }; + if (b === 'tr-name'){ + d.name = el.value; + // Until it is saved once, its short name follows what it is called. + if (f.isNew) d.id = this.freeId(f.what, d.name); + const id = q('.og-id'); + if (id) id.textContent = `{${f.what}.${d.id}}`; + } else if (b === 'tr-target'){ + if (mark(/^[1-9]\d{0,8}$/.test(el.value.trim()))) d.target = +el.value; + } else if (b === 'tr-value'){ + if (mark(/^\d{1,9}$/.test(el.value.trim()))) f.value = +el.value; + } else if (b === 'tr-each') d.each_stream = el.checked; + else if (b === 'tr-tiers') d.tiers = el.checked; + else if (b === 'tr-dur'){ + const ms = durValue(el, el.dataset.name === 'start_s' ? 1000 : 0, MAX_TIMER_MS); + if (ms != null) d[el.dataset.name] = Math.round(ms / 1000); + } + }, + async save(remove){ + const f = this.form, key = f.what === 'goal' ? 'goals' : 'timers'; + // A redraw keeps what a number field holds, not its mark: each is read again. + if (!remove) Modal.form.querySelectorAll('[data-bind="tr-target"],[data-bind="tr-value"],[data-bind="tr-dur"]').forEach(el => this.input(el)); + const d = Object.assign({}, f.d, {name:f.d.name.trim()}); + if (!remove){ + if (!d.name){ toast('Give it a name', 'err'); focusField('tr-name'); return; } + if (Modal.form.querySelector('[aria-invalid="true"]')){ toast('One of the numbers isn\'t right yet', 'err'); return; } + } + const next = clone(tracking()), at = next[key].findIndex(x => x.id === d.id); + if (remove){ if (at >= 0) next[key].splice(at, 1); } + else if (at >= 0) next[key][at] = d; + else next[key].push(d); + let r = await api('/integrations/goals', {goals:next.goals, timers:next.timers}); + if (!r || r.ok === false){ toast((r && r.error) || 'That didn\'t save', 'err', 6000); return; } + if (!remove && f.what === 'goal' && f.value !== f.valueWas){ + r = await api('/integrations/goals/change', {what:'goal', id:d.id, action:'set', amount:f.value}); + if (!r || r.ok === false){ toast((r && r.error) || 'Saved, but its count didn\'t change', 'err', 6000); return; } + } + S.data.tracking = r.tracking; + if (!remove) this.sel = `${f.what}:${d.id}`; + // Its own overlay goes with it: there is nothing left for it to show. + const slug = trackerSlug(f.what, d.id); + if (remove && this.slugs && this.slugs.has(slug)){ + await api(`/overlays/${encodeURIComponent(slug)}/delete`, {}); + await this.loadSlugs(); + } + this.form = null; + Modal.close(true); + this.render(); + toast(remove ? `Deleted ${d.name}` : f.isNew ? `${d.name} is ready: put it on stream when you like` : 'Saved', 'ok', 4500); + }, + async change(body){ + const r = await api('/integrations/goals/change', body); + if (!r || r.ok === false){ toast((r && r.error) || 'That didn\'t work', 'err'); return; } + S.data.tracking = r.tracking; + this.render(); + }, + // Make the overlay that shows this one goal or timer: a file like any + // other in the gallery, with a link for OBS, restyled in the Studio. + async putOnStream(what, id){ + const IC = window.ICOverlay, item = trackersOf(what).find(x => x.id === id); + if (!IC || !item) return; + // Made already (the list wasn't in yet): it keeps the look it was given. + const slug = trackerSlug(what, id); + await this.loadSlugs(); + if (this.slugs.has(slug)){ this.render(); return; } + const page = IC.bake(IC.trackerDoc(what === 'goal' ? 'Goal' : 'Timer', id, item.name)); + let r = null; + try { + const sent = await fetch('/overlays/' + encodeURIComponent(slug), {method:'POST', headers:{'Content-Type':'text/html'}, body:page}); + r = await sent.json(); + } catch(_){} + if (!r || !r.ok){ toast((r && r.error) || 'Could not make its overlay', 'err', 6000); return; } + await this.loadSlugs(); + this.render(); + toast(`${item.name} has its overlay: add it to OBS, or copy its link`, 'ok', 5000); + }, + async act(a, el){ + const what = el.dataset.what, id = el.dataset.id; + switch (a){ + case 'tr-new': { + const [kind, name, , fields] = TRACKER_STARTERS[+el.dataset.n]; + // Said before the form, not after it was filled in. + if (trackersOf(kind).length >= TRACKER_MAX[kind]){ + toast(`That's the most ${kind}s there can be (${TRACKER_MAX[kind]}): delete one first`, 'info', 5000); + return true; + } + this.edit(kind, '', Object.assign({name}, fields)); + return true; + } + case 'tr-edit': this.edit(what, id); return true; + case 'tr-pick': this.sel = `${what}:${id}`; this.render(); return true; + case 'tr-counts': this.form.d.counts = el.dataset.v; this.renderForm(); return true; + case 'tr-change': + if (el.dataset.do === 'reset' && !armConfirm(el, what === 'goal' ? 'Back to 0?' : 'Start over?')) return true; + await busy(el, () => this.change({what, id, action:el.dataset.do, amount:+el.dataset.n || 0})); + return true; + case 'tr-show': await busy(el, () => this.putOnStream(what, id)); return true; + case 'tr-obs': await addOverlayToObs(el, el.dataset.slug, el.dataset.name); return true; + // The Studio opens over this page, and goes back to it. + case 'tr-look': + if (typeof Studio !== 'undefined') Studio.openStudio(el.dataset.slug, 'goals'); + return true; + case 'tr-del': { + const has = this.slugs && this.slugs.has(trackerSlug(this.form.what, this.form.d.id)); + if (!armConfirm(el, has ? 'Delete it and its overlay?' : 'Delete it?')) return true; + await busy(el, () => this.save(true)); + return true; + } + case 'save': await busy(el, () => this.save(false)); return true; + } + return false; + }, +}; + +function wireStage(){ + ['ov-alerts', 'ov-goals'].forEach(id => { + const root = $(id); + if (!root || root.dataset.wired) return; + root.dataset.wired = '1'; + root.addEventListener('click', onClick); + root.addEventListener('keydown', onActivateKey); + root.addEventListener('input', e => { if (e.target.type === 'range') fillRange(e.target); }); + }); +} + +const OWNERS = {editor:Editor, builder:Builder, connections:Conn, import:Recipes, share:Recipes, catalog:Catalog, values:Values, tracker:Stage}; async function onClick(e){ const el = e.target.closest('[data-act]'); if (!el || el.disabled) return; const a = el.dataset.act; if (el.tagName === 'A') e.preventDefault(); - if (el.closest('.ig-menu') && a !== 'menu-delete') Menu.close(); + if (el.closest('.ig-menu') && a !== 'menu-delete' && a !== 'menu-folder') Menu.close(); const owner = el.closest('.ig-modal') && OWNERS[Modal.kind]; if (owner && await owner.act(a, el)) return; + if (el.closest('.ov-stage') && await Stage.act(a, el)) return; switch (a){ case 'modal-close': Modal.close(); break; case 'modal-back': Modal.goBack(); break; @@ -7401,6 +8290,32 @@ async function onClick(e){ remove(el.dataset.id); break; case 'share-one': Recipes.openShare(el.dataset.id); break; + case 'menu-folder': Menu.folders(el.dataset.id); break; + case 'set-folder': moveToFolder([el.dataset.id], el.dataset.v); break; + case 'fold': { + const f = el.dataset.folder; + if (!S.folded.delete(f)) S.folded.add(f); + store.set('ig-folded', JSON.stringify([...S.folded])); + render(); + break; + } + case 'folder-rename': renameNow(el.dataset.folder); break; + case 'folder-new': { + const name = newFolderName(); + S.pending.push(name); + renameNow(name); + break; + } + // Its integrations stay, out of any folder. + case 'folder-remove': { + const f = el.dataset.folder; + const ids = S.data.integrations.filter(i => i.folder === f).map(i => i.id); + S.pending = S.pending.filter(x => x !== f); + if (S.folded.delete(f)) store.set('ig-folded', JSON.stringify([...S.folded])); + if (!ids.length || await moveToFolder(ids, '')) toast(ids.length ? `Removed ${f}: what was in it stays` : `Removed ${f}`, 'ok'); + render(); + break; + } case 'reveal': { const id = el.dataset.field, show = !S.revealed.has(id); if (show) S.revealed.add(id); else S.revealed.delete(id); @@ -7510,7 +8425,7 @@ async function onClick(e){ }); break; case 'alerts-add': addAlertsToObs(el); break; - case 'alerts-open': AlertsLook.open(); break; + case 'alerts-open': openAlertsPage(); break; case 'media-del': deleteMedia(el); break; case 'alert-test': busy(el, async () => { @@ -7560,12 +8475,56 @@ function onInput(e){ if (!el.dataset || !el.dataset.bind || !Modal.el || !Modal.el.contains(el)) return; if (Modal.kind === 'editor') Editor.input(el); else if (Modal.kind === 'builder') Builder.input(el); - else if (Modal.kind === 'alerts') AlertsLook.input(el); else if (Modal.kind === 'values') Values.input(el); + else if (Modal.kind === 'tracker') Stage.input(el); // Pasted or dropped, not typed: typing would preview half a recipe. else if (Modal.kind === 'import' && el.dataset.bind === 'r-text' && /^insertFrom(Paste|Drop)$/.test(e.inputType || '')) Recipes.pasted(el); } +// ---- Dragging an integration to a folder: onto a folder's heading or its +// group, onto "Not in a folder", or onto the new folder zone that shows +// while dragging. +function onDragStart(e){ + const card = e.target.closest && e.target.closest('[data-drag-id]'); + if (!card) return; + S.dragging = card.dataset.dragId; + e.dataTransfer.effectAllowed = 'move'; + e.dataTransfer.setData('text/plain', S.dragging); + card.classList.add('ig-dragged'); + // After the browser took its picture of the card. + requestAnimationFrame(() => { const root = $('ig-root'); if (root && S.dragging) root.classList.add('ig-dragging'); }); +} +// The folder under the pointer, lit, when the card can go there. +function dropTarget(e){ + const at = e.target.closest && e.target.closest('[data-drop-folder]'); + const i = S.dragging && find(S.dragging); + return at && i && (at.hasAttribute('data-drop-new') || at.dataset.dropFolder !== (i.folder || '')) ? at : null; +} +function onDragOver(e){ + const at = dropTarget(e); + document.querySelectorAll('#ig-root .drop-on').forEach(x => { if (x !== at) x.classList.remove('drop-on'); }); + if (!at) return; + e.preventDefault(); + e.dataTransfer.dropEffect = 'move'; + at.classList.add('drop-on'); +} +async function onDrop(e){ + const at = dropTarget(e), id = S.dragging; + if (!at) return; + e.preventDefault(); + onDragEnd(); + if (!at.hasAttribute('data-drop-new')){ moveToFolder([id], at.dataset.dropFolder); return; } + const name = newFolderName(); + if (await moveToFolder([id], name)) renameNow(name); +} +function onDragEnd(){ + S.dragging = null; + const root = $('ig-root'); + if (root) root.classList.remove('ig-dragging'); + document.querySelectorAll('#ig-root .drop-on, #ig-root .ig-dragged').forEach(x => x.classList.remove('drop-on', 'ig-dragged')); + if (S.redraw){ S.redraw = false; render(); } +} + // Editors normalise some fields (commands lowercased, numbers clamped) // without touching the field being typed in; once it is left, show the // value that was kept. The last redraw rendered it into the attribute. @@ -7581,6 +8540,12 @@ function onFieldLeave(e){ // Enter or Space on a clickable card acts like a click; arrow keys move // through a group of radio buttons (the card colors) and pick. function onActivateKey(e){ + if (e.target.matches('.ig-folder-in') && (e.key === 'Enter' || e.key === 'Escape')){ + e.preventDefault(); + if (e.key === 'Enter') renameFolder(e.target.dataset.folder, e.target.value); + else { S.renaming = null; render(); } + return; + } // Enter in a list's find box picks the first match, or the typed name. if (e.key === 'Enter' && e.target.matches('[data-bind="pick-find"]')){ e.preventDefault(); @@ -7632,7 +8597,8 @@ document.addEventListener('focusin', e => { }); // A hidden page has nobody to show activity to; catch up when it's back. document.addEventListener('visibilitychange', () => { - if (!document.hidden && (S.visible || modalOpen())) poll(); + if (!document.hidden && (S.visible || S.stage || modalOpen())) poll(); + if (!document.hidden) Stage.tick(); }); function wireRoot(){ @@ -7642,6 +8608,14 @@ function wireRoot(){ root.addEventListener('click', onClick); root.addEventListener('input', onInput); root.addEventListener('keydown', onActivateKey); + root.addEventListener('dragstart', onDragStart); + root.addEventListener('dragover', onDragOver); + root.addEventListener('drop', onDrop); + root.addEventListener('dragend', onDragEnd); + // A folder's new name is kept once its field is left, as with Enter. + root.addEventListener('focusout', e => { + if (e.target.matches('.ig-folder-in') && S.renaming === e.target.dataset.folder) renameFolder(S.renaming, e.target.value); + }); root.addEventListener('toggle', e => { if (e.target.id === 'ig-activity-box') S.activityOpen = e.target.open; }, true); } @@ -7664,16 +8638,74 @@ window.Integrations = { }, hide(){ S.visible = false; - if (!modalOpen()) clearInterval(S.pollTimer); + if (!modalOpen() && !S.stage) clearInterval(S.pollTimer); + }, + // The Overlay tab showing its Alerts or its Goals page (`sub`), or + // neither any more. + async stage(sub){ + S.stage = sub === 'alerts' || sub === 'goals' ? sub : null; + if (!S.stage){ + clearTimeout(Stage.clockT); + if (!S.visible && !modalOpen()) clearInterval(S.pollTimer); + return; + } + wireStage(); + // Which goals have an overlay is the gallery's to say: ask each visit. + if (S.stage === 'goals') Stage.loadSlugs().then(() => { if (S.stage === 'goals') Stage.render(); }); + if (S.data) Stage.render(); + else if (!(await load())){ + const root = $(S.stage === 'alerts' ? 'ov-alerts' : 'ov-goals'); + if (root) root.innerHTML = '
⚠The app did not answer. Is InstantClone still running?
'; + } + // (Once the data is in: the default design is part of the alerts' look, + // and the gallery says which alerts play each design.) + if (S.stage === 'alerts' && S.data) Stage.loadDesigns().then(() => { + if (S.stage !== 'alerts') return; + Stage.render(); + if (typeof Studio !== 'undefined') Studio.alertsChanged(); + }); + if (S.stage) schedulePoll(); }, - // From the Overlay tab: where alerts go on stream. - async openAlertsPage(){ + // A new goal or timer, from the Overlay tab's own button. + async newTracker(what){ + if (!S.data && !(await load())) return; + Stage.edit(what, ''); + }, + // For the Studio's pickers: your goals and timers, read once if need be. + async tracking(){ + if (!S.data) await load(); + return tracking(); + }, + addOverlayToObs, + // The Studio's Alert card layer: the themes, fonts and entrances it has. + alertKit:{themes:THEMES, fonts:ALERT_FONTS, anims:ALERT_ANIMS, sounds:ALERT_SOUNDS}, + // The Overlay tab's Alerts page, the Studio's gallery of alert designs: + // which alerts play one, one for every alert, a ready-made one made a + // file, a deleted one let go, and a design's icon. + designUse:slug => Stage.designUse(slug), + useDesignEverywhere:slug => Stage.useEverywhere(slug), + ensureDesign:slug => Stage.ensureDesign(slug), + designGone:slug => Stage.designGone(slug), + designIcon:designIconHtml, + // The Studio's sound for a design: your uploads, one more, heard first. + soundUploads:() => S.data ? mediaOf('sound').map(m => ['media:' + m.name, m.name]) : [], + uploadSound:() => uploadMedia('sound'), + playSound:sound => playAlertSound({title:'', text:'', sound, volume:'70'}), + // The Alerts page with no alerts yet: Twitch alerts, opened to look at. + async addTwitchAlerts(){ if (!S.data && !(await load())) return; - Conn.open('stream'); + showTab('integrations'); + await addFrom({preset:'twitch_alerts'}, true); }, }; // A reload or a restored tab can land straight on Integrations. const pane = document.querySelector('.tab-pane[data-pane="integrations"]'); if (pane && !pane.hidden) window.Integrations.show(); +// Or on the Overlay tab, with one of this file's pages open. +const overlayPane = document.querySelector('.tab-pane[data-pane="overlay"]'); +if (overlayPane && !overlayPane.hidden){ + const open = overlayPane.querySelector('.sub-tab.on'); + window.Integrations.stage(open ? open.dataset.subtab : null); +} })(); diff --git a/web/overlay-runtime.js b/web/overlay-runtime.js index ae56d4a..2771fab 100644 --- a/web/overlay-runtime.js +++ b/web/overlay-runtime.js @@ -19,7 +19,7 @@ smoothness, then re-bakes on commit. */ 'use strict'; -(function (root) { +(function icOverlayModule(root) { // ---- Enumerations surfaced to the Studio UI ---- @@ -53,8 +53,20 @@ { key: 'glow', label: 'Glow', loop: true }, { key: 'flash', label: 'Flash', loop: false }, { key: 'shake', label: 'Shake', loop: false }, - { key: 'glitch', label: 'Glitch', loop: false } + { key: 'glitch', label: 'Glitch', loop: false }, + // The alerts themes' motions, for any layer: drawn on from the left, + // opened like a shutter, grown from a dot, stamped down, boinged out. + { key: 'wipe', label: 'Wipe on', loop: false }, + { key: 'shutter', label: 'Shutter', loop: false }, + { key: 'grow', label: 'Grow from a dot', loop: false }, + { key: 'stamp', label: 'Stamp', loop: false }, + { key: 'boing', label: 'Boing', loop: false }, + // Words only: scrambled letters that settle, or typed one at a time. + { key: 'decode', label: 'Decode', loop: false, words: true }, + { key: 'typewriter', label: 'Typewriter', loop: false, words: true } ]; + // The layers with words those two can change. + const WORD_KINDS = ['StatePill', 'Text', 'Banner']; const EASINGS = [ { key: 'linear', css: 'linear' }, @@ -88,6 +100,11 @@ { key: 'DestinationStatus', label: 'Destinations', group: 'data', fields: [] }, { key: 'CutCounter', label: 'Cut counter', group: 'data', fields: ['font', 'text'] }, { key: 'SessionElapsed', label: 'Session timer', group: 'data', fields: ['font'] }, + // Goals and timers (the app's goals.rs): which one a widget shows is its + // `goal` / `timer`, blank for the first. A goal bar resizes by its box, + // a timer by its font, like the other text widgets. + { key: 'Goal', label: 'Goal bar', group: 'stream', fields: ['goal'] }, + { key: 'Timer', label: 'Timer', group: 'stream', fields: ['timer', 'font'] }, { key: 'Text', label: 'Text', group: 'deco', fields: ['font', 'text'] }, { key: 'Icon', label: 'Icon', group: 'deco', fields: ['icon'] }, { key: 'Shape', label: 'Shape', group: 'deco', fields: ['shape'] }, @@ -100,11 +117,43 @@ { key: 'BackgroundTint', label: 'Background wash', group: 'ambient', fields: [], full: true, cost: 'mid' }, { key: 'LiquidFill', label: 'Liquid fill', group: 'ambient', fields: [], full: true, cost: 'mid' }, { key: 'Image', label: 'Image', group: 'media', fields: ['src'] }, + { key: 'AlertCard', label: 'Alert card', group: 'alert', fields: ['card'] }, { key: 'CustomHTML', label: 'Custom HTML', group: 'power', fields: ['custom'], cost: 'opt' } ]; const ICONS = ['bolt', 'eye', 'star', 'clock', 'shield', 'dot', 'live', 'wifi', 'alert', 'play']; - const SHAPES = ['rect', 'circle', 'pill', 'line']; + + // Fonts a text widget can use, from Google Fonts (as the alerts page offers + // them), each with the weights it has: a one-weight font asks for none. + const FONTS = { + 'Inter': 'wght@400;500;600;700;800', 'Poppins': 'wght@400;600;700;800', 'Montserrat': 'wght@400;600;700;800', + 'Fredoka': 'wght@400;600;700', 'Rubik': 'wght@400;600;700;800', 'Bebas Neue': '', 'Oswald': 'wght@400;600;700', + 'Bangers': '', 'Orbitron': 'wght@400;600;700;800', 'Press Start 2P': '', 'Permanent Marker': '', + 'Roboto Mono': 'wght@400;600;700', 'Chakra Petch': 'wght@400;500;600;700', 'Playfair Display': 'wght@400;700', + 'Special Elite': '' + }; + // The PC's own font (Segoe UI on Windows), which loads from nowhere. + const SYSTEM_FONT = 'system'; + function fontLink(families) { + if (!families.length) return ''; + const q = families.map(function (name) { + return 'family=' + name.split(' ').join('+') + (FONTS[name] ? ':' + FONTS[name] : ''); + }).join('&'); + return '\n'; + } + // A colour as the page can use it: a theme token (@live) is the theme's. + function themed(c, theme) { + if (typeof c !== 'string' || c.charAt(0) !== '@') return c; + return Object.assign(defaultTheme(), theme || {})[c.slice(1)] || c; + } + // `#rrggbb` at `alpha`, for backgrounds that let the scene show through. + function rgba(hex, alpha) { + const m = /^#([0-9a-f]{6})$/i.exec(hex || ''); + if (!m) return hex || 'transparent'; + const n = parseInt(m[1], 16); + return 'rgba(' + (n >> 16) + ',' + ((n >> 8) & 255) + ',' + (n & 255) + ',' + r(Math.max(0, Math.min(1, alpha))) + ')'; + } + const SHAPES = ['rect', 'card', 'circle', 'pill', 'line']; // ---- Defaults ---- @@ -113,7 +162,9 @@ const SIZES = { Icon: { w: 48, h: 48 }, PulsingDot: { w: 16, h: 16 }, BufferRing: { w: 120, h: 120 }, Heartbeat: { w: 110, h: 110 }, BitrateMeter: { w: 180, h: 36 }, BufferBar: { w: 180, h: 10 }, - Shape: { w: 120, h: 60 }, Divider: { w: 160, h: 2 }, Image: { w: 200, h: 200 }, CustomHTML: { w: 320, h: 120 } + Shape: { w: 120, h: 60 }, Divider: { w: 160, h: 2 }, Image: { w: 200, h: 200 }, CustomHTML: { w: 320, h: 120 }, + AlertCard: { w: 1120, h: 360 }, + Goal: { w: 460, h: 14 } }; const w = { id: id, @@ -135,9 +186,299 @@ // The meter smooths its line by default so a steady signal reads calm // (0 = raw/jittery, ~1 = glassy). Tunable in the Studio. if (kind === 'BitrateMeter') { w.smooth = 0.6; } + // A goal or a timer is about the stream, not the delay: it shows in + // every state, and doesn't fade in again each time the delay changes. + if (kind === 'Goal' || kind === 'Timer') { + w.states = {}; + w.base.anim = { style: 'instant', duration_ms: 400, easing: 'ease-out', delay_ms: 0 }; + w.goal = ''; w.timer = ''; w.always = true; + } + if (kind === 'Timer') { w.font_px = 72; w.end = ''; w.base.color = '@ink'; } + // An alert card comes in and goes its theme's way, by itself: the layer + // itself just stays (see ALERT_THEMES). + if (kind === 'AlertCard') { + w.card = { theme: 'island' }; w.always = true; w.states = {}; + w.base.anim = { style: 'instant', duration_ms: 400, easing: 'ease-out', delay_ms: 0 }; + } + // A goal bar fills from the accent into green, with a light passing over. + if (kind === 'Goal') Object.assign(w.base, { color: '@accent', grad: '@live', grad_anim: 'shine', grad_speed: 4.5, grad_angle: 90 }); return w; } + // How a part of a goal or timer overlay arrives when the overlay opens (a + // scene switch in OBS): one after another, `n` steps in. It plays once: + // the same in every state, so the delay changing never replays it. + // (At the shared strength: a tuned one bakes keyframes per state, and a + // new name in each state would replay the entrance at every change.) + function arrive(n) { return { style: 'slide-up', duration_ms: 560, easing: 'spring', delay_ms: n * 90 }; } + // The card behind a goal or a timer: dark glass the scene shows through. + function backCard(id, anchor, offset, size) { + const w = defaultWidget('Shape', id); + w.states = {}; w.always = true; w.shape = 'card'; + w.anchor = anchor; w.offset = offset; w.size = size; + w.base.color = '#0a0c10'; w.base.opacity = 0.62; w.base.anim = arrive(0); + return w; + } + // A layer of words on stream at all times: a goal's name or its numbers. + function streamText(id, text, o) { + const w = defaultWidget('Text', id); + w.states = {}; w.always = true; + w.base.anim = arrive(o.step || 1); + w.base.text = text; w.anchor = o.anchor; w.offset = o.offset; + w.font_px = o.font_px; w.font_weight = '700'; w.base.color = o.color; + if (o.uppercase) { w.uppercase = true; w.letter_spacing = 3; } + return w; + } + + // An overlay for one goal or one timer, ready to save: what the dashboard + // makes when it is first put on stream. Each part is its own layer, to + // move, restyle or delete: a goal's bar, its name and its numbers; a + // timer's name and its clock. + function trackerDoc(kind, id, name) { + const d = defaultDoc(name); + d.purpose = 'goals'; + if (kind === 'Goal') { + // Bottom centre. Centre anchors start at the middle, so each part is + // half its width back; the numbers end where the bar does (960 - 310 + // from the right edge). + const bar = defaultWidget('Goal', 'w4'); + bar.goal = id; bar.anchor = 'bottom'; bar.offset = { x: -310, y: 72 }; bar.size = { w: 620, h: 14 }; + bar.base.anim = arrive(3); + d.widgets.push(backCard('w1', 'bottom', { x: -340, y: 48 }, { w: 680, h: 96 }), + streamText('w2', '{{goal.' + id + '.name}}', { anchor: 'bottom', offset: { x: -310, y: 98 }, font_px: 26, color: '@ink', step: 1 }), + streamText('w3', '{{goal.' + id + '}} / {{goal.' + id + '.target}}', { anchor: 'bottom-right', offset: { x: 650, y: 98 }, font_px: 26, color: '@accent', step: 2 }), + bar); + } else { + // Top right, the name over the clock. + const clock = defaultWidget('Timer', 'w3'); + clock.timer = id; clock.anchor = 'top-right'; clock.offset = { x: 64, y: 82 }; clock.base.anim = arrive(2); + d.widgets.push(backCard('w1', 'top-right', { x: 40, y: 36 }, { w: 340, h: 140 }), + streamText('w2', '{{timer.' + id + '.name}}', { anchor: 'top-right', offset: { x: 66, y: 56 }, font_px: 20, color: '@ink', uppercase: true, step: 1 }), + clock); + } + return d; + } + + // The alerts page's themes, each a built-in alert design: its card, as a + // layer, at the bottom of the screen, where the alerts page put it. Each + // keeps its theme's own motion; everything about it can be changed, and + // anything can go around it. + const ALERT_THEMES = ['island', 'signal', 'banner', 'bubble', 'minimal', 'paper', 'cinema']; + // One theme's card as a single Alert card layer: the alerts page's own + // card, with its look (what a look from before designs becomes). + function alertCardDoc(theme) { + const d = defaultDoc(theme.charAt(0).toUpperCase() + theme.slice(1)); + d.purpose = 'alerts'; + const w = defaultWidget('AlertCard', 'w1'); + w.card = { theme: theme }; w.anchor = 'bottom'; w.offset = { x: -560, y: 36 }; + d.widgets.push(w); + return d; + } + + // A ready-made alert design's layer, at the bottom of the screen (x from + // the middle to its left edge, y up from the bottom): it waits hidden, + // and comes in its own way (`anim`, `ms`, after `delay`) as an alert plays. + function alertLayer(kind, id, o) { + const w = defaultWidget(kind, id); + w.always = true; w.states = { idle: { visible: false } }; + w.anchor = 'bottom'; w.offset = { x: o.x, y: o.y }; + if (o.w) w.size = { w: o.w, h: o.h || w.size.h }; + w.base.color = o.color || '@accent'; + if (o.op != null) w.base.opacity = o.op; + w.base.anim = { style: o.anim || 'fade', duration_ms: o.ms || 450, easing: o.ease || 'spring', delay_ms: o.delay || 0 }; + ['name', 'radius', 'border', 'border_color', 'shadow', 'shape', 'nopic', 'with_message'].forEach(function (k) { if (o[k] != null) w[k] = o[k]; }); + return w; + } + // The alert's picture, filling its box. + function alertPicture(id, o) { + const w = alertLayer('Image', id, o); + w.src = '{{alert.image}}'; w.cover = true; + return w; + } + // What the alert says (`text`), kept inside its width: long words shrink. + function alertWords(id, text, o) { + const w = alertLayer('Text', id, Object.assign({ h: Math.round(o.px * 1.15) }, o)); + w.base.text = text; w.fit = true; w.text_align = o.align || 'left'; + w.font_px = o.px; w.font_family = o.font; w.font_weight = String(o.weight || 700); + if (o.caps) { w.uppercase = true; w.letter_spacing = o.ls != null ? o.ls : 2; } + else if (o.ls != null) w.letter_spacing = o.ls; + return w; + } + // The viewer's own words (a cheer's or a resub's), over the card: up to + // three lines in its width, and only there when the alert has some. + function alertMessage(id, o) { + const w = alertWords(id, '{{alert.message}}', Object.assign({ name: 'Message', px: 22, weight: 500, color: '#ffffff', align: 'center', with_message: true, delay: 650 }, o)); + delete w.fit; w.wrap = true; + return w; + } + // Each ready-made design: its sound, its colours (accent, words) and its + // layers. + const ALERT_LOOKS = { + island: { sound: 'chime', accent: '#5ac8fa', ink: '#ffffff', layers: function () { return [ + alertLayer('Shape', 'w1', { name: 'Card', x: -300, y: 64, w: 600, h: 96, color: '#000000', op: 0.96, radius: 48, border: 1, border_color: '#2a2d33', shadow: true, anim: 'grow', ms: 800 }), + alertPicture('w2', { x: -286, y: 78, w: 68, h: 68, radius: 34, anim: 'scale-in', ms: 420, delay: 320 }), + alertWords('w3', '{{alert.title}}', { nopic: 84, x: -202, y: 118, w: 430, px: 17, font: 'Inter', weight: 500, color: '#9aa0a8', delay: 360 }), + alertWords('w4', '{{alert.text}}', { nopic: 84, x: -202, y: 80, w: 430, px: 30, font: 'Inter', color: '@ink', delay: 420 }), + alertLayer('PulsingDot', 'w5', { name: 'Live dot', x: 256, y: 105, w: 14, h: 14, delay: 520 }), + alertMessage('w6', { x: -300, y: 174, w: 600, font: 'Inter' }) + ]; } }, + signal: { sound: 'zap', accent: '#3ee0c5', ink: '#eef4ff', layers: function () { return [ + alertLayer('Shape', 'w1', { name: 'Panel', x: -320, y: 60, w: 640, h: 116, color: '#0b0f14', op: 0.88, radius: 0, border: 2, border_color: '@accent', anim: 'wipe', ms: 450, ease: 'sharp' }), + alertLayer('Shape', 'w2', { name: 'Edge', x: -320, y: 60, w: 8, h: 116, radius: 0, anim: 'shutter', ms: 300, delay: 250 }), + alertPicture('w3', { x: -296, y: 76, w: 84, h: 84, radius: 0, border: 2, border_color: '@accent', delay: 350 }), + alertWords('w4', '{{alert.title}}', { nopic: 100, x: -196, y: 128, w: 470, px: 15, font: 'Roboto Mono', weight: 600, color: '@accent', caps: true, ls: 4, delay: 300 }), + alertWords('w5', '{{alert.text}}', { nopic: 100, x: -196, y: 78, w: 470, px: 38, font: 'Chakra Petch', color: '@ink', anim: 'decode', ms: 700, delay: 380 }), + alertMessage('w6', { x: -320, y: 190, w: 640, font: 'Chakra Petch', align: 'left' }) + ]; } }, + banner: { sound: 'fanfare', accent: '#fcd34d', ink: '#0b0d12', layers: function () { return [ + alertLayer('Shape', 'w1', { name: 'Band', x: -350, y: 60, w: 700, h: 112, radius: 0, shadow: true, anim: 'wipe', ms: 500, ease: 'sharp' }), + alertLayer('Shape', 'w2', { name: 'Title tag', nopic: 104, x: -232, y: 126, w: 250, h: 34, color: '@ink', radius: 0, anim: 'wipe', ms: 350, ease: 'sharp', delay: 250 }), + alertPicture('w3', { x: -336, y: 72, w: 88, h: 88, radius: 0, border: 3, border_color: '@ink', delay: 300 }), + alertWords('w4', '{{alert.title}}', { nopic: 104, x: -222, y: 129, w: 230, px: 24, font: 'Bebas Neue', weight: 400, color: '@accent', ls: 3, shadow: false, delay: 380 }), + alertWords('w5', '{{alert.text}}', { nopic: 104, x: -232, y: 64, w: 560, px: 58, font: 'Bebas Neue', weight: 400, color: '@ink', ls: 1, shadow: false, anim: 'slide-right', ms: 420, delay: 300 }), + alertMessage('w6', { x: -350, y: 186, w: 700, font: 'Inter', align: 'left' }) + ]; } }, + bubble: { sound: 'pop', accent: '#a78bfa', ink: '#1b1d24', layers: function () { return [ + alertLayer('Shape', 'w1', { name: 'Bubble', x: -300, y: 70, w: 600, h: 118, color: '#ffffff', radius: 46, shadow: true, anim: 'boing', ms: 700 }), + alertLayer('Shape', 'w2', { name: 'Tail', x: -262, y: 50, w: 26, h: 26, color: '#ffffff', shape: 'circle', anim: 'boing', ms: 500, delay: 120 }), + alertLayer('Shape', 'w3', { name: 'Small tail', x: -284, y: 34, w: 14, h: 14, color: '#ffffff', shape: 'circle', anim: 'boing', ms: 500, delay: 180 }), + alertPicture('w4', { x: -284, y: 86, w: 86, h: 86, radius: 43, border: 4, border_color: '@accent', anim: 'scale-in', ms: 400, delay: 250 }), + alertWords('w5', '{{alert.title}}', { nopic: 102, x: -182, y: 136, w: 440, px: 18, font: 'Fredoka', weight: 600, color: '@accent', caps: true, ls: 1, shadow: false, delay: 300 }), + alertWords('w6', '{{alert.text}}', { nopic: 102, x: -182, y: 86, w: 440, px: 36, font: 'Fredoka', color: '@ink', shadow: false, anim: 'slide-up', delay: 340 }), + alertMessage('w7', { x: -300, y: 202, w: 600, font: 'Fredoka' }) + ]; } }, + minimal: { sound: 'blip', accent: '#5ac8fa', ink: '#ffffff', layers: function () { return [ + alertLayer('Shape', 'w1', { name: 'Line', x: -300, y: 70, w: 5, h: 104, radius: 3, anim: 'shutter', ms: 400 }), + alertPicture('w2', { x: -282, y: 76, w: 92, h: 92, radius: 14, delay: 200 }), + alertWords('w3', '{{alert.title}}', { nopic: 108, x: -174, y: 140, w: 500, px: 16, font: 'Inter', weight: 700, color: '@accent', caps: true, ls: 4, anim: 'slide-right', delay: 150 }), + alertWords('w4', '{{alert.text}}', { nopic: 108, x: -174, y: 78, w: 500, px: 48, font: 'Inter', weight: 800, color: '@ink', anim: 'slide-right', ms: 500, delay: 230 }), + alertMessage('w5', { x: -300, y: 188, w: 600, font: 'Inter', align: 'left' }) + ]; } }, + paper: { sound: 'whoosh', accent: '#e2553d', ink: '#1d1a16', layers: function () { return [ + alertLayer('Shape', 'w1', { name: 'Paper', x: -310, y: 62, w: 620, h: 128, color: '#f4efe4', radius: 3, shadow: true, anim: 'stamp', ms: 450 }), + alertLayer('Shape', 'w2', { name: 'Tape', x: -65, y: 176, w: 130, h: 30, op: 0.85, radius: 2, anim: 'stamp', ms: 350, delay: 250 }), + alertPicture('w3', { x: -292, y: 80, w: 92, h: 92, radius: 2, border: 5, border_color: '#ffffff', shadow: true, anim: 'stamp', ms: 400, delay: 150 }), + alertWords('w4', '{{alert.title}}', { nopic: 112, x: -180, y: 138, w: 440, px: 18, font: 'Special Elite', weight: 400, color: '@accent', caps: true, ls: 2, shadow: false, anim: 'typewriter', ms: 500, delay: 300 }), + alertWords('w5', '{{alert.text}}', { nopic: 112, x: -180, y: 82, w: 440, px: 40, font: 'Permanent Marker', weight: 400, color: '@ink', shadow: false, anim: 'stamp', ms: 400, delay: 380 }), + alertMessage('w6', { x: -310, y: 218, w: 620, font: 'Special Elite' }) + ]; } }, + cinema: { sound: 'bell', accent: '#d4b06a', ink: '#f4ede1', layers: function () { return [ + alertLayer('Shape', 'w1', { name: 'Letterbox', x: -380, y: 50, w: 760, h: 150, color: '#0a0908', op: 0.9, radius: 2, anim: 'shutter', ms: 700, ease: 'ease-in-out' }), + alertLayer('Divider', 'w2', { name: 'Top line', x: -280, y: 180, w: 560, h: 2, ms: 600, delay: 450 }), + alertLayer('Divider', 'w3', { name: 'Bottom line', x: -280, y: 66, w: 560, h: 2, ms: 600, delay: 450 }), + alertPicture('w4', { x: -50, y: 214, w: 100, h: 100, radius: 50, border: 2, border_color: '@accent', ms: 600, delay: 300 }), + alertWords('w5', '{{alert.title}}', { x: -300, y: 146, w: 600, px: 16, font: 'Playfair Display', color: '@accent', caps: true, ls: 6, align: 'center', ms: 600, delay: 500 }), + alertWords('w6', '{{alert.text}}', { x: -310, y: 86, w: 620, px: 44, font: 'Playfair Display', color: '@ink', align: 'center', ms: 800, delay: 650 }), + alertMessage('w7', { x: -380, y: 326, w: 760, font: 'Playfair Display', delay: 900 }) + ]; } } + }; + // A ready-made alert design: the theme built from layers (its shapes, the + // picture, the title, the words), each to move, restyle or delete. The + // theme's accent and words colours are the design's own (Theme tab). + function alertPreset(theme) { + const look = ALERT_LOOKS[theme] || ALERT_LOOKS.island; + const d = defaultDoc(theme.charAt(0).toUpperCase() + theme.slice(1)); + d.purpose = 'alerts'; + d.theme.accent = look.accent; d.theme.ink = look.ink; + d.sound = look.sound; + d.widgets = look.layers(); + return d; + } + + // The old card's entrances, as a layer's. + const LOOK_ENTRANCES = { slide: 'slide-up', pop: 'scale-in', fade: 'fade', zoom: 'scale-in', bounce: 'boing', drop: 'slide-down' }; + + // A look from before designs (the alerts page's theme and what was + // changed on it, or an Alert card layer's) as that theme's ready-made + // layers, with its colours, font, entrance, sound, size and place carried + // over. What layers can't be stays a card (null), exactly as it was: + // custom CSS (written for the card's markup), the picture on top, or a + // card's colour on a theme with no card (minimal). + function alertLookDoc(look) { + if (look.css || look.picture === 'top') return null; + const d = alertPreset(ALERT_THEMES.indexOf(look.theme) >= 0 ? look.theme : 'island'); + const hex = function (c) { return /^#[0-9a-f]{6}$/i.test(c || '') ? c : ''; }; + const set = function (v) { return v != null && v !== ''; }; + if (hex(look.accent)) d.theme.accent = look.accent; + if (hex(look.text)) d.theme.ink = look.text; + if (look.sound) d.sound = look.sound; + // The card is the first wide shape; shapes painted like it (a bubble's + // tails) take its new colour too. + const card = d.widgets.filter(function (w) { return w.kind === 'Shape' && w.size.w >= 300; })[0]; + if (!card && (hex(look.background) || set(look.opacity) || set(look.radius))) return null; + if (card && LOOK_ENTRANCES[look.animation]) card.base.anim.style = LOOK_ENTRANCES[look.animation]; + if (card) { + const was = card.base.color; + d.widgets.forEach(function (w) { + if (w.kind === 'Shape' && w.base.color === was && hex(look.background)) w.base.color = look.background; + }); + if (set(look.opacity)) card.base.opacity = Math.max(0, Math.min(100, +look.opacity)) / 100; + if (set(look.radius)) card.radius = Math.max(0, +look.radius); + } + if (FONTS[look.font] != null || look.font === SYSTEM_FONT) { + d.widgets.forEach(function (w) { if (w.kind === 'Text') w.font_family = look.font; }); + } + const scale = Math.max(50, Math.min(200, +look.scale || 100)) / 100; + if (scale !== 1) { + const sized = function (n) { return Math.round(n * scale); }; + d.widgets.forEach(function (w) { + w.offset = { x: sized(w.offset.x), y: sized(w.offset.y) }; + w.size = { w: sized(w.size.w), h: sized(w.size.h) }; + ['font_px', 'radius', 'border', 'nopic', 'letter_spacing'].forEach(function (k) { if (w[k] != null) w[k] = sized(w[k]); }); + }); + } + placeAlertLayers(d.widgets, look.position, look.align); + return d; + } + + // Move a ready-made design's layers (made at the bottom middle) to the + // top or the middle of the screen, or to its left or right side, as one + // piece, the old card's margin from the edge kept. The viewer's message + // isn't always there: it doesn't count in the piece's size, and from the + // top it goes under the card. + function placeAlertLayers(widgets, position, align) { + const vert = position === 'top' ? 'top' : position === 'middle' ? '' : 'bottom'; + const side = align === 'left' || align === 'right' ? align : ''; + if (vert === 'bottom' && !side) return; + const card = widgets.filter(function (w) { return !w.with_message; }); + const left = Math.min.apply(null, card.map(function (w) { return w.offset.x; })); + const right = Math.max.apply(null, card.map(function (w) { return w.offset.x + w.size.w; })); + const low = Math.min.apply(null, card.map(function (w) { return w.offset.y; })); + const high = Math.max.apply(null, card.map(function (w) { return w.offset.y + w.size.h; })); + const margin = 36, gap = 12, half = Math.round((high - low) / 2); + widgets.forEach(function (w) { + let x = w.offset.x, y = w.offset.y; + if (side === 'left') x = margin + (w.offset.x - left); + if (side === 'right') x = margin + (right - w.offset.x - w.size.w); + // From the top or the middle: what was highest is nearest the edge, + // and the message goes under the card, where a long one grows away + // from it (its top edge is the one that stays put). + if (vert === 'top') y = w.with_message ? high + gap : low + (high - w.offset.y - w.size.h); + if (!vert) y = w.with_message ? high - low - half + gap : (high - w.offset.y - w.size.h) - half; + w.anchor = vert && side ? vert + '-' + side : vert || side || 'center'; + w.offset = { x: x, y: y }; + }); + } + + // An alert design to start from: the alert's picture beside its title and + // words, on a dark card at the top of the screen. Each part waits hidden + // and comes in, one after another, when an alert plays. + function alertDoc(name) { + const d = defaultDoc(name); + d.purpose = 'alerts'; d.sound = 'chime'; + const waits = function (w, step) { + w.always = true; w.states = { idle: { visible: false } }; w.base.anim = arrive(step); + return w; + }; + const card = backCard('w1', 'top', { x: -300, y: 90 }, { w: 600, h: 150 }); + const pic = defaultWidget('Image', 'w2'); + pic.src = '{{alert.image}}'; pic.anchor = 'top'; pic.offset = { x: -276, y: 114 }; pic.size = { w: 102, h: 102 }; + const title = streamText('w3', '{{alert.title}}', { anchor: 'top', offset: { x: -152, y: 122 }, font_px: 22, color: '@accent', uppercase: true }); + const words = streamText('w4', '{{alert.text}}', { anchor: 'top', offset: { x: -152, y: 156 }, font_px: 42, color: '@ink' }); + d.widgets.push(waits(card, 0), waits(pic, 1), waits(title, 2), waits(words, 3)); + return d; + } + function defaultCustom() { return { html: '
{{phase}} - {{delay}}s
', @@ -230,13 +571,24 @@ glow: function (a) { return '0%,100%{opacity:calc(var(--ic-op,1)*' + r(Math.max(0, 1 - 0.45 * a)) + ')}50%{opacity:var(--ic-op,1)}'; }, flash: function (a) { return '0%{opacity:var(--ic-op,1)}30%{opacity:calc(var(--ic-op,1)*' + r(Math.max(0, 1 - 0.85 * a)) + ')}100%{opacity:var(--ic-op,1)}'; }, shake: function (a) { return '0%,100%{transform:translateX(0)}15%{transform:translateX(' + r(-12 * a) + 'px)}45%{transform:translateX(' + r(12 * a) + 'px)}70%{transform:translateX(' + r(-7 * a) + 'px)}'; }, - glitch: function (a) { return '0%,100%{transform:translate(0)}20%{transform:translate(' + r(-8 * a) + 'px,' + r(3 * a) + 'px)}40%{transform:translate(' + r(8 * a) + 'px,' + r(-3 * a) + 'px)}60%{transform:translate(' + r(-5 * a) + 'px,' + r(-3 * a) + 'px)}80%{transform:translate(' + r(5 * a) + 'px,' + r(3 * a) + 'px)}'; } + glitch: function (a) { return '0%,100%{transform:translate(0)}20%{transform:translate(' + r(-8 * a) + 'px,' + r(3 * a) + 'px)}40%{transform:translate(' + r(8 * a) + 'px,' + r(-3 * a) + 'px)}60%{transform:translate(' + r(-5 * a) + 'px,' + r(-3 * a) + 'px)}80%{transform:translate(' + r(5 * a) + 'px,' + r(3 * a) + 'px)}'; }, + // (Each ends clipped 80px out, so a layer's drop shadow shows whole.) + wipe: function () { return 'from{clip-path:inset(-80px calc(100% + 80px) -80px -80px)}to{clip-path:inset(-80px)}'; }, + shutter: function () { return 'from{clip-path:inset(50% -80px 50% -80px)}to{clip-path:inset(-80px)}'; }, + grow: function () { return 'from{opacity:0;clip-path:inset(45% 45% round 999px)}30%{opacity:var(--ic-op,1)}70%{clip-path:inset(-2% -2% round 24px)}to{opacity:var(--ic-op,1);clip-path:inset(-80px round 0)}'; }, + stamp: function (a) { return 'from{opacity:0;transform:scale(' + r(1 + 0.8 * a) + ')}60%{opacity:var(--ic-op,1);transform:scale(' + r(1 - 0.03 * a) + ')}to{opacity:var(--ic-op,1);transform:none}'; }, + boing: function (a) { return '0%{opacity:0;transform:scale(0)}35%{opacity:var(--ic-op,1);transform:scale(' + r(1 + 0.12 * a) + ',' + r(1 - 0.12 * a) + ')}55%{transform:scale(' + r(1 - 0.07 * a) + ',' + r(1 + 0.07 * a) + ')}72%{transform:scale(' + r(1 + 0.04 * a) + ',' + r(1 - 0.03 * a) + ')}100%{opacity:var(--ic-op,1);transform:none}'; }, + // The words do the moving (textFx in the runtime): the layer just shows. + decode: function () { return 'from,to{opacity:var(--ic-op,1)}'; }, + typewriter: function () { return 'from,to{opacity:var(--ic-op,1)}'; } }; // Default (intensity 1) keyframes registered globally, plus the two that // take no intensity (fade, animated-gradient). const KEYFRAMES = { fade: '@keyframes ic-fade{from{opacity:0}to{opacity:var(--ic-op,1)}}', - gradshift: '@keyframes ic-gradshift{0%{background-position:0% 50%}100%{background-position:200% 50%}}' + gradshift: '@keyframes ic-gradshift{0%{background-position:0% 50%}100%{background-position:200% 50%}}', + gradwave: '@keyframes ic-gradwave{0%{background-position:0% 50%}100%{background-position:100% 50%}}', + gradshine: '@keyframes ic-gradshine{0%{background-position:150% 0,0 0}100%{background-position:-50% 0,0 0}}' }; Object.keys(KEYFRAME_FN).forEach(function (k) { KEYFRAMES[k] = '@keyframes ic-' + k + '{' + KEYFRAME_FN[k](1) + '}'; }); @@ -255,7 +607,7 @@ function animsForState(w, sp, stateKey) { if (sp.anims) return sp.anims; if (sp.anim) return [sp.anim]; - if (stateKey === 'idle') return []; + if (stateKey === 'idle' && !w.always) return []; if (w.base.anims) return w.base.anims; if (w.base.anim) return [w.base.anim]; return []; @@ -269,13 +621,17 @@ "font-family:'Inter','SF Pro Display',-apple-system,Segoe UI,Roboto,sans-serif;", "-webkit-font-smoothing:antialiased;font-feature-settings:'tnum' 1}", // Colour crossfades and the widget glides to its per-state position - // between phases. Transforms stay free for the keyframe animations. - '.icw{will-change:transform,opacity;transition:color .45s cubic-bezier(.2,.7,.2,1),filter .45s ease,' + + // between phases. Transforms stay free for the keyframe animations. No + // will-change: it would hold a GPU layer per widget for good (a full + // screen one for the edge, wash and tide), and OBS shares that GPU. + '.icw{transition:color .45s cubic-bezier(.2,.7,.2,1),filter .45s ease,' + 'opacity .45s cubic-bezier(.2,.7,.2,1),' + 'top .5s cubic-bezier(.2,.8,.2,1),left .5s cubic-bezier(.2,.8,.2,1),' + 'right .5s cubic-bezier(.2,.8,.2,1),bottom .5s cubic-bezier(.2,.8,.2,1),' + 'margin-top .5s cubic-bezier(.2,.8,.2,1),margin-left .5s cubic-bezier(.2,.8,.2,1)}', '.icw .v{font-variant-numeric:tabular-nums;line-height:1}', + // A goal bar or timer whose goal or timer no longer exists. + '.icw.ic-none{visibility:hidden}', // Auto-hide: fade + gentle drift out, then stop painting (visibility). '.icw.ic-gone{opacity:0 !important;visibility:hidden;transform:translateY(8px);transition:opacity .8s ease,transform .8s ease,visibility 0s linear .8s;pointer-events:none}', // LiquidFill recedes smoothly first (height -> 0 over .6s), THEN fades, @@ -304,14 +660,22 @@ return '' + esc(w.base.text || '') + ''; case 'SessionElapsed': return '0:00'; + case 'Goal': + return ''; + case 'Timer': + return '0:00'; case 'BufferBar': return ''; case 'BufferRing': return '' + '' + (w.notext ? '' : '0.0'); - case 'BitrateMeter': - return ''; + case 'BitrateMeter': { + const shade = 'ica-' + cssId(w.id); + return '' + + '' + + ''; + } case 'DestinationStatus': return ''; case 'Icon': @@ -329,12 +693,18 @@ case 'LiquidFill': return ''; case 'Banner': - return '' + esc(w.base.text || 'ON A DELAY') + ''; + return '' + esc(shownText(w)) + ''; case 'EdgeAccent': return ''; case 'BackgroundTint': return ''; + case 'AlertCard': + // The alerts page draws the card, in this layer's look (alerts.html + // ?embed); the runtime tells it when (cardEls). + return ''; case 'Image': + // A picture said in words ({{alert.image}}) is filled in live. + if (/\{\{/.test(w.src || '')) return ''; return w.src ? '' : ''; case 'CustomHTML': return ''; // hydrated by the runtime into a sandboxed iframe @@ -343,8 +713,14 @@ } } + // The words a text widget starts with. A lower-third left blank says what + // it is for, rather than showing an empty strip. + function shownText(w) { + return w.base.text || (w.kind === 'Banner' ? 'ON A DELAY' : ''); + } + // CSS for one widget kind, scoped to #. Compositor-only animation. - function widgetCss(w) { + function widgetCss(w, theme) { const id = '#' + cssId(w.id); const fp = (w.font_px || 34); let css = ''; @@ -365,7 +741,21 @@ id + ' .lbl{color:currentColor;text-shadow:0 0 12px currentColor}'; break; case 'Text': - css = id + '{font-size:' + fp + 'px;font-weight:600;color:currentColor;text-shadow:0 1px 12px rgba(0,0,0,.4)}'; + // Kept to its width (fit): as wide as its box, aligned in it, and + // shrunk (--ic-fit, set by the runtime) when the words are longer. + // The shadow keeps words readable over a game; on a card of their + // own it can go (shadow: false). + css = id + '{font-size:' + (w.fit ? 'calc(' + fp + 'px * var(--ic-fit,1))' : fp + 'px') + ';font-weight:600;color:currentColor;white-space:nowrap' + + (w.shadow === false ? '' : ';text-shadow:0 1px 2px rgba(0,0,0,.55),0 1px 12px rgba(0,0,0,.4)') + '}'; + if (w.fit) css += id + '{width:' + r(+w.size.w) + 'px;line-height:' + Math.round(fp * 1.15) + 'px;text-align:' + (['center', 'right'].indexOf(w.text_align) >= 0 ? w.text_align : 'left') + '}'; + // Wrapped instead: onto up to three lines in its width, the rest + // cut with an ellipsis. Anchored at the bottom, it grows upwards. + else if (w.wrap) { + // One line tall even with no words yet, so the Studio's handle + // sits on it. + css += id + '{width:' + r(+w.size.w) + 'px;white-space:normal;line-height:' + Math.round(fp * 1.25) + 'px;min-height:' + Math.round(fp * 1.25) + 'px;text-align:' + (['center', 'right'].indexOf(w.text_align) >= 0 ? w.text_align : 'left') + '}' + + id + ' .lbl{display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:3;overflow:hidden;overflow-wrap:anywhere}'; + } break; case 'CutCounter': css = id + '{display:inline-flex;align-items:baseline;gap:6px;font-size:' + fp + 'px;font-weight:700;color:currentColor}'; @@ -373,10 +763,47 @@ case 'SessionElapsed': css = id + '{font-size:' + fp + 'px;font-weight:600;color:currentColor;font-variant-numeric:tabular-nums}'; break; + case 'Goal': { + // The bar fills by sliding (a transform), which the compositor does + // alone. Its words are Text layers of their own. + const round = w.radius != null ? Math.max(0, +w.radius) + 'px' : '999px'; + const track = rgba(themed(w.track || '#0a0c10', theme), w.trackOpacity != null ? +w.trackOpacity : 0.6); + css = '@keyframes ic-goal-hit{0%,100%{transform:scale(1)}40%{transform:scale(1.035)}}' + + id + '{width:' + w.size.w + 'px;color:currentColor}' + + id + ' .g{position:relative;display:block;transform-origin:center}' + + // What was just added, rising from the end of the fill. + '@keyframes ic-goal-add{0%{opacity:0;transform:translate(-50%,8px)}18%,65%{opacity:1;transform:translate(-50%,0)}100%{opacity:0;transform:translate(-50%,-14px)}}' + + id + ' .add{position:absolute;bottom:100%;left:var(--ic-p,0%);margin-bottom:8px;transform:translate(-50%,0);opacity:0;' + + 'font-weight:800;font-size:' + Math.max(14, Math.round(w.size.h * 1.3)) + 'px;line-height:1;color:var(--ic-live);white-space:nowrap;text-shadow:0 1px 2px rgba(0,0,0,.6);pointer-events:none}' + + id + ' .add.on{animation:ic-goal-add 1.6s cubic-bezier(.2,.7,.2,1) 1 both}' + + id + ' .g.hit{animation:ic-goal-hit .7s cubic-bezier(.2,.7,.2,1) 1}' + + id + ' .trk{position:relative;display:block;height:' + Math.max(3, w.size.h) + 'px;border-radius:' + round + ';overflow:hidden;' + + 'background:' + track + ';box-shadow:inset 0 0 0 1px rgba(255,255,255,.14)}' + + id + ' .fill{position:absolute;inset:0;border-radius:' + round + ';background:currentColor;transform:translateX(-100%);' + + 'transition:transform var(--ic-sm,.7s) cubic-bezier(.16,1,.3,1)}'; + break; + } + case 'Timer': + css = '@keyframes ic-time-add{0%{opacity:0;transform:translateY(40%)}18%,70%{opacity:1;transform:none}100%{opacity:0;transform:translateY(-60%)}}' + + id + '{display:inline-block;color:currentColor;font-weight:700}' + + id + ' .t{position:relative;display:block}' + + id + ' .v{display:block;font-size:' + fp + 'px;letter-spacing:-.02em;white-space:nowrap;' + + 'text-shadow:0 2px 3px rgba(0,0,0,.5),0 0 18px rgba(0,0,0,.35);transition:opacity .45s ease}' + + id + '.over .v{opacity:.6}' + + // The last minute: the warning colour, and a beat each second. + '@keyframes ic-time-beat{0%{transform:scale(1.045)}100%{transform:scale(1)}}' + + 'body[data-state] ' + id + '.soon .v{color:var(--ic-warn);-webkit-text-fill-color:currentColor;background:none;' + + 'animation:ic-time-beat 1s cubic-bezier(.2,.7,.2,1) infinite}' + + // Time someone just added, beside the clock for a moment. + id + ' .add{position:absolute;left:100%;top:0;margin-left:.5em;font-size:' + Math.round(fp * 0.34) + 'px;font-weight:800;' + + 'white-space:nowrap;opacity:0;color:var(--ic-live);text-shadow:0 1px 2px rgba(0,0,0,.6)}' + + id + ' .add.on{animation:ic-time-add 1.8s cubic-bezier(.2,.7,.2,1) 1 both}'; + break; case 'BufferBar': - css = id + '{width:' + w.size.w + 'px;height:' + Math.max(3, w.size.h) + 'px;border-radius:999px;background:rgba(255,255,255,.12);overflow:hidden}' + - id + ' .fill{display:block;height:100%;width:0;border-radius:999px;background:currentColor;' + - 'transform-origin:left center;transition:width var(--ic-sm,.42s) cubic-bezier(.16,1,.3,1)}'; + css = id + '{width:' + w.size.w + 'px;height:' + Math.max(3, w.size.h) + 'px;border-radius:999px;overflow:hidden;' + + 'background:rgba(255,255,255,.12);box-shadow:inset 0 0 0 1px rgba(255,255,255,.08)}' + + id + ' .fill{display:block;height:100%;border-radius:999px;background:currentColor;transform:translateX(-100%);' + + 'transition:transform var(--ic-sm,.42s) cubic-bezier(.16,1,.3,1)}'; break; case 'BufferRing': css = id + '{position:relative;width:' + w.size.w + 'px;height:' + w.size.w + 'px;color:currentColor}' + @@ -394,10 +821,12 @@ id + ' .spark{fill:none;stroke:currentColor;stroke-width:2;vector-effect:non-scaling-stroke;stroke-linejoin:round;stroke-linecap:round}'; break; case 'DestinationStatus': - css = id + ' .dots{display:inline-flex;gap:7px}' + - id + ' .dots i{width:10px;height:10px;border-radius:50%;background:rgba(255,255,255,.25)}' + + css = '@keyframes ic-dot-down{50%{opacity:.4}}' + + id + ' .dots{display:inline-flex;gap:8px}' + + id + ' .dots i{width:11px;height:11px;border-radius:50%;background:rgba(255,255,255,.22);' + + 'transition:background-color .4s ease,box-shadow .4s ease}' + id + ' .dots i.up{background:currentColor;box-shadow:0 0 8px currentColor}' + - id + ' .dots i.down{background:#ff5a5a;box-shadow:0 0 8px #ff5a5a}'; + id + ' .dots i.down{background:var(--ic-error);box-shadow:0 0 8px var(--ic-error);animation:ic-dot-down 1.4s ease-in-out infinite}'; break; case 'Icon': css = id + '{display:inline-flex;color:currentColor}' + @@ -405,10 +834,11 @@ break; case 'Shape': css = id + '{width:' + w.size.w + 'px;height:' + w.size.h + 'px;background:currentColor;' + - shapeRadius(w.shape) + '}'; + shapeRadius(w.shape) + '}' + edgeCss(w, id); break; case 'Divider': - css = id + '{width:' + w.size.w + 'px;height:' + Math.max(1, w.size.h) + 'px;background:currentColor;opacity:.5;border-radius:2px}'; + css = id + '{width:' + w.size.w + 'px;height:' + Math.max(1, w.size.h) + 'px;border-radius:2px;' + + 'background:linear-gradient(90deg,transparent,currentColor 18%,currentColor 82%,transparent)}'; break; case 'CornerFrame': css = id + '{position:fixed;inset:0;pointer-events:none;color:currentColor}' + @@ -420,8 +850,11 @@ id + ' .br{bottom:24px;right:24px;border-left:0;border-top:0}'; break; case 'PulsingDot': - css = id + ' .dot{display:block;width:' + Math.max(10, w.size.h) + 'px;height:' + Math.max(10, w.size.h) + 'px;' + - 'border-radius:50%;background:currentColor;box-shadow:0 0 10px currentColor}'; + css = '@keyframes ic-dot-ring{0%{transform:scale(1);opacity:.7}100%{transform:scale(2.6);opacity:0}}' + + id + ' .dot{position:relative;display:block;width:' + Math.max(10, w.size.h) + 'px;height:' + Math.max(10, w.size.h) + 'px;' + + 'border-radius:50%;background:currentColor;box-shadow:0 0 10px currentColor}' + + id + ' .dot::after{content:"";position:absolute;inset:0;border-radius:50%;border:2px solid currentColor;' + + 'animation:ic-dot-ring 2s cubic-bezier(.2,.7,.2,1) infinite}'; break; case 'Heartbeat': { const hb = Math.max(40, w.size.w); @@ -470,7 +903,13 @@ 'body[data-state="active"] ' + id + ' .liquid{animation:ic-swell calc(5s / var(--ic-spd-mul,1)) ease-in-out infinite}'; break; case 'Image': - css = id + ' img{width:' + w.size.w + 'px;height:auto;display:block}'; + // Filling its box (cover): its width and height, cropped to them. + css = id + ' img{width:' + w.size.w + 'px;height:' + (w.cover ? r(+w.size.h) + 'px;object-fit:cover' : 'auto') + ';display:block}' + + edgeCss(w, id + ' img'); + break; + case 'AlertCard': + css = id + '{width:' + w.size.w + 'px;height:' + w.size.h + 'px}' + + id + ' iframe{width:100%;height:100%;border:0;display:block;background:transparent;color-scheme:normal}'; break; case 'CustomHTML': css = id + '{width:' + w.size.w + 'px;height:' + w.size.h + 'px}' + @@ -488,19 +927,36 @@ } // Typography controls (text-bearing widgets). Weight targets the inner // text nodes so it overrides the per-widget defaults. - if (['DelayReadout', 'StatePill', 'Text', 'CutCounter', 'SessionElapsed', 'Banner'].indexOf(w.kind) >= 0) { + if (['DelayReadout', 'StatePill', 'Text', 'CutCounter', 'SessionElapsed', 'Banner', 'Timer'].indexOf(w.kind) >= 0) { if (w.font_weight) css += id + ',' + id + ' .v,' + id + ' .lbl,' + id + ' .u{font-weight:' + w.font_weight + '}'; if (w.letter_spacing != null) css += id + '{letter-spacing:' + w.letter_spacing + 'px}'; if (w.uppercase) css += id + '{text-transform:uppercase}'; else if (w.uppercase === false && w.kind === 'StatePill') css += id + '{text-transform:none}'; + if (FONTS[w.font_family] != null) css += id + '{font-family:"' + w.font_family + '",sans-serif}'; + else if (w.font_family === SYSTEM_FONT) css += id + '{font-family:system-ui,"Segoe UI",sans-serif}'; } + // An alert with no picture: this layer moves left by `nopic` px, into + // the picture's place (the translate property, so motions still apply). + if (+w.nopic) css += 'body[data-nopic] ' + id + '{translate:' + r(-w.nopic) + 'px 0}'; + // Only there when the alert brings a message of the viewer's. + if (w.with_message) css += 'body[data-nomsg] ' + id + '{display:none}'; return css; } + // A box's own corners (px, over its shape's), ring and drop shadow: a + // Shape's, or an Image's picture (`sel`). + function edgeCss(w, sel) { + let css = ''; + if (w.radius != null && w.radius !== '') css += 'border-radius:' + Math.max(0, +w.radius) + 'px;'; + if (+w.border > 0) css += 'box-sizing:border-box;border:' + r(+w.border) + 'px solid ' + resolveColor(w.border_color || '@ink') + ';'; + if (w.shadow) css += 'box-shadow:0 14px 40px rgba(0,0,0,.42),0 3px 10px rgba(0,0,0,.28);'; + return css ? sel + '{' + css + '}' : ''; + } function shapeRadius(shape) { if (shape === 'circle') return 'border-radius:50%'; if (shape === 'pill') return 'border-radius:999px'; if (shape === 'line') return 'border-radius:2px'; + if (shape === 'card') return 'border-radius:18px'; return 'border-radius:6px'; } function cssId(id) { return String(id).replace(/[^a-zA-Z0-9_-]/g, ''); } @@ -511,14 +967,20 @@ function stateCss(w) { const id = '#' + cssId(w.id); const safeId = (w.id || 'w').replace(/[^a-zA-Z0-9_-]/g, ''); - let out = ''; let customKf = ''; let usesAnim = {}; - STATES.forEach(function (st) { - const sp = (w.states && w.states[st.key]) || {}; + // Each state's look: the parts of the widget it styles (' .fill', or '' + // for the widget itself) and their declarations. + const looks = STATES.map(function (st) { + const own = (w.states && w.states[st.key]) || {}; + // "Same look in every state": only whether it shows is per state. + const sp = w.always ? { visible: own.visible } : own; const merged = mergeProps(w.base, sp); - const sel = 'body[data-state="' + st.key + '"] ' + id; + const parts = []; let rule = ''; + // Animations the gradient brings, on the widget itself: they join its + // own, which would otherwise replace them. + let extra = []; if (merged.visible === false) { // Fade out (then stop painting) instead of snapping off, so a widget // that drops out in a phase leaves smoothly. visibility flips after the @@ -529,54 +991,164 @@ // Opacity goes through --ic-op so opacity animations compose with it. const op = merged.opacity != null ? merged.opacity : 1; rule += '--ic-op:' + op + ';opacity:var(--ic-op);'; - // Per-state glow (drop-shadow halo in the widget's own colour). - if (merged.glow) rule += 'filter:drop-shadow(0 0 ' + merged.glow + 'px currentColor);'; // Per-state gradient (a second colour). Text widgets clip the // gradient onto the glyph spans directly (clipping on the flex - // wrapper renders blank); solid widgets get a gradient background. - if (merged.grad) { - // 3-stop loop so an animated gradient wraps seamlessly. - const c1 = resolveColor(merged.color), c2 = resolveColor(merged.grad); - const grad = 'linear-gradient(135deg,' + c1 + ',' + c2 + ',' + c1 + ')'; + // wrapper renders blank), a goal bar paints its fill, and solid + // widgets get a gradient background. + // One look in every state: one set of glow keyframes, the same name in + // each state, so a delay change never restarts the glow. + const g = merged.grad ? gradientCss(merged, safeId + (w.always ? '' : st.key)) : null; + const inner = g && (TEXT_KINDS.indexOf(w.kind) >= 0 || w.kind === 'Goal'); + // Per-state glow: a drop-shadow halo in the widget's own colour, or + // in the gradient's colours, on whatever the gradient paints. + if (merged.glow && !inner) rule += g ? g.glow : 'filter:drop-shadow(0 0 ' + merged.glow + 'px currentColor);'; + if (g) { + if (customKf.indexOf(g.kf) < 0) customKf += g.kf; + g.uses.forEach(function (k) { usesAnim[k] = true; }); if (TEXT_KINDS.indexOf(w.kind) >= 0) { - const ts = sel + ' .v,' + sel + ' .lbl,' + sel + ' .u'; - let g = 'background:' + grad + ';background-size:200% 100%;-webkit-background-clip:text;background-clip:text;-webkit-text-fill-color:transparent;color:transparent;'; - if (merged.grad_anim) { g += 'animation:ic-gradshift 3.5s linear infinite;'; usesAnim['gradshift'] = true; } - out += ts + '{' + g + '}'; + parts.push([[' .v', ' .lbl', ' .u'], g.paint + '-webkit-background-clip:text;background-clip:text;-webkit-text-fill-color:transparent;color:transparent;' + + animDecl(g.moves.concat(g.glowMoves)) + g.glow]); + } else if (w.kind === 'Goal') { + // The fill is clipped inside its track: the glow goes on the track. + parts.push([[' .fill'], g.paint + animDecl(g.moves)], [[' .trk'], g.glow + animDecl(g.glowMoves)]); } else if (w.kind === 'Shape' || w.kind === 'Banner' || w.kind === 'Divider') { - rule += 'background:' + grad + ';background-size:200% 100%;'; - if (merged.grad_anim) { rule += 'animation:ic-gradshift 3.5s linear infinite;'; usesAnim['gradshift'] = true; } + rule += g.paint; + extra = g.moves.concat(g.glowMoves); } } // Per-state position: the widget moves to a new spot in this state // and slides there via the `.icw` layout transition. if (sp.offset) rule += offsetDecls(w.anchor, sp.offset); - const list = animsForState(w, sp, st.key); - const named = list.filter(function (a) { return a && a.style !== 'instant'; }); - if (named.length) { - const parts = []; - named.forEach(function (a, idx) { + const list = animsForState(w, sp, st.key).filter(function (a) { return a && a.style !== 'instant'; }); + const named = []; + if (list.length) { + list.forEach(function (a, idx) { // intensity: 1 uses the shared keyframe; anything else bakes a // per-state scaled variant so each animation tunes independently. const amp = (a.amount != null && a.amount > 0) ? a.amount : 1; let name = 'ic-' + a.style; if (amp !== 1 && KEYFRAME_FN[a.style]) { - name = 'ic-' + a.style + '-' + safeId + st.key + idx; - customKf += '@keyframes ' + name + '{' + KEYFRAME_FN[a.style](amp) + '}'; + // One look in every state: one name, so a delay change + // never plays it again. + name = 'ic-' + a.style + '-' + safeId + (w.always ? '' : st.key) + idx; + const kf = '@keyframes ' + name + '{' + KEYFRAME_FN[a.style](amp) + '}'; + if (customKf.indexOf(kf) < 0) customKf += kf; } else { usesAnim[a.style] = true; } const v = animValue(a, name); - if (v) parts.push(v); + if (v) named.push(v); }); - if (parts.length) rule += 'animation:' + parts.join(',') + ';'; } + rule += animDecl(named.concat(extra)); } - if (rule) out += sel + '{' + rule + '}'; + parts.push([[''], rule]); + return { key: st.key, parts: parts.filter(function (p) { return p[1]; }) }; + }); + // One rule for the states that look alike: a widget with one look in + // every state gets one rule, not five copies of it. + const groups = []; + looks.forEach(function (look) { + const sig = JSON.stringify(look.parts); + const same = groups.find(function (g) { return g.sig === sig; }); + if (same) same.keys.push(look.key); + else groups.push({ sig: sig, keys: [look.key], parts: look.parts }); + }); + let out = ''; + groups.forEach(function (g) { + const heads = g.keys.length === STATES.length ? ['body[data-state] '] + : g.keys.map(function (k) { return 'body[data-state="' + k + '"] '; }); + g.parts.forEach(function (p) { + const sels = []; + heads.forEach(function (head) { p[0].forEach(function (sub) { sels.push(head + id + sub); }); }); + out += sels.join(',') + '{' + p[1] + '}'; + }); }); return { css: out + customKf, anims: usesAnim }; } + // Goal bars, timers and the words that say them were styled per delay + // state at first: a look set while the Studio showed "Active" never + // reached OBS while you weren't live. They have one look for every state + // now, and an old one keeps the look it was given: its most styled + // state's, Active first. + const LOOK_KEYS = ['color', 'opacity', 'glow', 'grad', 'grad_anim', 'grad_speed', 'grad_angle', 'anims', 'anim', 'text']; + function aboutStream(w) { + return w.kind === 'Goal' || w.kind === 'Timer' || /\{\{(goal|timer)\./.test((w.base && w.base.text) || ''); + } + // One look in every state, the one state `from` had: it becomes the + // widget's own, and the states keep only whether it shows. (The Studio's + // "Same look in every delay state" switch, and the upgrade below.) + function oneLook(w, from) { + const states = w.states || {}, look = states[from] || {}; + LOOK_KEYS.forEach(function (k) { if (look[k] != null) w.base[k] = JSON.parse(JSON.stringify(look[k])); }); + if (look.anims) delete w.base.anim; + else if (look.anim) delete w.base.anims; + Object.keys(states).forEach(function (k) { + LOOK_KEYS.concat(['hide_after_ms', 'exit', 'offset']).forEach(function (x) { delete states[k][x]; }); + }); + w.always = true; + } + function upgradeWidget(w) { + if (w.always !== undefined || !aboutStream(w)) return; + const states = w.states || {}; + const styled = function (k) { return LOOK_KEYS.filter(function (x) { return (states[k] || {})[x] != null; }).length; }; + oneLook(w, Object.keys(states).reduce(function (best, k) { return styled(k) > styled(best) ? k : best; }, 'active')); + } + // The page of the Overlay tab an overlay says it is on: 'delay' (it shows + // the delay), 'goals' (a goal's or a timer's own overlay, on its card), + // 'alerts' (an alert design, which the alerts page plays) or 'custom' + // (anything else), set where it was made. The app has the last word + // (web.rs overlay_kind); one from before pages is a delay overlay. + const PURPOSES = ['delay', 'goals', 'alerts', 'custom']; + function docPurpose(doc) { + return PURPOSES.indexOf(doc.purpose) >= 0 ? doc.purpose : 'delay'; + } + // A saved overlay brought up to date, in place, as the Studio opens it. + function upgradeDoc(doc) { + (doc.widgets || []).forEach(function (w) { if (w && w.base) upgradeWidget(w); }); + return doc; + } + + function animDecl(list) { return list.length ? 'animation:' + list.join(',') + ';' : ''; } + + // A state's gradient: how it paints and how it moves. Flow slides it + // across on a loop, wave sways it back and forth, shine sweeps a light + // over it. A glow takes its colours: following them as they move, under + // `key`'s own keyframes, or in the first colour while it stays still. + function gradientCss(m, key) { + const c1 = resolveColor(m.color), c2 = resolveColor(m.grad); + const angle = (m.grad_angle != null ? +m.grad_angle : 135) + 'deg'; + const motion = m.grad_anim === true ? 'flow' : (m.grad_anim || ''); + const secs = Math.max(0.5, m.grad_speed != null ? +m.grad_speed : 3.5); + const g = { paint: '', moves: [], glowMoves: [], glow: '', kf: '', uses: [] }; + if (motion === 'shine') { + g.paint = 'background:linear-gradient(100deg,transparent 35%,rgba(255,255,255,.85) 50%,transparent 65%),' + + 'linear-gradient(' + angle + ',' + c1 + ',' + c2 + ');background-size:250% 100%,100% 100%;background-repeat:no-repeat;'; + g.moves.push('ic-gradshine ' + r(secs) + 's ease-in-out infinite'); + g.uses.push('gradshine'); + } else if (motion === 'wave') { + g.paint = 'background:linear-gradient(' + angle + ',' + c1 + ',' + c2 + ');background-size:200% 100%;'; + g.moves.push('ic-gradwave ' + r(secs) + 's ease-in-out infinite alternate'); + g.uses.push('gradwave'); + } else { + // 3-stop loop so a flowing gradient wraps seamlessly. + g.paint = 'background:linear-gradient(' + angle + ',' + c1 + ',' + c2 + ',' + c1 + ');background-size:200% 100%;'; + if (motion === 'flow') { g.moves.push('ic-gradshift ' + r(secs) + 's linear infinite'); g.uses.push('gradshift'); } + } + if (!m.glow) return g; + const halo = function (c) { return 'filter:drop-shadow(0 0 ' + m.glow + 'px ' + c + ')'; }; + if (motion === 'flow' || motion === 'wave') { + const name = 'ic-glow-' + key; + g.kf = '@keyframes ' + name + '{0%,100%{' + halo(c1) + '}50%{' + halo(c2) + '}}'; + // A wave goes there and back in two passes; a flow in one. + g.glowMoves.push(name + ' ' + r(motion === 'wave' ? secs * 2 : secs) + 's ease-in-out infinite'); + } else { + g.glow = halo(c1) + ';'; + } + return g; + } + function mergeProps(base, over) { return { visible: over.visible != null ? over.visible : true, @@ -585,6 +1157,8 @@ glow: over.glow != null ? over.glow : base.glow, grad: over.grad != null ? over.grad : base.grad, grad_anim: over.grad_anim != null ? over.grad_anim : base.grad_anim, + grad_speed: over.grad_speed != null ? over.grad_speed : base.grad_speed, + grad_angle: over.grad_angle != null ? over.grad_angle : base.grad_angle, text: over.text != null ? over.text : base.text }; } @@ -596,7 +1170,7 @@ return c; } // Text widgets whose glyphs can take a gradient (background-clip:text). - const TEXT_KINDS = ['DelayReadout', 'StatePill', 'Text', 'CutCounter', 'SessionElapsed', 'Banner']; + const TEXT_KINDS = ['DelayReadout', 'StatePill', 'Text', 'CutCounter', 'SessionElapsed', 'Banner', 'Timer']; // ---- Icons (curated, currentColor) ---- function iconSvg(name) { @@ -628,6 +1202,20 @@ // baked per-state auto-hide timers) without re-baking the file. var AUTOHIDE=new URLSearchParams(location.search).get('autohide')!=='off'; var forced=new URLSearchParams(location.search).get('state'); // sim override + // An alert design waits, hidden ("idle"), until the alerts page (or the + // Studio) hands it an alert: it shows ("active") for the alert's length, + // then leaves. The delay's own state never moves it. + var ALERTS=body.getAttribute('data-purpose')==='alerts', ALERT=null, alertT=0; + if(ALERTS&&!forced) forced='idle'; + // The feed counts each overlay's saves since the app started, under a short + // key of its file name (web.rs overlay_key, FNV-1a). A count that changes + // under this page means the Studio saved it: load the new one. + var SELF=(function(){ + var m=/\/overlay\/([^\/]+)\.html$/.exec(location.pathname); if(!m) return ''; + var s=decodeURIComponent(m[1]), h=0x811c9dc5; + for(var i=0;i>>0; } + return ('0000000'+h.toString(16)).slice(-8); + })(), SAVED=null; function deriveState(s){ if(!s.ingest_alive) return 'idle'; @@ -641,7 +1229,7 @@ var tweens={}; function tweenNum(el,to,dp){ var from=parseFloat(el.getAttribute('data-n')||'0')||0; - if(Math.abs(to-from)<0.05){el.textContent=to.toFixed(dp);el.setAttribute('data-n',to);return;} + if(Math.abs(to-from)<0.05){ if(el.textContent!==to.toFixed(dp)){el.textContent=to.toFixed(dp);el.setAttribute('data-n',to);} return; } var k=el.__k||(el.__k=Math.random()); cancelAnimationFrame(tweens[k]); var t0=performance.now(),dur=400; @@ -687,7 +1275,7 @@ var frac=0; if(s.phase==='preparing'){var tgt=s.armed_delay_ms||s.buffer_target_ms||1;frac=Math.max(0,Math.min(1,(s.buffer_fill_ms||0)/tgt));} else if(s.phase==='ready'||s.phase==='active'){frac=1;} - document.querySelectorAll('[data-kind="BufferBar"] .fill').forEach(function(f){f.style.width=(frac*100).toFixed(1)+'%';}); + document.querySelectorAll('[data-kind="BufferBar"] .fill').forEach(function(f){f.style.transform='translateX('+(frac*100-100).toFixed(1)+'%)';}); // LiquidFill: a full-screen tide that rises with the buffer (full when // live). Skip while the widget is auto-hiding so the recede plays out. document.querySelectorAll('[data-kind="LiquidFill"] .liquid').forEach(function(l){ @@ -718,37 +1306,229 @@ var rng=Math.max(mx-mn, mx*0.18, 1); var pts=line.map(function(y,i){return (i/(Math.max(1,line.length-1))*100).toFixed(1)+','+(30-((y-mn)/rng)*28).toFixed(1);}).join(' '); var p=el.querySelector('.spark'); if(p) p.setAttribute('points',pts); + var area=el.querySelector('.area'); if(area) area.setAttribute('points',pts+' 100,32 0,32'); }); // CutCounter var cuts=(s.stats&&s.stats.cuts)||0; - document.querySelectorAll('[data-kind="CutCounter"] .lbl').forEach(function(l){var tpl=l.getAttribute('data-tpl')||'{n}';l.textContent=tpl.replace('{n}',cuts);}); - // DestinationStatus dots + document.querySelectorAll('[data-kind="CutCounter"] .lbl').forEach(function(l){setText(l,(l.getAttribute('data-tpl')||'{n}').replace('{n}',cuts));}); + // DestinationStatus: a dot each (one grey while there are none). A dot + // that changes keeps its element, so its colour fades across. + var dests=s.destinations||[]; document.querySelectorAll('[data-kind="DestinationStatus"] .dots').forEach(function(d){ - var list=s.destinations||[]; var html=''; - for(var i=0;i';} - if(!list.length) html=''; - if(d.getAttribute('data-c')!==String(list.length)+(list.map(function(x){return x.alive?1:0}).join(''))){ - d.innerHTML=html; d.setAttribute('data-c',String(list.length)+(list.map(function(x){return x.alive?1:0}).join(''))); - } + var n=Math.max(1,dests.length); + while(d.children.lengthn) d.removeChild(d.lastChild); + for(var i=0;ihad){ var add=part(el,'add'); el.style.setProperty('--ic-p',(p*100).toFixed(1)+'%'); add.textContent='+'+(g.value-had); replay(add,'on'); } + var was=el.__p; if(was===p) return; el.__p=p; + part(el,'fill').style.transform='translateX('+(p*100-100).toFixed(2)+'%)'; + // Reached just now, not already full when the page opened. + if(p>=1&&was!=null&&was<1) replay(part(el,'g'),'hit'); + }); + } + function paintTimers(){ + timerEls.forEach(function(el){ + var t=pick(TIMERS,el.getAttribute('data-timer'))||(PREVIEW?SAMPLE_TIMER:null); + el.classList.toggle('ic-none',!t); if(!t) return; + var left=t.running?Math.max(0,t.ends_at_ms-Date.now()):t.left_ms; + var over=left<=0, end=el.getAttribute('data-end'); + setText(part(el,'v'),over&&end?end:clockText(left)); + el.classList.toggle('over',over); + el.classList.toggle('soon',t.running&&!over&&left<=60000); + // It only goes up while running when someone added time: say so. Not + // from 0:00, where it went up because it was started again. + var was=el.__left; el.__left=left; + if(t.running&&was>0&&left-was>1500){ var add=part(el,'add'); add.textContent='+'+clockText(left-was); replay(add,'on'); } + }); + } + // One wake-up a second, just after the shown second ends, while a clock + // this page shows (a Timer's, or one its words say) is running. A feed + // message has just written the words (render), so only a wake-up does. + function tick(woke){ + clearTimeout(clockT); clockT=0; + var words=timerWords(); + if(!timerEls.length&&!words.length) return; + paintTimers(); + if(woke&&words.length) applyText(curState,buildView(window.__ic_last||{},curState)); + var shown=timerEls.map(function(el){ return pick(TIMERS,el.getAttribute('data-timer')); }), next=0; + TIMERS.forEach(function(t){ + if(shown.indexOf(t)<0&&words.indexOf(t.id)<0) return; + var left=t.running?t.ends_at_ms-Date.now():0; + if(left>0){ var wait=(left%1000)+25; if(!next||wait) with live {{var}} support // so a Text/StatePill/Banner can read {{delay}}, {{bitrate}}, etc. - function substVars(t,view){ return t.replace(/\{\{(\w+)\}\}/g,function(m,k){ return view&&view[k]!=null?view[k]:''; }); } + function substVars(t,view){ return t.replace(/\{\{([\w.]+)\}\}/g,function(m,k){ return view&&view[k]!=null?view[k]:''; }); } function applyText(stateKey,view){ document.querySelectorAll('[data-has-txt]').forEach(function(el){ - var lbl=el.querySelector('.lbl'); if(lbl==null) return; + var lbl=el.querySelector('.lbl'); if(lbl==null||el.__fx) return; var t=el.getAttribute('data-txt-'+stateKey); if(t==null) t=el.getAttribute('data-txt-base'); - if(t!=null) lbl.textContent = (t.indexOf('{{')>=0) ? substVars(t,view) : t; + if(t==null) return; + t=(t.indexOf('{{')>=0) ? substVars(t,view) : t; + if(lbl.textContent!==t){ lbl.textContent=t; if(el.hasAttribute('data-fit')) fitText(el); } + }); + // A picture said in words: shown once there is one. + document.querySelectorAll('img[data-src-tpl]').forEach(function(img){ + var src=substVars(img.getAttribute('data-src-tpl'),view); + if(img.__src!==src){ img.__src=src; if(src) img.src=src; else img.removeAttribute('src'); img.style.visibility=src?'':'hidden'; } + }); + } + // Words kept to their width shrink until they fit it (never below 30%). + function fitText(el){ + var lbl=el.querySelector('.lbl'), room=el.clientWidth; if(!lbl||!room) return; + var now=parseFloat(el.style.getPropertyValue('--ic-fit'))||1, need=lbl.offsetWidth/now; + el.style.setProperty('--ic-fit', String(need>room ? Math.max(0.3, room/need) : 1)); + } + // (Measured again when their width changes, and once the fonts are in: + // they change the words' width.) + var fitWatch=window.ResizeObserver ? new ResizeObserver(function(seen){ seen.forEach(function(e){ fitText(e.target); }); }) : null; + document.querySelectorAll('[data-fit]').forEach(function(el){ if(fitWatch) fitWatch.observe(el); }); + // A font is only fetched once words use it (an alert's, as it plays), so + // each font arriving fits them again too. + function fitAll(){ document.querySelectorAll('[data-fit]').forEach(fitText); } + if(document.fonts){ document.fonts.ready.then(fitAll); if(document.fonts.addEventListener) document.fonts.addEventListener('loadingdone', fitAll); } + // Words that decode (scrambled letters settling, left to right) or type + // themselves in as a state begins. While they move, the words aren't + // written over; once settled, they are what they say. + var GLYPHS='ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789#%&*+<>/'; + function textFx(st){ + document.querySelectorAll('[data-txfx-'+st+']').forEach(function(el){ + var spec=el.getAttribute('data-txfx-'+st).split(':'), lbl=el.querySelector('.lbl'); + if(!lbl) return; + var kind=spec[0], dur=Math.max(100,+spec[1]||600), t0=performance.now()+(+spec[2]||0), to=lbl.textContent, run={}; + el.__fx=run; + (function frame(now){ + if(el.__fx!==run) return; + var p=Math.max(0,Math.min(1,(now-t0)/dur)), n=Math.floor(p*to.length), out=''; + if(kind==='typewriter') out=to.slice(0,n); + else for(var i=0;ix0&&y1>y0 ? {x:x0, y:y0, w:x1-x0, h:y1-y0} : null; + } + function playAlert(a){ + ALERT=a; clearTimeout(alertT); alertOn=true; + // Its own accent recolours what the design paints in its accent; with + // no picture, the layers that make room for one close the gap. + if(/^#[0-9a-f]{6}$/i.test(a.color||'')) body.style.setProperty('--ic-accent',a.color); else body.style.removeProperty('--ic-accent'); + body.toggleAttribute('data-nopic', !alertCard().image); + body.toggleAttribute('data-nomsg', !a.message); + forced='active'; body.removeAttribute('data-state'); void body.offsetWidth; lastHideKey=''; + render(window.__ic_last||{}); + postCards('play',true); + // The page around plays "The design's sound": the design's own, or its + // first card's theme's. A preview there shows just where its layers are. + var first=cardEls[0], theme=''; + if(first){ try{ theme=JSON.parse(first.getAttribute('data-look')||'{}').theme||'island'; }catch(_){} } + try{ parent.postMessage({__ic_design:{theme:theme, sound:body.getAttribute('data-alert-sound')||'', box:layersBox()}},'*'); }catch(_){} + alertT=setTimeout(function(){ alertOn=false; postCards('out',true); forced='idle'; render(window.__ic_last||{}); },Math.max(1000,+a.ms||6000)); } // CustomHTML bridge: push live state into each sandboxed iframe. @@ -789,19 +1569,33 @@ }); } function render(s){ + if(SELF&&s.saved){ + var n=s.saved[SELF]||0; + if(SAVED===null) SAVED=n; else if(n!==SAVED){ location.reload(); return; } + } var st=forced||deriveState(s); + if(s.goals) GOALS=s.goals; + if(s.timers) TIMERS=s.timers; var view=buildView(s,st); window.__ic_last=s; - if(body.getAttribute('data-state')!==st){ body.setAttribute('data-state',st); } + var entered=body.getAttribute('data-state')!==st; + if(entered){ body.setAttribute('data-state',st); } applyText(st,view); // every tick so {{vars}} stay live + if(entered) textFx(st); var key=st+'|'+(window.__ic_noHide?'1':'0'); if(key!==lastHideKey){ lastHideKey=key; applyHides(st); } curState=st; applyData(s); + if(goalEls.length) paintGoals(); + if(PREVIEW&&!alertOn) postCards('still'); + tick(false); pumpCustom(view,view); } function buildView(s,st){ + return alertView(trackerView(viewOf(s,st))); + } + function viewOf(s,st){ var br=(s.stats&&s.stats.bitrate_kbps)||0; var tgt=s.armed_delay_ms||s.buffer_target_ms||1; var pct=s.phase==='preparing'?Math.round(Math.max(0,Math.min(1,(s.buffer_fill_ms||0)/tgt))*100):(s.phase==='ready'||s.phase==='active'?100:0); @@ -812,7 +1606,8 @@ bufferPct:pct, bitrate:br, destAlive:s.destinations_alive||0, destTotal:s.destinations_total||0, cutCount:(s.stats&&s.stats.cuts)||0, - ingest:!!s.ingest_alive + ingest:!!s.ingest_alive, + goals:GOALS, timers:TIMERS }; } @@ -820,8 +1615,15 @@ // auto-hide widgets visible while the user edits a state. window.addEventListener('message',function(ev){ var d=ev.data; if(!d) return; + // From this app only: a saved overlay is sandboxed (its own origin is + // opaque, its address is the app's), the Studio's canvas is a srcdoc + // (no address, the app's origin). + if(d.__ic_alert&&ALERTS&&(ev.origin===location.origin||ev.origin===window.origin)){ playAlert(d.__ic_alert); return; } if(d.__ic_sim){ - window.__ic_noHide=!!d.noHide; forced=d.forceState||null; + PREVIEW=true; + window.__ic_noHide=!!d.noHide; + // An alert design keeps what it is doing (an alert playing) unless told. + forced=d.forceState||(ALERTS?forced||'idle':null); // replay: drop the state attribute + reflow so one-shot entrance // animations restart even when re-selecting the same state. if(d.replay){ body.removeAttribute('data-state'); void body.offsetWidth; lastHideKey=''; } @@ -868,13 +1670,13 @@ function subst(s){ document.querySelectorAll('[data-ic-tpl]').forEach(function(n){ var tpl=n.getAttribute('data-ic-tpl'); - n.textContent=tpl.replace(/\{\{(\w+)\}\}/g,function(_,k){return (s[k]!=null)?s[k]:'';}); + n.textContent=tpl.replace(/\{\{([\w.]+)\}\}/g,function(_,k){return (s[k]!=null)?s[k]:'';}); }); } // Wrap any text node containing {{ }} once, on load. window.addEventListener('DOMContentLoaded',function(){ var walk=document.createTreeWalker(document.body,NodeFilter.SHOW_TEXT,null); - var hits=[],n; while(n=walk.nextNode()){ if(/\{\{\w+\}\}/.test(n.nodeValue)) hits.push(n); } + var hits=[],n; while(n=walk.nextNode()){ if(/\{\{[\w.]+\}\}/.test(n.nodeValue)) hits.push(n); } hits.forEach(function(t){ var sp=document.createElement('span'); sp.setAttribute('data-ic-tpl',t.nodeValue); t.parentNode.replaceChild(sp,t); }); }); window.addEventListener('message',function(ev){ @@ -886,6 +1688,24 @@ })(); `; + // A script as an overlay carries it: no comment lines, no indentation. + // Line breaks stay, so the script reads (and runs) as written. + function compactJs(js) { + return js.split('\n').map(function (line) { return line.trim(); }) + .filter(function (line) { return line && line.slice(0, 2) !== '//'; }).join('\n'); + } + const RUNTIME_SHIPPED = compactJs(RUNTIME_JS); + const BRIDGE_SHIPPED = compactJs(CUSTOM_BRIDGE); + // This file's fingerprint (FNV-1a over its own source), written into + // every overlay it bakes: a saved overlay with another one was baked by + // an older Studio, and the dashboard bakes it again (Studio.upgradeSaved). + const BUILD = (function () { + const s = icOverlayModule.toString(); + let h = 0x811c9dc5; + for (let i = 0; i < s.length; i++) { h ^= s.charCodeAt(i); h = Math.imul(h, 0x01000193) >>> 0; } + return ('0000000' + h.toString(16)).slice(-8); + })(); + // ---- bake(doc) -> self-contained static HTML string ---- function bake(doc) { @@ -896,7 +1716,7 @@ doc.widgets.forEach(function (w, idx) { if (!w.id) w.id = 'w' + idx; - widgetCssAll += widgetCss(w); + widgetCssAll += widgetCss(w, doc.theme); const sc = stateCss(w); widgetCssAll += sc.css; Object.keys(sc.anims).forEach(function (k) { usedAnim[k] = true; }); @@ -937,6 +1757,11 @@ dataAttrs += ' data-level="' + r(Math.max(0, Math.min(100, +w.level))) + '"'; } if (w.kind === 'DelayReadout') dataAttrs += ' data-bind="' + esc(w.bind || 'target_delay_ms') + '"'; + if (w.kind === 'Goal') { + dataAttrs += ' data-goal="' + esc(w.goal || '') + '"'; + if (w.smooth != null) style += '--ic-sm:' + r(Math.max(0, +w.smooth)) + 's;'; + } + if (w.kind === 'Timer') dataAttrs += ' data-timer="' + esc(w.timer || '') + '" data-end="' + esc(w.end || '') + '"'; if (w.kind === 'BitrateMeter') { dataAttrs += ' data-bind="' + esc(w.bind || 'bitrate_kbps') + '"'; if (w.smooth != null) dataAttrs += ' data-smooth="' + r(Math.max(0, Math.min(0.95, w.smooth))) + '"'; @@ -944,12 +1769,19 @@ // CutCounter is intentionally excluded: its label is the live count // (driven by applyData via data-tpl), so per-state text overrides // would fight the counter. The rest take per-state text overrides. - const hasText = ['StatePill', 'Text', 'Banner'].indexOf(w.kind) >= 0; + if (w.kind === 'Text' && w.fit) dataAttrs += ' data-fit'; + const hasText = WORD_KINDS.indexOf(w.kind) >= 0; if (hasText) { - dataAttrs += ' data-has-txt data-txt-base="' + esc(w.base.text || '') + '"'; + dataAttrs += ' data-has-txt data-txt-base="' + esc(shownText(w)) + '"'; STATES.forEach(function (st) { const sp = (w.states && w.states[st.key]) || {}; if (sp.text != null) dataAttrs += ' data-txt-' + st.key + '="' + esc(sp.text) + '"'; + // Words that decode or type themselves in as this state begins: + // how, for how long, after how long (the runtime's textFx). Not + // in a state that hides them: an alert's words would scramble or + // go blank as it leaves. + const said = sp.visible !== false && animsForState(w, w.always ? {} : sp, st.key).filter(function (a) { return a && (a.style === 'decode' || a.style === 'typewriter'); })[0]; + if (said) dataAttrs += ' data-txfx-' + st.key + '="' + said.style + ':' + Math.round(+said.duration_ms || 600) + ':' + Math.round(+said.delay_ms || 0) + '"'; }); } // Per-state auto-hide-after-ms (+ exit style): show, then leave. @@ -983,8 +1815,11 @@ // Studio previews it on the dashboard's own origin. None may close the // \n\n' + bodyHtml + '\n' + - '