From db53c58ae614b42db9636290b9ae620f2d0d43dd Mon Sep 17 00:00:00 2001 From: Simon Ziegler Date: Thu, 25 Apr 2024 23:05:54 +0200 Subject: [PATCH 01/50] stretch pdf-output to resolution to behave similar to media-items --- .prettierrc.json | 2 +- src/server/PlaylistItems/PDF.ts | 11 ++--------- 2 files changed, 3 insertions(+), 10 deletions(-) diff --git a/.prettierrc.json b/.prettierrc.json index 9d24913..85386b9 100644 --- a/.prettierrc.json +++ b/.prettierrc.json @@ -8,5 +8,5 @@ "bracketSpacing": true, "quoteProps": "as-needed", "vueIndentScriptAndStyle": true, - "endOfLine": "lf" + "endOfLine": "auto" } \ No newline at end of file diff --git a/src/server/PlaylistItems/PDF.ts b/src/server/PlaylistItems/PDF.ts index 7d94839..befe3b6 100644 --- a/src/server/PlaylistItems/PDF.ts +++ b/src/server/PlaylistItems/PDF.ts @@ -68,20 +68,13 @@ export default class PDF extends PlaylistItemBase { ); const scale = Math.min(...Object.values(scales)); - const canvas = Canvas.createCanvas( - casparcg_resolution.width, - casparcg_resolution.height - ); + const canvas = Canvas.createCanvas(viewport.width * scale, viewport.height * scale); await page.render({ // eslint-disable-next-line @typescript-eslint/naming-convention canvasContext: canvas.getContext("2d") as unknown as CanvasRenderingContext2D, /* eslint-disable @typescript-eslint/naming-convention */ - viewport: page.getViewport({ - scale, - offsetY: (casparcg_resolution.height - scale * viewport.height) / 2, - offsetX: (casparcg_resolution.width - scale * viewport.width) / 2 - }), + viewport: page.getViewport({ scale }), /* eslint-enable @typescript-eslint/naming-convention */ background: "#000000" }).promise; From 3fdb29ba14e582803eef6c69637fa09766d8120d Mon Sep 17 00:00:00 2001 From: Simon Ziegler Date: Fri, 26 Apr 2024 01:07:48 +0200 Subject: [PATCH 02/50] moved companion-control from osc to websocket --- README.md | 1 - build-scripts/build.ts | 1 - config.json | 7 -- src/server/CasparCG.ts | 2 + src/server/JGCPReceiveMessages.ts | 5 ++ src/server/config.ts | 24 ------- src/server/control.ts | 96 +++++++++++--------------- src/server/main.ts | 6 +- src/server/servers/osc-server.ts | 91 ------------------------ src/server/servers/websocket-server.ts | 2 +- 10 files changed, 48 insertions(+), 187 deletions(-) delete mode 100644 src/server/servers/osc-server.ts diff --git a/README.md b/README.md index 80f18ff..ffa444f 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,6 @@ Generate graphics with song-lyrics or for other church-service-elements and play ## roadmap - implement more playlist-items: Text -- companion integration (buttons for song parts -> send name to casparcg) - client-messages: create message-log, group same - fix "Buffer() is deprecated" - load files from disc always at item selection to stay up to date diff --git a/build-scripts/build.ts b/build-scripts/build.ts index fa27767..65e74d7 100644 --- a/build-scripts/build.ts +++ b/build-scripts/build.ts @@ -68,7 +68,6 @@ config_file.path = { playlist: "Playlist/", bible: "Bible/Luther-Bibel.json" }; -config_file.companion.address = "172.0.0.1"; config_file.log_level = "INFO"; fs.writeFileSync(path.join(release_dir, "config.json"), JSON.stringify(config_file, undefined, "\t")); diff --git a/config.json b/config.json index d8a6bc8..de932ec 100644 --- a/config.json +++ b/config.json @@ -31,12 +31,5 @@ "websocket": { "port": 8765 } - }, - "osc_server": { - "port": 8766 - }, - "companion": { - "address": "192.168.178.20", - "osc_port": 12321 } } \ No newline at end of file diff --git a/src/server/CasparCG.ts b/src/server/CasparCG.ts index aa4e0eb..8998cb9 100644 --- a/src/server/CasparCG.ts +++ b/src/server/CasparCG.ts @@ -4,6 +4,7 @@ import { logger } from "./logger"; import { XMLParser } from "fast-xml-parser"; interface CasparCGPathsSettings { + /* eslint-disable @typescript-eslint/naming-convention */ /* eslint-disable @typescript-eslint/naming-convention */ "data-path": string; "initial-path": string; @@ -11,6 +12,7 @@ interface CasparCGPathsSettings { "media-path": string; "template-path": string; /* eslint-enable @typescript-eslint/naming-convention */ + /* eslint-enable @typescript-eslint/naming-convention */ } export interface CasparCGResolution { diff --git a/src/server/JGCPReceiveMessages.ts b/src/server/JGCPReceiveMessages.ts index 5645194..2d53982 100644 --- a/src/server/JGCPReceiveMessages.ts +++ b/src/server/JGCPReceiveMessages.ts @@ -58,6 +58,10 @@ export interface SetVisibility extends Base { visibility: boolean; } +export interface ToggleVisibility extends Base { + command: "toggle_visibility"; +} + export interface SelectItemSlide extends Base { command: "select_item_slide"; item: number; @@ -109,6 +113,7 @@ export interface DeleteItem { export type Message = | RequestItemSlides | SetVisibility + | ToggleVisibility | OpenPlaylist | Navigate | SelectItemSlide diff --git a/src/server/config.ts b/src/server/config.ts index e0d45a0..ea41323 100644 --- a/src/server/config.ts +++ b/src/server/config.ts @@ -40,13 +40,6 @@ export interface ConfigJSON { port: number; }; }; - osc_server: { - port: number; - }; - companion: { - address: string; - osc_port: number; - }; } const config_path = "config.json"; @@ -84,13 +77,6 @@ const config_template: ConfigJSON = { websocket: { port: 0 } - }, - osc_server: { - port: 0 - }, - companion: { - address: "template", - osc_port: 0 } }; @@ -163,8 +149,6 @@ class ConfigClass { ); file_check &&= check_valid_port(config?.client_server?.http?.port); file_check &&= check_valid_port(config?.client_server?.websocket?.port); - file_check &&= check_valid_port(config?.osc_server?.port); - file_check &&= check_valid_port(config?.companion?.osc_port); return file_check; } @@ -230,14 +214,6 @@ class ConfigClass { return structuredClone(this.config.client_server); } - get osc_server(): ConfigJSON["osc_server"] { - return structuredClone(this.config["osc_server"]); - } - - get companion(): ConfigJSON["companion"] { - return structuredClone(this.config.companion); - } - get behaviour(): ConfigJSON["behaviour"] { return structuredClone(this.config.behaviour); } diff --git a/src/server/control.ts b/src/server/control.ts index bcbb53b..2da42ef 100644 --- a/src/server/control.ts +++ b/src/server/control.ts @@ -6,8 +6,6 @@ import tmp from "tmp"; import Playlist from "./Playlist.ts"; import type { ActiveItemSlide } from "./Playlist.ts"; -import OSCServer from "./servers/osc-server.ts"; -import type { OSCFunctionMap, OSCServerArguments } from "./servers/osc-server.ts"; import WebsocketServer from "./servers/websocket-server.ts"; import type { WebsocketServerArguments, @@ -32,34 +30,11 @@ export interface CasparCGConnection { export default class Control { private playlist: Playlist; private ws_server: WebsocketServer; - private osc_server: OSCServer; private search_part: SearchPart; - // mapping of the OSC-commands to the functions - private readonly osc_function_map: OSCFunctionMap = { - control: { - playlist_item: { - navigate: { - direction: (value: number) => this.navigate("item", value) - } - }, - item_slide: { - navigate: { - direction: (value: number) => this.navigate("slide", value) - } - }, - output: { - visibility: { - set: (value: boolean) => this.set_visibility(value), - toggle: (value: string) => this.toggle_visibility(value) - } - } - } - }; - // mapping of the websocket-messages to the functions // eslint-disable-next-line @typescript-eslint/ban-types - private readonly ws_function_map: { [T in JGCPRecv.Message["command"]]: Function } = { + private readonly client_ws_function_map: { [T in JGCPRecv.Message["command"]]: Function } = { new_playlist: (msg: JGCPRecv.NewPlaylist, ws: WebSocket) => this.new_playlist(ws), load_playlist: (msg: JGCPRecv.OpenPlaylist, ws: WebSocket) => this.load_playlist(msg?.playlist, ws), @@ -72,6 +47,7 @@ export default class Control { this.navigate(msg?.type, msg?.steps, msg?.client_id, ws), set_visibility: (msg: JGCPRecv.SetVisibility, ws: WebSocket) => this.set_visibility(msg.visibility, msg.client_id, ws), + toggle_visibility: () => this.toggle_visibility(), move_playlist_item: (msg: JGCPRecv.MovePlaylistItem, ws: WebSocket) => this.move_playlist_item(msg.from, msg.to, ws), add_item: (msg: JGCPRecv.AddItem, ws: WebSocket) => @@ -93,21 +69,19 @@ export default class Control { JGCP: { open: (ws: WebSocket) => this.ws_on_connection(ws), message: (ws: WebSocket, data: RawData) => this.ws_on_message(ws, data) + }, + // eslint-disable-next-line @typescript-eslint/naming-convention + "": { + open: (ws: WebSocket) => this.ws_on_connection(ws), + message: (ws: WebSocket, data: RawData) => this.ws_on_message(ws, data) } }; - constructor( - ws_server_parameters: WebsocketServerArguments, - osc_server_parameters: OSCServerArguments - ) { + constructor(ws_server_parameters: WebsocketServerArguments) { // initialize the websocket server logger.log("starting websocket-server"); this.ws_server = new WebsocketServer(ws_server_parameters, this.ws_message_handler); - // initialize the osc server - logger.log("starting osc-server"); - this.osc_server = new OSCServer(osc_server_parameters, this.osc_function_map); - this.playlist = new Playlist(); this.search_part = new SearchPart(); @@ -165,7 +139,7 @@ export default class Control { logger.debug("sending playlist to client"); - ws.send(JSON.stringify(message)); + ws?.send(JSON.stringify(message)); ws_send_response(`playlist has been send to client`, true, ws); } @@ -187,7 +161,7 @@ export default class Control { if (ws) { logger.debug("sending playlist to client"); - ws.send(JSON.stringify(response_playlist_items)); + ws?.send(JSON.stringify(response_playlist_items)); } else { logger.debug("sending playlist to all clients"); @@ -201,7 +175,7 @@ export default class Control { if (ws) { logger.debug("sending state to client"); - ws.send(JSON.stringify(message)); + ws?.send(JSON.stringify(message)); } else { logger.debug("sending state to all clients"); @@ -533,7 +507,7 @@ export default class Control { files }; - ws.send(JSON.stringify(message)); + ws?.send(JSON.stringify(message)); } } @@ -556,7 +530,7 @@ export default class Control { bible }; - ws.send(JSON.stringify(message)); + ws?.send(JSON.stringify(message)); } private get_item_file(type: JGCPRecv.GetItemData["type"], path: string, ws: WebSocket) { @@ -570,7 +544,7 @@ export default class Control { files }; - ws.send(JSON.stringify(message)); + ws?.send(JSON.stringify(message)); } private create_playlist_pdf(ws: WebSocket, type: JGCPRecv.CreatePlaylistPDF["type"]) { @@ -622,25 +596,24 @@ export default class Control { fs.rm(pdf_file.name, () => {}); } - ws.send(JSON.stringify(message)); + ws?.send(JSON.stringify(message)); }); } - private async toggle_visibility(osc_feedback_path?: string) { - logger.log(`toggling CasparCG-visibility: '${this.playlist.visibility ? "hidden" : "true"}'`); - - let visibility_feedback = false; + private async toggle_visibility() { + logger.log( + `toggling CasparCG-visibility: '${this.playlist.visibility ? "hidden" : "visible"}'` + ); - visibility_feedback = await this.playlist.toggle_visibility(); + const message: JGCPSend.State = { + command: "state", + visibility: await this.playlist.toggle_visibility() + }; - // if a feedback-path is given, write the feedback to it - if (osc_feedback_path !== undefined) { - this.osc_server.send_value(osc_feedback_path, visibility_feedback); - } + this.send_all_clients(message); - this.send_all_clients({ - command: "state", - visibility: visibility_feedback + this.ws_server.get_connections("").forEach((ws_client) => { + ws_client?.send(JSON.stringify(message)); }); } @@ -649,12 +622,21 @@ export default class Control { * @param message JSON-message to be sent */ private send_all_clients(message: JGCPSend.Message) { + const message_string = JSON.stringify(message); + // gather all the clients const ws_clients = this.ws_server.get_connections("JGCP"); ws_clients.forEach((ws_client) => { - ws_client.send(JSON.stringify(message)); + ws_client.send(message_string); }); + + // if the command is "state" and includes "visibility" + if (message.command === "state" && typeof message.visibility === "boolean") { + this.ws_server.get_connections("").forEach((ws) => { + ws.send(message_string); + }); + } } /** @@ -679,7 +661,7 @@ export default class Control { command: "clear" }; - ws.send(JSON.stringify(clear_message)); + ws?.send(JSON.stringify(clear_message)); } } @@ -717,11 +699,11 @@ export default class Control { ws_send_response("'command' is not of type 'string", false, ws); return; - } else if (!Object.keys(this.ws_function_map).includes(data.command)) { + } else if (!Object.keys(this.client_ws_function_map).includes(data.command)) { logger.error("can't parse JGCP-message: 'comand' is not implemented"); ws_send_response(`command '${data.command}' is not implemented`, false, ws); } else { - void this.ws_function_map[data.command](data as never, ws); + void this.client_ws_function_map[data.command](data as never, ws); } } diff --git a/src/server/main.ts b/src/server/main.ts index a7829f5..0d34fc1 100644 --- a/src/server/main.ts +++ b/src/server/main.ts @@ -7,8 +7,4 @@ import Config from "./config.ts"; // http-server new HTTPServer(Config.client_server.http.port); -new Control(Config.client_server.websocket, { - port_receive: Config.osc_server.port, - address_send: Config.companion.address, - port_send: Config.companion.osc_port -}); +new Control(Config.client_server.websocket); diff --git a/src/server/servers/osc-server.ts b/src/server/servers/osc-server.ts deleted file mode 100644 index 7a2a4dc..0000000 --- a/src/server/servers/osc-server.ts +++ /dev/null @@ -1,91 +0,0 @@ -import { Client as ClientOSC, Server as ServerOSC } from "node-osc"; -import type { ArgumentType as ArgumentTypeOSC } from "node-osc"; -import { logger } from "../logger"; - -export interface OSCServerArguments { - port_receive: number; - address_send: string; - port_send: number; -} - -export type OSCFunctionMap = { - [key: string]: - | OSCFunctionMap - | ((value: boolean) => void | Promise) - | ((value: number) => void | Promise) - | ((value: string) => void | Promise); -}; - -export default class OSCServer { - // private osc_server: osc.UDPPort; - private osc_server: ServerOSC; - private osc_client: ClientOSC; - - private function_map: OSCFunctionMap; - - constructor(args: OSCServerArguments, function_map: OSCFunctionMap) { - this.function_map = function_map; - - this.osc_server = new ServerOSC(args.port_receive, "0.0.0.0"); - - this.osc_client = new ClientOSC(args.address_send, args.port_send); - - this.osc_server.on("message", (osc_msg) => { - logger.debug(`received OSC-command (${JSON.stringify(osc_msg)})`); - - const parts = osc_msg[0].split("/"); - - // remove the first empty elementn from the leading slash - parts.shift(); - - // execute the command map - this.execute_command(parts, this.function_map, osc_msg[1]); - }); - } - - private execute_command(path: string[], command_tree: OSCFunctionMap, value: ArgumentTypeOSC) { - if (path.length > 1) { - const path_part = path.shift(); - - if (path_part !== undefined) { - const traversed_command_tree = command_tree[path_part]; - - if (typeof traversed_command_tree === "object") { - this.execute_command(path, traversed_command_tree, value); - } - } - } else { - const command = command_tree[path[0]]; - - if (typeof command === "function") { - void command(value as never); - } - } - } - - send_value(path: string, value: ArgumentTypeOSC) { - let type: string | undefined; - - switch (typeof value) { - case "number": - type = value.toString().includes(".") ? "f" : "i"; - break; - case "boolean": - type = "i"; - value = Number(value); - break; - case "string": - type = "s"; - break; - } - - // if the type is undefined, the type is not supported -> exit - if (type === undefined) { - logger.warn("can't send OSC-command: invalid type"); - return; - } - - logger.debug(`sending OSC-command: '${path} ${JSON.stringify(value)}'`); - this.osc_client.send([path, value]); - } -} diff --git a/src/server/servers/websocket-server.ts b/src/server/servers/websocket-server.ts index fd1692c..700b9f6 100644 --- a/src/server/servers/websocket-server.ts +++ b/src/server/servers/websocket-server.ts @@ -99,7 +99,7 @@ export default class WebsocketServer { ws.close(); // remove the connection from the list - const index = this.connections[ws.protocol].indexOf(ws); + const index = this.connections[ws.protocol]?.indexOf(ws); if (index > -1) { this.connections[ws.protocol].splice(index, 1); From 8bddf4a4d0c8dd0061a07ba18d2d43b718c199d5 Mon Sep 17 00:00:00 2001 From: Simon Ziegler Date: Fri, 26 Apr 2024 01:57:30 +0200 Subject: [PATCH 03/50] Parsing song-file-chords --- README.md | 2 +- src/server/PlaylistItems/SongFile.ts | 49 +++++++++++++++++++++++++--- 2 files changed, 46 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index ffa444f..5eb28ef 100644 --- a/README.md +++ b/README.md @@ -34,5 +34,5 @@ Generate graphics with song-lyrics or for other church-service-elements and play - modify client to avoid props-down-chaining (instead ts-file like `config.ts` or `logger.ts`) - create example-files for songs and psalm - move template-jump()-function into update() to prevent error messages in casparcg-log -- parse chords in songfile in preparation for chord-view - casparcg: if no connection on startup possible: try periodically to reconnect +- song-file: chords itself as chords and transpose them diff --git a/src/server/PlaylistItems/SongFile.ts b/src/server/PlaylistItems/SongFile.ts index 199e9ce..60c3411 100644 --- a/src/server/PlaylistItems/SongFile.ts +++ b/src/server/PlaylistItems/SongFile.ts @@ -54,6 +54,7 @@ export interface SongFileMetadata { Translation?: string; Copyright?: string; LangCount: number; + Chords?: Chords; /* eslint-enable @typescript-eslint/naming-convention */ } @@ -90,6 +91,8 @@ export type ItemPartClient = TitlePartClient | LyricPartClient; export type SongPart = string[][][]; export type SongParts = Record; +export type Chords = Record>; + /** * processes and saves song-files (*.sng) * They should be compatible with those created by songbeamer (no guarantee given) @@ -111,7 +114,7 @@ export default class SongFile { this.song_file_path = path; if (path !== undefined) { - this.parse_song_text(); + this.parse_song_file(); } } @@ -119,7 +122,7 @@ export default class SongFile { * parses the metadata in a text header * @param header a string representing the header */ - private parse_text_header(header: string): void { + private parse_metadata(header: string): void { // split the header into the individual lines const header_data: string[] = header.split(/\r?\n/); @@ -154,14 +157,52 @@ export default class SongFile { case "LangCount": this.metadata[key] = Number(value); break; + case "Chords": + this.metadata.Chords = this.parse_base64_chords(value); + break; default: break; } }); } + private parse_base64_chords(base64: string): Chords { + const return_object: Chords = {}; + + const chords = Buffer.from(base64, "base64").toString(); + + const chord_regex = /(?\d+),(?\d+),(?.*)\r/g; + + let match = chord_regex.exec(chords); + while (match !== null) { + const check_number = (val: string): number | false => { + const number = Number(val); + + if (Number.isNaN(number) || !Number.isInteger(number) || number < -1) { + return false; + } else { + return number; + } + }; + + const line = check_number(match.groups?.line); + const position = check_number(match.groups?.position); + const chord = match.groups?.chord; + + if (line !== false && position !== false && typeof chord === "string") { + return_object[line] ??= {}; + + return_object[line][position] = chord; + } + + match = chord_regex.exec(chords); + } + + return return_object; + } + // parse the text-content - private parse_song_text() { + private parse_song_file() { if (!this.song_file_path) { return; } @@ -188,7 +229,7 @@ export default class SongFile { const data = raw_data.split(/\r?\n---?(?:\r?\n|$)/); // parse metadata of the header - this.parse_text_header(data[0]); + this.parse_metadata(data[0]); // remove the header the array data.splice(0, 1); From ebd49c9acc53822467d0266461b4ccd0ad1bffee Mon Sep 17 00:00:00 2001 From: Simon Ziegler Date: Mon, 29 Apr 2024 16:17:14 +0200 Subject: [PATCH 04/50] Parsing and storing Chords --- build-scripts/server-debug.js | 3 +- .../ItemDialogue/SongPartSelector.vue | 2 +- src/server/PlaylistItems/Song.ts | 5 +- src/server/PlaylistItems/SongFile/Chord.ts | 73 +++++++++++++++++++ .../PlaylistItems/{ => SongFile}/SongFile.ts | 29 +++++--- src/server/search_part.ts | 2 +- src/templates/Song.ts | 2 +- 7 files changed, 98 insertions(+), 18 deletions(-) create mode 100644 src/server/PlaylistItems/SongFile/Chord.ts rename src/server/PlaylistItems/{ => SongFile}/SongFile.ts (93%) diff --git a/build-scripts/server-debug.js b/build-scripts/server-debug.js index 15631d8..75db3f5 100644 --- a/build-scripts/server-debug.js +++ b/build-scripts/server-debug.js @@ -9,6 +9,7 @@ esbuild.build({ sourcemap: true, external: [ "pdfjs-dist", - "canvas" + "canvas", + "note-art" ] }); \ No newline at end of file diff --git a/src/client/src/ControlWindow/ItemDialogue/SongPartSelector.vue b/src/client/src/ControlWindow/ItemDialogue/SongPartSelector.vue index 7ebf8d6..cc3a728 100644 --- a/src/client/src/ControlWindow/ItemDialogue/SongPartSelector.vue +++ b/src/client/src/ControlWindow/ItemDialogue/SongPartSelector.vue @@ -8,7 +8,7 @@ import MenuButton from "@/ControlWindow/MenuBar/MenuButton.vue"; import type { SongFile } from "@server/search_part"; - import type { SongPart } from "@server/PlaylistItems/SongFile"; + import type { SongPart } from "@server/PlaylistItems/SongFile/SongFile"; library.add(fas.faAdd, fas.faTrash, fas.faPlus, fas.faXmark, fas.faCheck); diff --git a/src/server/PlaylistItems/Song.ts b/src/server/PlaylistItems/Song.ts index 5c21124..4370a32 100644 --- a/src/server/PlaylistItems/Song.ts +++ b/src/server/PlaylistItems/Song.ts @@ -3,8 +3,8 @@ import { recurse_object_check } from "../lib.ts"; import { logger } from "../logger.ts"; import { PlaylistItemBase } from "./PlaylistItem.ts"; import type { ClientItemBase, ClientItemSlidesBase, ItemPropsBase } from "./PlaylistItem.ts"; -import SongFile from "./SongFile.ts"; -import type { ItemPart, LyricPart } from "./SongFile.ts"; +import SongFile from "./SongFile/SongFile.ts"; +import type { Chords, ItemPart, LyricPart } from "./SongFile/SongFile.ts"; export interface SongTemplate { template: "JohnCG/Song"; @@ -23,6 +23,7 @@ export type ClientSongItem = SongProps & ClientItemBase; export interface SongTemplateData { parts: ItemPart[]; languages?: number[]; + chords?: Chords; slide: number; } diff --git a/src/server/PlaylistItems/SongFile/Chord.ts b/src/server/PlaylistItems/SongFile/Chord.ts new file mode 100644 index 0000000..e2e7c2e --- /dev/null +++ b/src/server/PlaylistItems/SongFile/Chord.ts @@ -0,0 +1,73 @@ +export default class Chord { + private note: string; + private chord_descriptors: string = ""; + private bass_note?: string; + + readonly invalid: boolean = false; + + constructor(note: string) { + const regex_chord_parts = /(?\w[<#=]?)(?[\w\d]*?)(?:\/(?\w[<#=]))?/; + + const result = regex_chord_parts.exec(note); + + if (result) { + if (result.groups?.Note) { + this.note = standardize_note(result.groups?.Note); + } else { + this.invalid = true; + + throw new SyntaxError("invalid chord"); + } + + if (result.groups?.Bass) { + this.bass_note = standardize_note(result.groups?.Bass); + } + + if (result.groups?.descriptor) { + this.chord_descriptors = result.groups.descriptor; + } + } else { + this.invalid = true; + this.note = note; + } + } + + get_chord_string(transpose_steps: number = 0): string { + let chord_string = `${transpose(this.note, transpose_steps)}${this.chord_descriptors}`; + + if (this.bass_note !== undefined) { + chord_string += `/${transpose(this.bass_note, transpose_steps)}`; + } + + return chord_string; + } +} + +const notes_sharp = ["C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"]; +const notes_flat = ["C", "D<", "D", "E<", "E", "F", "G<", "G", "A<", "A", "B<", "B"]; +function transpose(note: string, steps: number): string { + // convert the note to a number between 0 and 11 + let note_number = notes_sharp.indexOf(note); + if (note_number === -1) { + note_number = notes_flat.indexOf(note); + } + if (note_number === -1) { + return null; + } + + // if there are no transposing steps, return the note + if (steps === 0) { + return note; + } + + // add the transpose-steps + note_number += steps; + // flatten to the positive 12-step range + note_number = ((note_number % 12) + 12) % 12; + + return steps > 0 ? notes_sharp[note_number] : notes_flat[note_number]; +} + +function standardize_note(raw_note: string): string { + return raw_note.replace("H", "B"); +} diff --git a/src/server/PlaylistItems/SongFile.ts b/src/server/PlaylistItems/SongFile/SongFile.ts similarity index 93% rename from src/server/PlaylistItems/SongFile.ts rename to src/server/PlaylistItems/SongFile/SongFile.ts index 60c3411..cdbd1af 100644 --- a/src/server/PlaylistItems/SongFile.ts +++ b/src/server/PlaylistItems/SongFile/SongFile.ts @@ -1,5 +1,6 @@ import fs from "fs"; import iconv from "iconv-lite"; +import Chord from "./Chord"; export const verse_types = [ "refrain", @@ -55,6 +56,7 @@ export interface SongFileMetadata { Copyright?: string; LangCount: number; Chords?: Chords; + Transpose?: number; /* eslint-enable @typescript-eslint/naming-convention */ } @@ -91,7 +93,7 @@ export type ItemPartClient = TitlePartClient | LyricPartClient; export type SongPart = string[][][]; export type SongParts = Record; -export type Chords = Record>; +export type Chords = Record>; /** * processes and saves song-files (*.sng) @@ -101,7 +103,7 @@ export default class SongFile { private song_file_path?: string; // private variables - private text_parts: SongParts = {}; + private song_parts: SongParts = {}; metadata: SongFileMetadata = { /* eslint-disable @typescript-eslint/naming-convention */ @@ -160,6 +162,9 @@ export default class SongFile { case "Chords": this.metadata.Chords = this.parse_base64_chords(value); break; + case "Transpose": + this.metadata.Transpose = Number(value); + break; default: break; } @@ -192,7 +197,7 @@ export default class SongFile { if (line !== false && position !== false && typeof chord === "string") { return_object[line] ??= {}; - return_object[line][position] = chord; + return_object[line][position] = new Chord(chord); } match = chord_regex.exec(chords); @@ -231,7 +236,7 @@ export default class SongFile { // parse metadata of the header this.parse_metadata(data[0]); - // remove the header the array + // remove the header of the array data.splice(0, 1); // go through all the text blocks and save them to the text-dictionary @@ -254,7 +259,7 @@ export default class SongFile { lines.splice(0, 1); // if it is the element with the key, there is no entry in the text-dictionary --> create it - this.text_parts[key] = []; + this.song_parts[key] = []; } // pad the text with empty lines so that every language has an equal amount of lines @@ -272,8 +277,8 @@ export default class SongFile { slide[Math.floor(ii / this.metadata.LangCount)].push(vv); }); - if (this.text_parts[key] !== undefined) { - this.text_parts[key].push(slide); + if (this.song_parts[key] !== undefined) { + this.song_parts[key].push(slide); } } } @@ -300,7 +305,7 @@ export default class SongFile { return { type: "lyric", part, - slides: this.text_parts[part] + slides: this.song_parts[part] }; } @@ -326,7 +331,7 @@ export default class SongFile { return { type: "lyric", part, - slides: this.text_parts[part].length + slides: this.song_parts[part].length }; } @@ -338,11 +343,11 @@ export default class SongFile { * all the parts of the song in the order they are defined */ get avaliable_parts(): string[] { - return Object.keys(this.text_parts); + return Object.keys(this.song_parts); } get all_parts(): SongParts { - return this.text_parts; + return this.song_parts; } get languages(): number[] { @@ -354,6 +359,6 @@ export default class SongFile { } get text(): Record { - return this.text_parts; + return this.song_parts; } } diff --git a/src/server/search_part.ts b/src/server/search_part.ts index 87eba4e..b18d6be 100644 --- a/src/server/search_part.ts +++ b/src/server/search_part.ts @@ -4,7 +4,7 @@ import fs from "fs"; import * as JGCPRecv from "./JGCPReceiveMessages"; import Config from "./config"; -import SngFile, { SongParts } from "./PlaylistItems/SongFile"; +import SngFile, { SongParts } from "./PlaylistItems/SongFile/SongFile"; import { PsalmFile as PsmFile } from "./PlaylistItems/Psalm"; import { logger } from "./logger"; import { casparcg } from "./CasparCG"; diff --git a/src/templates/Song.ts b/src/templates/Song.ts index 7b88bbc..b122f0a 100644 --- a/src/templates/Song.ts +++ b/src/templates/Song.ts @@ -1,5 +1,5 @@ import { SongTemplateData } from "../server/PlaylistItems/Song"; -import { ItemPart } from "../server/PlaylistItems/SongFile"; +import { ItemPart } from "../server/PlaylistItems/SongFile/SongFile"; let data: SongTemplateData & { mute_transition: boolean }; From ccafd81e52c3521e05c2d630b7db1f52d732251f Mon Sep 17 00:00:00 2001 From: Simon Ziegler Date: Tue, 30 Apr 2024 00:03:58 +0200 Subject: [PATCH 05/50] Added Text-Item --- README.md | 1 - casparcg/Templates/JohnCG/Text.html | 97 +++++++++++++++++ .../src/ControlWindow/AddPart/AddPart.vue | 4 +- .../ControlWindow/AddPart/Parts/AddText.vue | 44 ++++++++ .../src/ControlWindow/EditPart/EditPart.vue | 8 ++ .../src/ControlWindow/EditPart/EditText.vue | 42 ++++++++ .../ControlWindow/ItemDialogue/TextEditor.vue | 83 ++++++++++++++ src/server/Playlist.ts | 14 +-- src/server/PlaylistItems/PlaylistItem.ts | 23 ++-- src/server/PlaylistItems/Text.ts | 102 ++++++++++++++++++ src/templates/Bible.ts | 2 +- src/templates/Text.ts | 87 +++++++++++++++ 12 files changed, 489 insertions(+), 18 deletions(-) create mode 100644 casparcg/Templates/JohnCG/Text.html create mode 100644 src/client/src/ControlWindow/AddPart/Parts/AddText.vue create mode 100644 src/client/src/ControlWindow/EditPart/EditText.vue create mode 100644 src/client/src/ControlWindow/ItemDialogue/TextEditor.vue create mode 100644 src/server/PlaylistItems/Text.ts create mode 100644 src/templates/Text.ts diff --git a/README.md b/README.md index 5eb28ef..f9c3e18 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,6 @@ Generate graphics with song-lyrics or for other church-service-elements and play 8. Optionally: create a shortcut to `chrome --app=http://127.0.0.1:8888` to open the client like a standalone app ## roadmap -- implement more playlist-items: Text - client-messages: create message-log, group same - fix "Buffer() is deprecated" - load files from disc always at item selection to stay up to date diff --git a/casparcg/Templates/JohnCG/Text.html b/casparcg/Templates/JohnCG/Text.html new file mode 100644 index 0000000..2ffeb4d --- /dev/null +++ b/casparcg/Templates/JohnCG/Text.html @@ -0,0 +1,97 @@ + + + + + + JohnCG - Text-template + + + + +
+
+
+
+
+
+
+ + + \ No newline at end of file diff --git a/src/client/src/ControlWindow/AddPart/AddPart.vue b/src/client/src/ControlWindow/AddPart/AddPart.vue index 5951ff4..5d70d3d 100644 --- a/src/client/src/ControlWindow/AddPart/AddPart.vue +++ b/src/client/src/ControlWindow/AddPart/AddPart.vue @@ -19,6 +19,7 @@ import type * as JGCPRecv from "@server/JGCPReceiveMessages"; import type { BibleFile } from "@server/PlaylistItems/Bible"; import type { ItemProps } from "@server/PlaylistItems/PlaylistItem"; + import AddText from "./Parts/AddText.vue"; library.add( fas.faMusic, @@ -46,7 +47,7 @@ { text: "Song", value: "song", icon: "music" }, { text: "Psalm", value: "psalm", icon: "book-bible" }, { text: "Bible", value: "bible", icon: "quote-left" }, - // { text: "Text", value: "text", icon: "font" }, + { text: "Text", value: "text", icon: "font" }, { text: "Media", value: "media", icon: "image" }, { text: "Template", value: "template", icon: "pen-ruler" }, { text: "PDF", value: "pdf", icon: "file-pdf" }, @@ -113,6 +114,7 @@ @add="add_item" @refresh="get_files('bible')" /> + + import { library } from "@fortawesome/fontawesome-svg-core"; + import * as fas from "@fortawesome/free-solid-svg-icons"; + import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome"; + import { onMounted, ref } from "vue"; + + import MenuButton from "@/ControlWindow/MenuBar/MenuButton.vue"; + import TextEditor from "@/ControlWindow/ItemDialogue/TextEditor.vue"; + + import type { TextProps } from "@server/PlaylistItems/Text"; + + library.add(fas.faPlus); + + const input = ref(); + const text_input = ref(""); + + onMounted(() => { + input.value?.focus(); + }); + + const emit = defineEmits<{ + add: [item_props: TextProps]; + refresh: []; + }>(); + + function add_text() { + if (text_input.value !== undefined && text_input.value.length > 0) { + emit("add", { + type: "text", + caption: text_input.value.split("\n", 1)[0].slice(0, 50), + color: "#FF0000", + text: text_input.value + }); + } + } + + + + + diff --git a/src/client/src/ControlWindow/EditPart/EditPart.vue b/src/client/src/ControlWindow/EditPart/EditPart.vue index 0f096a1..52d4b4a 100644 --- a/src/client/src/ControlWindow/EditPart/EditPart.vue +++ b/src/client/src/ControlWindow/EditPart/EditPart.vue @@ -8,6 +8,7 @@ import type { BibleFile } from "@server/PlaylistItems/Bible"; import type { ClientPlaylistItem } from "@server/PlaylistItems/PlaylistItem"; import EditAMCP from "./EditAMCP.vue"; + import EditText from "./EditText.vue"; defineProps<{ ws: WebSocket; @@ -52,6 +53,13 @@ :bible="bible" :item_index="item_index" /> + + import { onUnmounted, ref, watch } from "vue"; + + import type * as JGCPRecv from "@server/JGCPReceiveMessages"; + import type { ClientTextItem } from "@server/PlaylistItems/Text"; + import TextEditor from "../ItemDialogue/TextEditor.vue"; + + const props = defineProps<{ + ws: WebSocket; + item_index: number; + }>(); + + const text = ref(""); + + const item_props = defineModel("item_props", { required: true }); + + watch( + () => item_props.value.text, + (data) => { + text.value = data ?? ""; + }, + { deep: true, immediate: true } + ); + + onUnmounted(() => { + item_props.value.text = text.value.length > 0 ? text.value : ""; + + const message: JGCPRecv.UpdateItem = { + command: "update_item", + index: props.item_index, + props: item_props.value + }; + + props.ws.send(JSON.stringify(message)); + }); + + + + + diff --git a/src/client/src/ControlWindow/ItemDialogue/TextEditor.vue b/src/client/src/ControlWindow/ItemDialogue/TextEditor.vue new file mode 100644 index 0000000..161d56f --- /dev/null +++ b/src/client/src/ControlWindow/ItemDialogue/TextEditor.vue @@ -0,0 +1,83 @@ + +