about summary refs log tree commit diff
path: root/users/Profpatsch/lyric/extension
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
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')
-rw-r--r--users/Profpatsch/lyric/extension/.gitignore5
-rw-r--r--users/Profpatsch/lyric/extension/.prettierrc8
-rw-r--r--users/Profpatsch/lyric/extension/LICENSE1
-rw-r--r--users/Profpatsch/lyric/extension/eslint.config.mjs42
-rw-r--r--users/Profpatsch/lyric/extension/package.json52
-rw-r--r--users/Profpatsch/lyric/extension/src/extension.ts236
-rw-r--r--users/Profpatsch/lyric/extension/tsconfig.json22
7 files changed, 366 insertions, 0 deletions
diff --git a/users/Profpatsch/lyric/extension/.gitignore b/users/Profpatsch/lyric/extension/.gitignore
new file mode 100644
index 000000000000..d2405ad640c3
--- /dev/null
+++ b/users/Profpatsch/lyric/extension/.gitignore
@@ -0,0 +1,5 @@
+/node_modules/
+/out/
+
+# ignore for now
+/package-lock.json
diff --git a/users/Profpatsch/lyric/extension/.prettierrc b/users/Profpatsch/lyric/extension/.prettierrc
new file mode 100644
index 000000000000..e97f84abaf62
--- /dev/null
+++ b/users/Profpatsch/lyric/extension/.prettierrc
@@ -0,0 +1,8 @@
+{
+  "trailingComma": "all",
+  "tabWidth": 2,
+  "semi": true,
+  "singleQuote": true,
+  "printWidth": 90,
+  "arrowParens": "avoid"
+}
diff --git a/users/Profpatsch/lyric/extension/LICENSE b/users/Profpatsch/lyric/extension/LICENSE
new file mode 100644
index 000000000000..4292032e6223
--- /dev/null
+++ b/users/Profpatsch/lyric/extension/LICENSE
@@ -0,0 +1 @@
+same as toplevel
diff --git a/users/Profpatsch/lyric/extension/eslint.config.mjs b/users/Profpatsch/lyric/extension/eslint.config.mjs
new file mode 100644
index 000000000000..996dd8ca58ee
--- /dev/null
+++ b/users/Profpatsch/lyric/extension/eslint.config.mjs
@@ -0,0 +1,42 @@
+import tseslint from "typescript-eslint";
+import tsplugin from "@typescript-eslint/eslint-plugin";
+import parser from "@typescript-eslint/parser";
+
+export default tseslint.config(tseslint.configs.eslintRecommended, {
+  languageOptions: {
+    parser: parser,
+    parserOptions: {
+      projectService: true,
+    },
+  },
+  ignores: ["node_modules/", "eslint.config.mjs"],
+  plugins: { "@typescript-eslint": tsplugin },
+  rules: {
+    "prettier/prettier": "off",
+    "prefer-const": "warn",
+    "@typescript-eslint/ban-ts-comment": "warn",
+    "no-array-constructor": "off",
+    "@typescript-eslint/no-array-constructor": "warn",
+    "@typescript-eslint/no-duplicate-enum-values": "warn",
+    "@typescript-eslint/no-empty-object-type": "warn",
+    "@typescript-eslint/no-explicit-any": "warn",
+    "@typescript-eslint/no-extra-non-null-assertion": "warn",
+    "@typescript-eslint/no-misused-new": "warn",
+    "@typescript-eslint/no-namespace": "warn",
+    "@typescript-eslint/no-non-null-asserted-optional-chain": "warn",
+    "@typescript-eslint/no-require-imports": "warn",
+    "@typescript-eslint/no-this-alias": "warn",
+    "@typescript-eslint/no-unnecessary-type-constraint": "warn",
+    "@typescript-eslint/no-unsafe-declaration-merging": "warn",
+    "@typescript-eslint/no-unsafe-function-type": "warn",
+    "@typescript-eslint/strict-boolean-expressions": ["warn"],
+    "no-unused-expressions": "off",
+    "@typescript-eslint/no-unused-expressions": "warn",
+    "no-unused-vars": "off",
+    "@typescript-eslint/no-unused-vars": ["warn", { argsIgnorePattern: "^_" }],
+    "@typescript-eslint/no-wrapper-object-types": "warn",
+    "@typescript-eslint/prefer-as-const": "warn",
+    "@typescript-eslint/prefer-namespace-keyword": "warn",
+    "@typescript-eslint/triple-slash-reference": "warn",
+  },
+});
diff --git a/users/Profpatsch/lyric/extension/package.json b/users/Profpatsch/lyric/extension/package.json
new file mode 100644
index 000000000000..722da81b4719
--- /dev/null
+++ b/users/Profpatsch/lyric/extension/package.json
@@ -0,0 +1,52 @@
+{
+  "name": "profpatsch-jump-to-lrc-position",
+  "displayName": "Jump to .lrc Position in mpv",
+  "description": "Reads a timestamp from the current file and pipes it to a mpv socket",
+  "version": "0.0.1",
+  "engines": {
+    "vscode": "^1.75.0"
+  },
+  "categories": [
+    "Other"
+  ],
+  "main": "./out/extension.js",
+  "activationEvents": [
+    "onLanguage:lrc"
+  ],
+  "contributes": {
+    "commands": [
+      {
+        "command": "extension.jumpToLrcPosition",
+        "title": "Jump to .lrc Position",
+        "category": "LRC"
+      }
+    ],
+    "languages": [
+      {
+        "id": "lrc",
+        "extensions": [
+          ".lrc"
+        ],
+        "aliases": [
+          "Lyric file"
+        ]
+      }
+    ]
+  },
+  "scripts": {
+    "vscode:prepublish": "npm run compile",
+    "compile": "tsc",
+    "install-extension": "vsce package --allow-missing-repository --out ./jump-to-lrc-position.vsix && code --install-extension ./jump-to-lrc-position.vsix"
+  },
+  "devDependencies": {
+    "vscode": "^1.1.37",
+    "@eslint/js": "^9.10.0",
+    "@types/eslint__js": "^8.42.3",
+    "@types/node": "^22.5.5",
+    "@typescript-eslint/parser": "^8.7.0",
+    "eslint": "^9.10.0",
+    "globals": "^15.9.0",
+    "typescript": "^5.6.2",
+    "typescript-eslint": "^8.6.0"
+  }
+}
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();
diff --git a/users/Profpatsch/lyric/extension/tsconfig.json b/users/Profpatsch/lyric/extension/tsconfig.json
new file mode 100644
index 000000000000..44a5195795da
--- /dev/null
+++ b/users/Profpatsch/lyric/extension/tsconfig.json
@@ -0,0 +1,22 @@
+{
+    "compilerOptions": {
+        "target": "ES6",
+        "module": "commonjs",
+        "lib": [
+            "ES6"
+        ],
+        "outDir": "./out",
+        "rootDir": "./src",
+        "strict": true,
+        "sourceMap": true,
+        "esModuleInterop": true,
+        "skipLibCheck": true
+    },
+    "include": [
+        "src"
+    ],
+    "exclude": [
+        "node_modules",
+        ".vscode-test"
+    ]
+}