about summary refs log tree commit diff
path: root/users/Profpatsch/lyric/extension/src/extension.ts
diff options
context:
space:
mode:
authorProfpatsch <mail@profpatsch.de>2024-09-27T23·30+0200
committerProfpatsch <mail@profpatsch.de>2024-10-05T13·49+0000
commit9bec21ea1cb9137d8c5dae6ee2cb78aac1b5601a (patch)
treeec649db1716bc7b0a7a5d42b8adb4f0c81410659 /users/Profpatsch/lyric/extension/src/extension.ts
parent970dcaa04f3c4bda473674f7e7bb2d2d87ab13e8 (diff)
feat(users/Profpatsch/lyric): add vscode extension & helpers r/8759
* tap-bpm: simple CLI program that accepts key inputs and averages a
BPM value

* lyric-timing-mpv-script: If you press Ctrl+l, mpv attaches the
  current timestamp to a .lrc file named after the song.
  This is for manually timing missing songs for uploading them to
  https://lrclib.net/

* extension: vscode extension for `.lrc` files, currently with the
  following features:

    1. A “jump to LRC position” command which reads an .lrc timestamp
    from the current line and expects mpv to listen on
    `~/tmp/mpv-socket` (via `--input-ipc-server`), and will seek to
    the exact timestamp (down to the ms) in the currently playing
    song.

    2. Some initial linting warnings

      - A lint that warns if the difference to the next timestamp is
      more than 10s (which usually means there’s an instrumental and
      the previous line is stuck)

      - A lint that checks that timestamps are monotonically
      increasing

Change-Id: I32a4ac0e2c5bbe3d94e45ffcf647f81bc7c08aa0
Reviewed-on: https://cl.tvl.fyi/c/depot/+/12537
Tested-by: BuildkiteCI
Reviewed-by: Profpatsch <mail@profpatsch.de>
Diffstat (limited to 'users/Profpatsch/lyric/extension/src/extension.ts')
-rw-r--r--users/Profpatsch/lyric/extension/src/extension.ts236
1 files changed, 236 insertions, 0 deletions
diff --git a/users/Profpatsch/lyric/extension/src/extension.ts b/users/Profpatsch/lyric/extension/src/extension.ts
new file mode 100644
index 000000000000..4f46e0a1ed7c
--- /dev/null
+++ b/users/Profpatsch/lyric/extension/src/extension.ts
@@ -0,0 +1,236 @@
+import * as vscode from 'vscode';
+import * as net from 'net';
+
+export function activate(context: vscode.ExtensionContext) {
+  context.subscriptions.push(...registerCheckLineTimestamp(context));
+  context.subscriptions.push(
+    vscode.commands.registerCommand('extension.jumpToLrcPosition', () => {
+      const editor = vscode.window.activeTextEditor;
+
+      if (!editor) {
+        vscode.window.showInformationMessage('No active editor found.');
+        return;
+      }
+
+      const ext = new Ext(editor);
+      const position = editor.selection.active;
+      const res = ext.getTimestampFromLine(position);
+
+      if (!res) {
+        return;
+      }
+      const { milliseconds, seconds } = res;
+
+      // Prepare JSON command to send to the socket
+      const jsonCommand = {
+        command: ['seek', seconds, 'absolute'],
+      };
+
+      const socket = new net.Socket();
+
+      const socketPath = process.env.HOME + '/tmp/mpv-socket';
+      socket.connect(socketPath, () => {
+        socket.write(JSON.stringify(jsonCommand));
+        socket.write('\n');
+        vscode.window.showInformationMessage(
+          `Sent command to jump to [${formatTimestamp(milliseconds)}].`,
+        );
+        socket.end();
+      });
+
+      socket.on('error', err => {
+        vscode.window.showErrorMessage(`Failed to send command: ${err.message}`);
+      });
+    }),
+  );
+}
+
+// If the difference to the timestamp on the next line is larger than 10 seconds, underline the next line and show a warning message on hover
+export function registerCheckLineTimestamp(_context: vscode.ExtensionContext) {
+  const changesToCheck: Set<vscode.TextDocument> = new Set();
+  const everSeen = new Set<vscode.TextDocument>();
+
+  return [
+    vscode.workspace.onDidChangeTextDocument(e => {
+      changesToCheck.add(e.document);
+      if (vscode.window.activeTextEditor?.document === e.document) {
+        doEditorChecks(vscode.window.activeTextEditor, everSeen, changesToCheck);
+      }
+    }),
+    vscode.workspace.onDidOpenTextDocument(e => {
+      changesToCheck.add(e);
+      everSeen.add(e);
+      if (vscode.window.activeTextEditor?.document === e) {
+        doEditorChecks(vscode.window.activeTextEditor, everSeen, changesToCheck);
+      }
+    }),
+
+    vscode.window.onDidChangeActiveTextEditor(editor => {
+      if (editor) {
+        doEditorChecks(editor, everSeen, changesToCheck);
+      }
+    }),
+    vscode.window.onDidChangeVisibleTextEditors(editors => {
+      for (const editor of editors) {
+        doEditorChecks(editor, everSeen, changesToCheck);
+      }
+    }),
+  ];
+}
+
+function doEditorChecks(
+  editor: vscode.TextEditor,
+  everSeen: Set<vscode.TextDocument>,
+  changesToCheck: Set<vscode.TextDocument>,
+) {
+  const ext = new Ext(editor);
+  const document = editor.document;
+
+  if (!everSeen.has(editor.document)) {
+    changesToCheck.add(editor.document);
+    everSeen.add(editor.document);
+  }
+
+  if (!changesToCheck.has(document)) {
+    return;
+  }
+  changesToCheck.delete(document);
+
+  const from = 0;
+  const to = document.lineCount - 1;
+  for (let line = from; line <= to; line++) {
+    const warnings: string[] = [];
+    const timeDiff = timeDifferenceTooLarge(ext, line);
+    if (timeDiff !== undefined) {
+      warnings.push(timeDiff);
+    }
+    const nextTimestampSmaller = nextLineTimestampSmallerThanCurrent(ext, line);
+    if (nextTimestampSmaller !== undefined) {
+      warnings.push(nextTimestampSmaller);
+    }
+    for (const warning of warnings) {
+      global_manageWarnings.setWarning(document, line, warning);
+    }
+    // unset any warnings if this doesn’t apply anymore
+    if (warnings.length === 0) {
+      global_manageWarnings.setWarning(document, line);
+    }
+  }
+}
+
+/** Warn if the difference to the timestamp on the next line is larger than 10 seconds */
+function timeDifferenceTooLarge(ext: Ext, line: number): string | undefined {
+  const timeDifference = ext.getTimeDifferenceToNextLineTimestamp(
+    new vscode.Position(line, 0),
+  );
+  if (
+    !timeDifference ||
+    timeDifference.thisLineIsEmpty ||
+    timeDifference.difference <= 10000
+  ) {
+    return;
+  }
+  return `Time difference to next line is ${formatTimestamp(timeDifference.difference)}`;
+}
+
+/** Warn if the timestamp on the next line is smaller or equal to the current timestamp */
+function nextLineTimestampSmallerThanCurrent(ext: Ext, line: number): string | undefined {
+  const timeDifference = ext.getTimeDifferenceToNextLineTimestamp(
+    new vscode.Position(line, 0),
+  );
+  if (!timeDifference) {
+    return;
+  }
+  if (timeDifference.difference == 0) {
+    return `The timestamp to the next line is not increasing`;
+  }
+  if (timeDifference.difference < 0) {
+    return `The timestamp to the next line is decreasing`;
+  }
+}
+
+class Ext {
+  constructor(public editor: vscode.TextEditor) {}
+
+  getTimeDifferenceToNextLineTimestamp(position: vscode.Position) {
+    const thisLineTimestamp = this.getTimestampFromLine(position);
+    const nextLineTimestamp = this.getTimestampFromLine(
+      position.with({ line: position.line + 1 }),
+    );
+    if (!thisLineTimestamp || !nextLineTimestamp) {
+      return;
+    }
+    return {
+      difference: nextLineTimestamp.milliseconds - thisLineTimestamp.milliseconds,
+      thisLineIsEmpty: thisLineTimestamp.text.trim() === '',
+    };
+  }
+
+  getTimestampFromLine(position: vscode.Position) {
+    const document = this.editor.document;
+    const lineText = document.lineAt(position.line).text;
+
+    // Extract timestamp [mm:ss.ms] from the current line
+    const match = lineText.match(/\[(\d+:\d+\.\d+)\](.*)/);
+    if (!match) {
+      return;
+    }
+    const [, timestamp, text] = match!;
+    const milliseconds = parseTimestamp(timestamp);
+    const seconds = milliseconds / 1000;
+    return { milliseconds, seconds, text };
+  }
+}
+
+function parseTimestamp(timestamp: string): number {
+  // Parse [mm:ss.ms] format into milliseconds
+  const [min, sec] = timestamp.split(':');
+
+  const minutes = parseInt(min, 10);
+  const seconds = parseFloat(sec);
+
+  return minutes * 60 * 1000 + seconds * 1000;
+}
+
+function formatTimestamp(ms: number): string {
+  // Format milliseconds back into [mm:ss.ms]
+  const minutes = Math.floor(ms / 60000);
+  ms %= 60000;
+  const seconds = (ms / 1000).toFixed(2);
+
+  return `${String(minutes).padStart(2, '0')}:${seconds}`;
+}
+
+class ManageWarnings {
+  private warnings: Map<number, string> = new Map();
+  private diagnostics: vscode.DiagnosticCollection;
+
+  constructor() {
+    this.diagnostics = vscode.languages.createDiagnosticCollection();
+  }
+
+  /** Set a warning message on a line in a document, if null then unset */
+  setWarning(document: vscode.TextDocument, line: number, message?: string) {
+    if (message !== undefined) {
+      this.warnings.set(line, message);
+    } else {
+      this.warnings.delete(line);
+    }
+    this.updateDiagnostics(document);
+  }
+
+  private updateDiagnostics(document: vscode.TextDocument) {
+    const mkWarning = (line: number, message: string) => {
+      const lineRange = document.lineAt(line).range;
+      return new vscode.Diagnostic(lineRange, message, vscode.DiagnosticSeverity.Warning);
+    };
+    const diagnostics: vscode.Diagnostic[] = [];
+    for (const [line, message] of this.warnings) {
+      diagnostics.push(mkWarning(line, message));
+    }
+    this.diagnostics.delete(document.uri);
+    this.diagnostics.set(document.uri, diagnostics);
+  }
+}
+
+const global_manageWarnings = new ManageWarnings();