// @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 ``;
}
/**
* 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 ``;
}
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 ``;
}
/** @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