// @ts-check /* global SDPIComponents */ /** * Property inspector shared by all Feishin actions: shows the connection status reported by the plugin, the connection * settings under it (with the steps to enable Feishin's remote control server while there is no connection), and the * settings of the selected action in sections. */ (() => { const STRINGS = { en: { "checking": "Checking connection to Feishin…", "status.connecting": "Connecting to Feishin…", "status.connecting.hint": "{address}", "status.connected": "Connected to Feishin", "status.connected.hint": "{address}", "status.unreachable": "Feishin can't be reached", "status.unreachable.hint": "Nothing answered at {address}. Make sure Feishin is running and its remote control server is turned on. See the steps below.", "status.unauthorized": "Wrong username or password", "status.unauthorized.hint": "Feishin rejected the login. Use the same username and password in Feishin (Settings → Window → Remote) and below.", "status.error": "Can't connect to Feishin", "status.error.hint": "{detail}. Check the host and port below.", "status.change": "Change", "status.close": "Close", "volume.unknown": "Feishin hasn't reported its volume yet – it only does that when the volume changes. Change the volume in Feishin once and these controls will work (only needed once).", "section.controls": "Controls", "section.look": "On the key", "settings.volumeStep": "Step", "settings.volume": "Volume", "tiles.off": "Off", "tiles.below": "Below", "tiles.above": "Above", "settings.picture": "Picture", "picture.icon": "Icon", "picture.cover": "Album art", "settings.time": "Time", "settings.timeShows": "Shows", "shows.left": "Time left", "shows.played": "Time played", "settings.seekStep": "Jump by", "settings.dialStep": "Step per tick", "settings.dialSeek": "Seek per tick", "guide.title": "How to connect Feishin", "guide.step1": "Open Feishin on this computer and go to Settings → Window → Remote.", "guide.step2": "Turn on Enable remote control server.", "guide.step3": "Set the username to feishin and the password to streamdeck. Already using other credentials? Enter them below instead.", "guide.step4": "Keep the port at 4333, or enter your port below.", "guide.done": "The status at the top turns green as soon as the connection works.", "connection.shared": "These settings apply to all Feishin actions. Leave a field empty to use the default.", "connection.host": "Host", "connection.port": "Port", "connection.username": "Username", "connection.password": "Password", "connection.reconnect": "Reconnect", }, de: { "checking": "Verbindung zu Feishin wird geprüft…", "status.connecting": "Verbinde mit Feishin…", "status.connecting.hint": "{address}", "status.connected": "Mit Feishin verbunden", "status.connected.hint": "{address}", "status.unreachable": "Feishin ist nicht erreichbar", "status.unreachable.hint": "Unter {address} antwortet nichts. Stelle sicher, dass Feishin läuft und die Fernsteuerung aktiviert ist. Siehe die Schritte unten.", "status.unauthorized": "Falscher Benutzername oder falsches Passwort", "status.unauthorized.hint": "Feishin hat die Anmeldung abgelehnt. Verwende in Feishin (Einstellungen → Fenster → Fernsteuerung) und unten denselben Benutzernamen und dasselbe Passwort.", "status.error": "Keine Verbindung zu Feishin möglich", "status.error.hint": "{detail}. Prüfe Host und Port unten.", "status.change": "Ändern", "status.close": "Schließen", "volume.unknown": "Feishin hat seine Lautstärke noch nicht gemeldet – das passiert erst, wenn sie sich ändert. Ändere die Lautstärke einmal in Feishin, danach funktionieren diese Regler (nur einmal nötig).", "section.controls": "Bedienung", "section.look": "Auf der Taste", "settings.volumeStep": "Schrittweite", "settings.volume": "Lautstärke", "tiles.off": "Aus", "tiles.below": "Unten", "tiles.above": "Oben", "settings.picture": "Bild", "picture.icon": "Symbol", "picture.cover": "Albumcover", "settings.time": "Zeit", "settings.timeShows": "Zeigt", "shows.left": "Restzeit", "shows.played": "Gespielte Zeit", "settings.seekStep": "Springen um", "settings.dialStep": "Schritt pro Raste", "settings.dialSeek": "Spulen pro Raste", "guide.title": "So verbindest du Feishin", "guide.step1": "Öffne Feishin auf diesem Computer und gehe zu Einstellungen → Fenster → Fernsteuerung.", "guide.step2": "Schalte Server für Fernsteuerung aktivieren ein.", "guide.step3": "Setze den Benutzernamen auf feishin und das Passwort auf streamdeck. Du nutzt schon andere Zugangsdaten? Dann trage sie stattdessen unten ein.", "guide.step4": "Lass den Port auf 4333 oder trage deinen Port unten ein.", "guide.done": "Sobald die Verbindung steht, wird der Status oben grün.", "connection.shared": "Diese Einstellungen gelten für alle Feishin-Aktionen. Leere Felder verwenden den Standardwert.", "connection.host": "Host", "connection.port": "Port", "connection.username": "Benutzer", "connection.password": "Passwort", "connection.reconnect": "Neu verbinden", }, }; /** @type {Record} */ let strings = pickStrings(navigator.language); /** @typedef {{ status: string; address: string; detail?: string; volumeKnown?: boolean }} Connection */ /** @type {Connection | undefined} */ let connection; const client = SDPIComponents.streamDeckClient; const statusEl = /** @type {HTMLElement} */ (document.getElementById("status")); const statusTitle = /** @type {HTMLElement} */ (document.getElementById("status-title")); const statusHint = /** @type {HTMLElement} */ (document.getElementById("status-hint")); const statusAction = /** @type {HTMLButtonElement} */ (document.getElementById("status-action")); const connectionBox = /** @type {HTMLElement} */ (document.getElementById("connection")); const guide = /** @type {HTMLElement} */ (document.getElementById("guide")); const volumeUnknown = /** @type {HTMLElement} */ (document.getElementById("volume-unknown")); const volumeTiles = /** @type {HTMLElement} */ (document.getElementById("volume-tiles")); const picture = /** @type {HTMLElement} */ (document.getElementById("picture")); const timeTiles = /** @type {HTMLElement} */ (document.getElementById("time-tiles")); const timeShowsRow = /** @type {HTMLElement} */ (document.getElementById("time-shows-row")); const timeShows = /** @type {HTMLElement} */ (document.getElementById("time-shows")); /** * The connection box: open while there is a problem (autoOpen), unless the user opened or closed it (setupOpen). * @type {boolean | null} */ let setupOpen = null; let autoOpen = false; /** The selected action. */ let action = ""; // The key pictures the volume tiles show, per action. const VOLUME_ICONS = /** @type {Record} */ ({ "de.felitendo.feishin.volumeup": "up", "de.felitendo.feishin.volumedown": "down", "de.felitendo.feishin.mute": "unmuted", }); const [getShowVolume, setShowVolume] = SDPIComponents.useSettings("showVolume", () => refreshTiles()); const [getVolumeAt, setVolumeAt] = SDPIComponents.useSettings("volumeAt", () => refreshTiles()); const [getShowCover, setShowCover] = SDPIComponents.useSettings("showCover", () => refreshTiles()); const [getShowTime, setShowTime] = SDPIComponents.useSettings("showTime", () => refreshTiles()); const [getTimeAt, setTimeAt] = SDPIComponents.useSettings("timeAt", () => refreshTiles()); const [getTimeShows, setTimeShows] = SDPIComponents.useSettings("timeShows", () => refreshTiles()); // Rows like in obs-tools: 30 % for the label, the rest for the field. sdpi-item has a fixed 95 px + 241 px grid. for (const item of document.querySelectorAll("sdpi-item")) { const style = document.createElement("style"); // More specific than its own ".grid", which comes later. style.textContent = ".container.grid { grid-template-columns: 30% minmax(0, 1fr); }"; item.shadowRoot?.append(style); } for (const source of document.querySelectorAll("select.choice")) { dropdown(/** @type {HTMLSelectElement} */ (source)); } localize(); renderBox(); client.getConnectionInfo().then(({ info, actionInfo }) => { strings = pickStrings(info?.application?.language); action = actionInfo.action; localize(); if (connection) { renderConnection(connection); } for (const part of document.querySelectorAll(".action-section")) { const actions = (part.getAttribute("data-actions") ?? "").split(/\s+/); /** @type {HTMLElement} */ (part).hidden = !actions.includes(action); } for (const section of document.querySelectorAll(".section")) { /** @type {HTMLElement} */ (section).hidden = !section.querySelector(".action-section:not([hidden])"); } renderBox(); refreshTiles(); client.send("sendToPlugin", { event: "getConnection" }); }); client.sendToPropertyInspector.subscribe((/** @type {{ payload?: any }} */ ev) => { if (ev.payload?.event === "connection") { renderConnection(ev.payload); } }); document.getElementById("reconnect")?.addEventListener("click", () => { client.send("sendToPlugin", { event: "reconnect" }); }); statusAction.addEventListener("click", () => { setupOpen = !(setupOpen ?? autoOpen); renderBox(); }); /** * @param {Connection} next */ function renderConnection(next) { connection = next; statusEl.dataset.status = next.status; statusEl.classList.toggle("quiet", next.status === "connected"); statusTitle.textContent = t(`status.${next.status}`); statusHint.textContent = t(`status.${next.status}.hint`) .replace("{address}", next.address) .replace("{detail}", next.detail || next.address); volumeUnknown.hidden = next.status !== "connected" || next.volumeKnown !== false; // A new problem opens the connection box, a working connection closes it. "Connecting" while Feishin is away // changes nothing, or the box would flicker with each try. const problem = ["unreachable", "unauthorized", "error"].includes(next.status); if ((next.status === "connected" || problem) && problem !== autoOpen) { autoOpen = problem; setupOpen = null; } guide.hidden = next.status === "connected"; renderBox(); } function renderBox() { const open = setupOpen ?? autoOpen; connectionBox.hidden = !open; statusAction.textContent = t(open ? "status.close" : "status.change"); statusAction.setAttribute("aria-expanded", String(open)); repaint(); } async function refreshTiles() { renderLook({ showVolume: await getShowVolume(), volumeAt: await getVolumeAt(), showCover: await getShowCover(), showTime: await getShowTime(), timeAt: await getTimeAt(), timeShows: await getTimeShows(), }); } /** * The rows of "On the key". A click renders them with the new value right away, because sdpi-components saves a * setting only after a short wait. * @param {Record} s The settings the rows need. */ function renderLook(s) { const icon = VOLUME_ICONS[action]; if (icon) { renderTiles(volumeTiles, s.showVolume === false ? "off" : s.volumeAt === "above" ? "above" : "below", { picture: (option) => keyPicture(`volume/${icon}`, 15.6, option === "off" ? "" : "50%", option), pick: (option) => { setShowVolume(option !== "off"); if (option !== "off") { setVolumeAt(option); } renderLook({ ...s, showVolume: option !== "off", volumeAt: option === "off" ? s.volumeAt : option }); }, }); } if (action === "de.felitendo.feishin.playpause") { const cover = s.showCover === true; renderSegments(picture, "picture", ["icon", "cover"], cover ? "cover" : "icon", (option) => { setShowCover(option === "cover"); renderLook({ ...s, showCover: option === "cover" }); }); const shows = s.timeShows === "played" ? "played" : "left"; const time = shows === "played" ? "1:05" : "-2:31"; // The key as it looks while playing. renderTiles(timeTiles, s.showTime === false ? "off" : s.timeAt === "above" ? "above" : "below", { picture: (option) => { const text = option === "off" ? "" : time; return cover ? coverPicture(text, option) : keyPicture("play-pause/pause", 15.5, text, option); }, pick: (option) => { setShowTime(option !== "off"); if (option !== "off") { setTimeAt(option); } renderLook({ ...s, showTime: option !== "off", timeAt: option === "off" ? s.timeAt : option }); }, }); timeShowsRow.hidden = s.showTime === false; renderSegments(timeShows, "shows", ["left", "played"], shows, (option) => { setTimeShows(option); renderLook({ ...s, timeShows: option }); }); } repaint(); } /** * Choices side by side. * @param {HTMLElement} segments * @param {string} name Their texts are "name.option". * @param {string[]} options * @param {string} choice * @param {(option: string) => void} pick */ function renderSegments(segments, name, options, choice, pick) { segments.replaceChildren( ...options.map((option) => { const button = document.createElement("button"); button.type = "button"; button.className = option === choice ? "on" : ""; button.setAttribute("role", "radio"); button.setAttribute("aria-checked", String(option === choice)); button.textContent = t(`${name}.${option}`); button.addEventListener("click", () => pick(option)); return button; }), ); } /** * Where the key shows its text: not at all, below or above the icon. Tiles with a small picture of the key. * @param {HTMLElement} tiles * @param {string} choice "off", "below" or "above" * @param {{ picture: (option: string) => string; pick: (option: string) => void }} how The picture of the key with * a choice, and what picking it does. */ function renderTiles(tiles, choice, how) { tiles.replaceChildren( ...["off", "below", "above"].map((option) => { const tile = document.createElement("button"); tile.type = "button"; tile.className = option === choice ? "tile on" : "tile"; tile.setAttribute("role", "radio"); tile.setAttribute("aria-checked", String(option === choice)); tile.addEventListener("click", () => how.pick(option)); const key = document.createElement("span"); key.className = "tile-key"; key.innerHTML = how.picture(option); const name = document.createElement("span"); name.className = "tile-name"; name.textContent = t(`tiles.${option}`); tile.append(key, name); return tile; }), ); } /** * A small picture of the key, drawn like svgWithTitle in src/images.ts: icon and text together in the middle, 16 px * apart. The text is under or above the icon. * @param {string} icon Icon in imgs/actions, without ".svg". Its view box is 40 units wide. * @param {number} height How tall the icon is, in units of its view box. * @param {string} text Text on the key, "" for none. * @param {string} at "below" or "above" */ function keyPicture(icon, height, text, at) { const ink = height * 3.6; const above = at === "above"; const shift = text ? 16 : 0; const baseline = above ? (144 - ink) / 2 + shift - 16 : (144 + ink) / 2 + shift; const picture = ``; const title = text ? `${text}` : ""; return `${picture}${title}`; } /** * A small picture of the key with a cover, drawn like svgWithCover in src/images.ts. A made up cover stands in for * the one of the song. * @param {string} text Text on the key, "" for none. * @param {string} at "below" or "above" */ function coverPicture(text, at) { const cover = `` + ``; if (!text) { return `${cover}`; } const above = at === "above"; // Several pictures are on the page, and ids are for the whole page. const id = `shade-${at}`; const shade = `` + `` + ``; const title = `${text}`; return `${cover}${shade}${title}`; } /** @type {{ root: HTMLElement; close: () => void } | null} */ let openDropdown = null; function closeDropdown() { const current = openDropdown; openDropdown = null; current?.close(); } document.addEventListener("mousedown", (e) => { if (openDropdown && !openDropdown.root.contains(/** @type {Node} */ (e.target))) { closeDropdown(); } }); /** * A drop-down like in obs-tools, in place of a hidden