This commit is contained in:
Timofey Koolin 2026-04-12 15:17:02 +03:00
commit fb28bd4545
34 changed files with 13982 additions and 0 deletions

21
.eslintrc.js Normal file
View file

@ -0,0 +1,21 @@
module.exports = {
root: true,
parser: "@typescript-eslint/parser",
env: { node: true, browser: true },
plugins: ["@typescript-eslint"],
extends: [
"eslint:recommended",
"plugin:@typescript-eslint/recommended",
],
parserOptions: {
sourceType: "module",
},
rules: {
"no-unused-vars": "off",
"@typescript-eslint/no-unused-vars": ["warn", { argsIgnorePattern: "^_" }],
"@typescript-eslint/no-explicit-any": "off",
"@typescript-eslint/ban-ts-comment": "off",
"no-prototype-builtins": "off",
"@typescript-eslint/no-empty-function": "off",
},
};

72
.github/workflows/publish.yml vendored Normal file
View file

@ -0,0 +1,72 @@
name: Publish Release
on:
workflow_dispatch:
inputs:
version-part:
description: Part of version number for change
required: true
type: choice
options:
- minor
- patch
release-title:
description: Title for the release
required: false
type: string
permissions:
contents: write
jobs:
release:
runs-on: ubuntu-latest
steps:
- name: checkout
uses: actions/checkout@v4
- name: install nodejs
uses: actions/setup-node@v4
with:
node-version: '22'
- name: git config
run: |
git config user.email github-robot@github.com
git config user.name github-robot
- name: up version
id: bump-version
run: |
CURRENT_VERSION=$(node -e "console.log(JSON.parse(require('fs').readFileSync('package.json', 'utf8')).version)")
if [[ "$CURRENT_VERSION" == *-pre-release ]]; then
VERSION="${CURRENT_VERSION%-pre-release}"
npm config set tag-version-prefix ""
npm version "$VERSION"
else
npm config set tag-version-prefix ""
npm version "${{ github.event.inputs.version-part }}"
VERSION=$(node -e "console.log(JSON.parse(require('fs').readFileSync('package.json', 'utf8')).version)")
fi
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
- name: build
run: |
npm ci --no-optional
npm run build
- name: push tag to github
run: |
git push origin
git push origin --tags
- name: create release
uses: softprops/action-gh-release@v2
with:
name: ${{ steps.bump-version.outputs.version }} ${{ github.event.inputs.release-title }}
tag_name: ${{ steps.bump-version.outputs.version }}
generate_release_notes: true
files: |
main.js
manifest.json
styles.css

34
.github/workflows/test.yml vendored Normal file
View file

@ -0,0 +1,34 @@
name: Tests
on:
push:
branches:
- master
pull_request:
branches: ["**"]
jobs:
ci:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'npm'
- name: Install dependencies
run: npm ci --no-optional
- name: Build project
run: npm run build
- name: Run tests
run: npm test
- name: Run linter
run: npm run lint

14
.gitignore vendored Normal file
View file

@ -0,0 +1,14 @@
node_modules/
main.js
*.log
.DS_Store
data.json
.obsidian/
dist/
coverage/
.obsidian-cache/
obsidian-tests/demo-vault/videos/*.webm
obsidian-tests/demo-vault/videos/*.mp4
obsidian-tests/demo-vault/videos/*.mkv
obsidian-tests/demo-vault/videos/*.mov
obsidian-tests/demo-vault/videos/*.m4v

94
README.md Normal file
View file

@ -0,0 +1,94 @@
# Video Notes
[English](README.md) · [Русский](README.ru.md)
An offline-only Obsidian plugin for taking notes on videos that already live inside your vault. Inspired by [media-extended](https://github.com/aidenlx/media-extended), minus every online feature — no YouTube, no streaming, no network calls.
## Features
- Dedicated video panel that plays any video file from your vault.
- Insert a human-readable timestamp link like `[[lecture.mp4#t=736|12:16]]` at the current playback position.
- Clicking such a link opens the panel and jumps the player to that exact second (and pauses, so you can inspect the frame).
- Take a screenshot of the current frame, save it as PNG to your configured attachments folder, and get the timestamp link inserted next to it.
- Configurable **pre-roll offset**: when you press the button, the timestamp (and screenshot) are taken a few seconds *before* the click, so you don't miss the moment you're reacting to.
- Playback control commands: play/pause toggle, seek backward/forward by a configurable step. Bind them to any hotkey.
## Screenshots
![Player panel](docs/screenshots/en/player.png)
![Timestamp inserted](docs/screenshots/en/timestamp-inserted.png)
![Screenshot inserted](docs/screenshots/en/screenshot-inserted.png)
> Demo footage: *Caminandes 3: Llamigos* © Blender Foundation — [studio.blender.org](https://studio.blender.org/projects/caminandes-3/), licensed under [CC-BY 3.0](https://creativecommons.org/licenses/by/3.0/).
## Install
### From Obsidian
Not yet on the community plugins list. Install manually:
1. Download `main.js`, `manifest.json`, `styles.css` from the [latest release](https://github.com/rekby/obsidian-video-notes/releases/latest).
2. Put them into `<your-vault>/.obsidian/plugins/video-notes/`.
3. In Obsidian: Settings → Community plugins → enable **Video Notes**.
## Usage
All features are exposed as commands. Open the command palette (⌘P / Ctrl+P) and search for "Local video".
| Command | What it does |
| --- | --- |
| Open local video | Fuzzy file picker across `.mp4/.webm/.mkv/.mov/.m4v` files in your vault. |
| Toggle play/pause | Play or pause the active player. |
| Resume playback | Start playing (no-op if already playing). |
| Pause | Pause the active player. |
| Seek backward / Seek forward | Move the playback position by *seek step* seconds. |
| Insert timestamp at current position | Insert `[[video#t=N\|MM:SS]]`. Pause behaviour follows the **Pause on insert** setting. |
| Insert timestamp and pause | Same as above, but always pauses regardless of the setting. |
| Insert timestamp (keep playing) | Same, but always keeps the player running. |
| Take screenshot at current position | Save a PNG frame to attachments + insert image and timestamp link. Pause behaviour follows the setting. |
| Take screenshot and pause | Same, but always pauses. |
| Take screenshot (keep playing) | Same, but always keeps the player running. |
Bind any of these to a hotkey via Settings → Hotkeys. Typical workflow: bind **Insert timestamp and pause** to one hotkey (for "stop and write a thought") and **Resume playback** to another (to continue when you're done typing).
## Settings
| Setting | Default | Description |
| --- | --- | --- |
| Timestamp offset (seconds) | `3` | Subtract this many seconds from the current position when inserting a timestamp or screenshot. |
| Seek step (seconds) | `5` | Step used by Seek backward / Seek forward. |
| Pause on insert | `true` | Pause the player after inserting a timestamp or screenshot. |
| Seek on insert | `true` | Also jump the player to the inserted (offset-adjusted) moment. |
| Player location | `right` | Where a new player opens when you click a timestamp link and none is open yet: right sidebar or bottom split of the current tab. If the player is already open, it is reused and simply switches to the right video + time. |
> Timestamps and screenshots are always inserted into the **last markdown tab you had focused**, not the video panel itself. You can click buttons inside the player freely without losing the target note.
## Development
```bash
npm install
npm run dev # esbuild watch
npm run build # production build
npm test # jest
npm run lint
```
### Generating README screenshots (optional, local-only)
```bash
npm run demo:screenshots
```
This fetches the demo footage (Caminandes 3, CC-BY 3.0) into the demo vault if missing, boots a real Obsidian via `wdio-obsidian-service`, loads the plugin against the vault in `obsidian-tests/demo-vault/`, drives the player through an e2e scenario for each UI language, and writes fresh PNGs into `docs/screenshots/en/` and `docs/screenshots/ru/`.
### Releasing
1. Go to GitHub → Actions → **Publish Release** → Run workflow.
2. Choose `patch` or `minor`. The workflow:
- strips the `-pre-release` suffix on the very first run, otherwise bumps the chosen part;
- syncs `manifest.json` and `versions.json` via `version-bump.mjs`;
- builds, pushes the tag, creates a GitHub release with `main.js`, `manifest.json`, `styles.css` attached.
## License
MIT © rekby

94
README.ru.md Normal file
View file

@ -0,0 +1,94 @@
# Video Notes
[English](README.md) · [Русский](README.ru.md)
Оффлайн-плагин для Obsidian: делайте заметки по видеофайлам, которые уже лежат в вашем vault. Вдохновлён [media-extended](https://github.com/aidenlx/media-extended), но **без всех онлайн-функций** — никакого YouTube, стриминга или сетевых запросов.
## Возможности
- Отдельная панель плеера, проигрывающая любое видео из vault.
- Вставка человекочитаемой таймкод-ссылки вида `[[lecture.mp4#t=736|12:16]]` на текущую позицию воспроизведения.
- Клик по такой ссылке открывает панель, перематывает плеер на нужную секунду и ставит его на паузу — удобно рассматривать кадр.
- Скриншот текущего кадра: PNG сохраняется в настроенную папку вложений, рядом вставляется таймкод-ссылка.
- Настраиваемый **pre-roll offset**: таймкод и скриншот берутся на несколько секунд *раньше* момента нажатия — ловит случай, когда реагируешь уже после интересного момента.
- Управление воспроизведением: play/pause, перемотка назад/вперёд на настраиваемый шаг. Все команды можно повесить на хоткеи.
## Скриншоты
![Панель плеера](docs/screenshots/ru/player.png)
![Вставленный таймкод](docs/screenshots/ru/timestamp-inserted.png)
![Вставленный скриншот](docs/screenshots/ru/screenshot-inserted.png)
> Видео для демо: *Caminandes 3: Llamigos* © Blender Foundation — [studio.blender.org](https://studio.blender.org/projects/caminandes-3/), лицензия [CC-BY 3.0](https://creativecommons.org/licenses/by/3.0/).
## Установка
### Из Obsidian
Пока не опубликован в community plugins. Ставим вручную:
1. Скачайте `main.js`, `manifest.json`, `styles.css` из [последнего релиза](https://github.com/rekby/obsidian-video-notes/releases/latest).
2. Положите их в `<ваш-vault>/.obsidian/plugins/video-notes/`.
3. В Obsidian: Settings → Community plugins → включите **Video Notes**.
## Использование
Все функции — это команды. Откройте палитру команд (⌘P / Ctrl+P) и ищите «Local video».
| Команда | Что делает |
| --- | --- |
| Open local video | Fuzzy-поиск по файлам `.mp4/.webm/.mkv/.mov/.m4v` во vault. |
| Toggle play/pause | Play или pause активного плеера. |
| Resume playback | Продолжить воспроизведение (ничего не делает, если уже играет). |
| Pause | Поставить плеер на паузу. |
| Seek backward / Seek forward | Перемотка на *seek step* секунд. |
| Insert timestamp at current position | Вставка `[[video#t=N\|MM:SS]]`. Поведение паузы — как в настройке **Pause on insert**. |
| Insert timestamp and pause | То же самое, но всегда ставит плеер на паузу, вне зависимости от настройки. |
| Insert timestamp (keep playing) | То же самое, но всегда оставляет плеер играть. |
| Take screenshot at current position | PNG кадра в attachments + вставка картинки и таймкод-ссылки. Пауза — по настройке. |
| Take screenshot and pause | То же, но всегда ставит на паузу. |
| Take screenshot (keep playing) | То же, но оставляет плеер играть. |
Любую команду можно повесить на хоткей в Settings → Hotkeys. Типичный сценарий: «Insert timestamp and pause» на один хоткей (остановиться и записать мысль), «Resume playback» — на другой (продолжить, когда закончил печатать).
## Настройки
| Настройка | По умолчанию | Описание |
| --- | --- | --- |
| Timestamp offset (seconds) | `3` | Вычитается из текущей позиции при вставке таймкода/скриншота. |
| Seek step (seconds) | `5` | Шаг для Seek backward / Seek forward. |
| Pause on insert | `true` | Ставить плеер на паузу после вставки таймкода или скриншота. |
| Seek on insert | `true` | Дополнительно перематывать плеер на вставленный момент (с учётом offset). |
| Player location | `right` | Где открывать новый плеер при клике на таймкод-ссылку, если плеер ещё не открыт: в правой панели или снизу (horizontal split текущей вкладки). Если плеер уже открыт — он используется повторно и просто переключается на нужное видео и время. |
> Таймкоды и скриншоты всегда вставляются в **последнюю активную markdown-вкладку**, а не в панель плеера. Поэтому кнопки в плеере можно нажимать смело — вставка идёт в вашу заметку.
## Разработка
```bash
npm install
npm run dev # esbuild watch
npm run build # production build
npm test # jest
npm run lint
```
### Генерация скриншотов для README (локально, не в CI)
```bash
npm run demo:screenshots
```
При необходимости подкачивает демо-ролик (Caminandes 3, CC-BY 3.0) в demo-vault, поднимает настоящий Obsidian через `wdio-obsidian-service`, загружает плагин против vault-а `obsidian-tests/demo-vault/`, для каждого языка интерфейса прогоняет сценарий с плеером и пишет свежие PNG в `docs/screenshots/en/` и `docs/screenshots/ru/`.
### Релизы
1. GitHub → Actions → **Publish Release** → Run workflow.
2. Выберите `patch` или `minor`. Workflow:
- на самом первом запуске снимает суффикс `-pre-release`, иначе бампает выбранную часть версии;
- синхронизирует `manifest.json` и `versions.json` через `version-bump.mjs`;
- собирает плагин, пушит тег, создаёт GitHub release с `main.js`, `manifest.json`, `styles.css` в assets.
## Лицензия
MIT © rekby

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 266 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 273 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 113 KiB

47
esbuild.config.mjs Normal file
View file

@ -0,0 +1,47 @@
import esbuild from "esbuild";
import process from "process";
import builtins from "builtin-modules";
const banner = `/*
THIS IS A GENERATED/BUNDLED FILE BY ESBUILD
if you want to view the source, please visit the github repository of this plugin
*/
`;
const prod = process.argv[2] === "production";
const context = await esbuild.context({
banner: { js: banner },
entryPoints: ["src/main.ts"],
bundle: true,
external: [
"obsidian",
"electron",
"@codemirror/autocomplete",
"@codemirror/collab",
"@codemirror/commands",
"@codemirror/language",
"@codemirror/lint",
"@codemirror/search",
"@codemirror/state",
"@codemirror/view",
"@lezer/common",
"@lezer/highlight",
"@lezer/lr",
...builtins,
],
format: "cjs",
target: "es2020",
logLevel: "info",
sourcemap: prod ? false : "inline",
treeShaking: true,
outfile: "main.js",
minify: prod,
});
if (prod) {
await context.rebuild();
await context.dispose();
} else {
await context.watch();
}

10
jest.config.js Normal file
View file

@ -0,0 +1,10 @@
module.exports = {
preset: "ts-jest",
testEnvironment: "node",
roots: ["<rootDir>/tests"],
testMatch: ["**/*.test.ts"],
moduleFileExtensions: ["ts", "js"],
transform: {
"^.+\\.ts$": ["ts-jest", { tsconfig: { module: "commonjs", target: "ES2020", esModuleInterop: true } }],
},
};

10
manifest.json Normal file
View file

@ -0,0 +1,10 @@
{
"id": "video-notes",
"name": "Video Notes",
"version": "0.0.1-pre-release",
"minAppVersion": "1.8.7",
"description": "Offline video notes: human-readable timestamp links, frame screenshots and playback control for files inside your vault.",
"author": "rekby",
"authorUrl": "https://github.com/rekby",
"isDesktopOnly": false
}

View file

@ -0,0 +1,3 @@
# Caminandes 3 — watching notes
Taking timestamps and frame grabs from the Blender short.

View file

@ -0,0 +1,3 @@
# Caminandes 3 — заметки по просмотру
Делаю таймкоды и стоп-кадры по короткометражке Blender.

View file

@ -0,0 +1,234 @@
/* global describe, it, before */
import crypto from "node:crypto";
import fs from "node:fs/promises";
import path from "node:path";
import { $, browser } from "@wdio/globals";
import { obsidianPage } from "wdio-obsidian-service";
import {
DEMO_VIDEO_SHA256,
DEMO_VIDEO_VAULT_PATH,
HASH_MISMATCH_MESSAGE,
} from "../../scripts/demo-video-meta.mjs";
const OUT_ROOT = path.resolve("docs/screenshots");
const APP_SELECTOR = ".app-container";
const VIDEO_PATH = DEMO_VIDEO_VAULT_PATH;
const VIDEO_DISK_PATH = path.resolve(
"obsidian-tests/demo-vault",
DEMO_VIDEO_VAULT_PATH,
);
const VIEW_TYPE = "video-notes-player";
const TIMESTAMP_TIME = 51;
const SCREENSHOT_TIME = 130;
async function verifyDemoVideoHash() {
const buf = await fs.readFile(VIDEO_DISK_PATH);
const actual = crypto.createHash("sha256").update(buf).digest("hex");
if (actual !== DEMO_VIDEO_SHA256) {
throw new Error(
`demo video SHA-256 mismatch for ${VIDEO_DISK_PATH}\n` +
` expected: ${DEMO_VIDEO_SHA256}\n` +
` actual: ${actual}\n` +
HASH_MISMATCH_MESSAGE,
);
}
}
const LANGUAGES = [
{
code: "",
outDir: "en",
note: "Caminandes notes.md",
timestampCaption: "The berry after rescuing the penguin",
screenshotCaption: "Koro and the chick feast on the berries",
},
{
code: "ru",
outDir: "ru",
note: "Заметки к Caminandes.md",
timestampCaption: "Ягода после спасения пингвина",
screenshotCaption: "Коро и пингвинёнок лакомятся ягодами",
},
];
async function waitForPlugin() {
await browser.waitUntil(
async () =>
browser.executeObsidian(({ plugins }) => Boolean(plugins.videoNotes)),
{ timeout: 30000, timeoutMsg: "video-notes plugin did not load" },
);
}
async function setObsidianLanguage(code) {
await browser.execute((lang) => {
if (lang) {
window.localStorage.setItem("language", lang);
} else {
window.localStorage.removeItem("language");
}
}, code);
await browser.reloadObsidian();
await waitForPlugin();
}
async function zeroPluginOffset() {
await browser.executeObsidian(async ({ plugins }) => {
plugins.videoNotes.settings.offsetSec = 0;
await plugins.videoNotes.saveSettings();
});
}
async function hideChrome() {
await browser.execute(() => {
const styleId = "video-notes-demo-style";
document.getElementById(styleId)?.remove();
const style = document.createElement("style");
style.id = styleId;
style.textContent = `
.workspace-ribbon,
.status-bar { display: none !important; }
.workspace-tab-header-container { opacity: 0.85; }
`;
document.head.append(style);
window.dispatchEvent(new Event("resize"));
});
await browser.pause(200);
}
async function saveAppScreenshot(outDir, name) {
const app = await $(APP_SELECTOR);
await app.waitForDisplayed();
await fs.mkdir(path.join(OUT_ROOT, outDir), { recursive: true });
await app.saveScreenshot(path.join(OUT_ROOT, outDir, name));
}
async function loadVideoInPlayer() {
await browser.executeObsidian(
async ({ app, obsidian }, { viewType, videoPath }) => {
const file = app.vault.getAbstractFileByPath(videoPath);
if (!(file instanceof obsidian.TFile)) {
throw new Error(`video ${videoPath} not found in vault`);
}
let leaf = app.workspace.getLeavesOfType(viewType)[0];
if (!leaf) {
leaf = app.workspace.getRightLeaf(false);
if (!leaf) throw new Error("no right leaf available");
await leaf.setViewState({ type: viewType, active: false });
}
app.workspace.revealLeaf(leaf);
await leaf.view.loadFile(file);
},
{ viewType: VIEW_TYPE, videoPath: VIDEO_PATH },
);
}
async function pauseAndSeek(timeSec) {
await browser.executeObsidian(
async ({ app }, { viewType, t }) => {
const leaf = app.workspace.getLeavesOfType(viewType)[0];
if (!leaf) throw new Error("player leaf not found");
const video = leaf.view.containerEl.querySelector("video");
if (!video) throw new Error("video element not found");
video.pause();
await new Promise((resolve, reject) => {
const onSeeked = () => {
video.removeEventListener("seeked", onSeeked);
clearTimeout(timer);
resolve();
};
video.addEventListener("seeked", onSeeked);
const timer = setTimeout(() => {
video.removeEventListener("seeked", onSeeked);
reject(new Error(`video did not seek to ${t}s within 1000ms`));
}, 1000);
video.currentTime = t;
});
},
{ viewType: VIEW_TYPE, t: timeSec },
);
}
async function appendToNote(notePath, text) {
await browser.executeObsidian(
({ app, obsidian }, { filePath, payload }) => {
const leaf = app.workspace
.getLeavesOfType("markdown")
.find(
(candidate) =>
candidate.view instanceof obsidian.MarkdownView &&
candidate.view.file?.path === filePath,
);
if (!leaf) throw new Error(`no markdown leaf for ${filePath}`);
const view = leaf.view;
const last = view.editor.lastLine();
view.editor.setCursor({
line: last,
ch: view.editor.getLine(last).length,
});
view.editor.replaceSelection(payload);
},
{ filePath: notePath, payload: text },
);
}
for (const lang of LANGUAGES) {
describe(`README screenshots [${lang.outDir}]`, function () {
before(async () => {
await verifyDemoVideoHash();
await fs.mkdir(path.join(OUT_ROOT, lang.outDir), { recursive: true });
await waitForPlugin();
await setObsidianLanguage(lang.code);
await zeroPluginOffset();
await hideChrome();
await obsidianPage.openFile(lang.note);
await loadVideoInPlayer();
await pauseAndSeek(TIMESTAMP_TIME);
});
it("captures the player panel", async () => {
await saveAppScreenshot(lang.outDir, "player.png");
});
it("captures a note with a timestamp inserted", async () => {
await appendToNote(lang.note, `\n\n${lang.timestampCaption} `);
await browser.executeObsidianCommand("video-notes:insert-timestamp");
await browser.waitUntil(
async () =>
browser.executeObsidian(({ app, obsidian }, filePath) => {
const leaf = app.workspace
.getLeavesOfType("markdown")
.find(
(candidate) =>
candidate.view instanceof obsidian.MarkdownView &&
candidate.view.file?.path === filePath,
);
return leaf?.view?.editor?.getValue().includes("#t=51") ?? false;
}, lang.note),
{ timeout: 1000, timeoutMsg: "timestamp link was not inserted" },
);
await saveAppScreenshot(lang.outDir, "timestamp-inserted.png");
});
it("captures a note with a screenshot inserted", async () => {
await pauseAndSeek(SCREENSHOT_TIME);
await appendToNote(lang.note, `\n\n${lang.screenshotCaption}\n`);
await browser.executeObsidianCommand("video-notes:take-screenshot");
await browser.waitUntil(
async () =>
browser.executeObsidian(({ app, obsidian }, filePath) => {
const leaf = app.workspace
.getLeavesOfType("markdown")
.find(
(candidate) =>
candidate.view instanceof obsidian.MarkdownView &&
candidate.view.file?.path === filePath,
);
return leaf?.view?.editor?.getValue().includes("![[") ?? false;
}, lang.note),
{ timeout: 1000, timeoutMsg: "screenshot was not inserted" },
);
await saveAppScreenshot(lang.outDir, "screenshot-inserted.png");
});
});
}

12189
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

45
package.json Normal file
View file

@ -0,0 +1,45 @@
{
"name": "video-notes",
"version": "0.0.1-pre-release",
"description": "Obsidian plugin for local video notes: timestamp links, screenshots, playback control — offline only.",
"main": "main.js",
"scripts": {
"dev": "node esbuild.config.mjs",
"build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production",
"test": "jest",
"lint": "eslint src --ext .ts",
"demo:fetch-video": "node scripts/fetch-demo-video.mjs",
"demo:screenshots": "npm run build && npm run demo:fetch-video && wdio run ./wdio.demo.conf.mjs",
"version": "node version-bump.mjs && git add manifest.json versions.json"
},
"keywords": [
"obsidian",
"obsidian-plugin",
"video",
"timestamp",
"screenshot"
],
"author": "rekby",
"license": "MIT",
"devDependencies": {
"@types/jest": "^29.5.12",
"@types/node": "^20.11.0",
"@typescript-eslint/eslint-plugin": "^8.20.0",
"@typescript-eslint/parser": "^8.20.0",
"@wdio/cli": "^9.27.0",
"@wdio/globals": "^9.27.0",
"@wdio/local-runner": "^9.27.0",
"@wdio/mocha-framework": "^9.27.0",
"builtin-modules": "^3.3.0",
"esbuild": "^0.20.0",
"eslint": "^8.56.0",
"jest": "^29.7.0",
"obsidian": "^1.4.11",
"ts-jest": "^29.1.2",
"tslib": "^2.6.2",
"typescript": "^5.3.3",
"wdio-obsidian-reporter": "^2.4.0",
"wdio-obsidian-service": "^2.4.0",
"webdriverio": "^9.27.0"
}
}

View file

@ -0,0 +1,31 @@
// Shared metadata for the README screenshot demo footage.
// Imported by both scripts/fetch-demo-video.mjs and the e2e spec so the
// pinned hash has a single source of truth.
//
// Video: "Caminandes 3: Llamigos" by Blender Foundation, CC-BY 3.0.
// https://archive.org/details/CaminandesLlamigos
//
// The SHA-256 is pinned so the captions baked into the screenshots (which
// describe specific frames at 00:51 "berry after rescuing the penguin" and
// 02:10 "Koro and the chick feast on the berries") never drift out of sync
// with the actual footage. If this hash mismatches, re-verify those frames
// against the new file and update both this constant and the captions in
// obsidian-tests/demo/readme-screenshots.e2e.mjs.
export const DEMO_VIDEO_VAULT_PATH = "videos/caminandes-3-llamigos.webm";
export const DEMO_VIDEO_URL =
"https://archive.org/download/CaminandesLlamigos/Caminandes_%20Llamigos-1080p.webm";
export const DEMO_VIDEO_SHA256 =
"16e757a80ffec51a8423f0c7a267adbaf78b533656ddd488eccc2caaf947d99c";
export const HASH_MISMATCH_MESSAGE = [
"The demo video has changed.",
"The README screenshot e2e has captions hard-coded for specific frames",
'(00:51 "berry after rescuing the penguin", 02:10 "Koro and the chick',
'feast on the berries").',
"Re-verify those frames against the new file, update DEMO_VIDEO_SHA256 in",
"scripts/demo-video-meta.mjs, and update the captions in",
"obsidian-tests/demo/readme-screenshots.e2e.mjs.",
].join(" ");

View file

@ -0,0 +1,61 @@
#!/usr/bin/env node
// Downloads the demo footage used by the README screenshot e2e.
// See scripts/demo-video-meta.mjs for the pinned hash and licensing info.
import crypto from "node:crypto";
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import {
DEMO_VIDEO_SHA256,
DEMO_VIDEO_URL,
DEMO_VIDEO_VAULT_PATH,
HASH_MISMATCH_MESSAGE,
} from "./demo-video-meta.mjs";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const repoRoot = path.resolve(__dirname, "..");
const target = path.join(
repoRoot,
"obsidian-tests/demo-vault",
DEMO_VIDEO_VAULT_PATH,
);
function sha256(buf) {
return crypto.createHash("sha256").update(buf).digest("hex");
}
function assertHash(buf, source) {
const actual = sha256(buf);
if (actual !== DEMO_VIDEO_SHA256) {
throw new Error(
`[fetch-demo-video] SHA-256 mismatch for ${source}\n` +
` expected: ${DEMO_VIDEO_SHA256}\n` +
` actual: ${actual}\n` +
HASH_MISMATCH_MESSAGE,
);
}
}
if (fs.existsSync(target)) {
assertHash(fs.readFileSync(target), path.relative(repoRoot, target));
console.log(
`[fetch-demo-video] already present and verified: ${path.relative(repoRoot, target)}`,
);
process.exit(0);
}
fs.mkdirSync(path.dirname(target), { recursive: true });
console.log(`[fetch-demo-video] downloading ${DEMO_VIDEO_URL}`);
const res = await fetch(DEMO_VIDEO_URL);
if (!res.ok) {
throw new Error(`[fetch-demo-video] HTTP ${res.status} ${res.statusText}`);
}
const buf = Buffer.from(await res.arrayBuffer());
assertHash(buf, DEMO_VIDEO_URL);
fs.writeFileSync(target, buf);
console.log(
`[fetch-demo-video] wrote ${path.relative(repoRoot, target)} (${buf.length} bytes)`,
);

164
src/i18n.ts Normal file
View file

@ -0,0 +1,164 @@
import { getLanguage } from "obsidian";
export type Locale = "en" | "ru";
export interface Strings {
commands: {
openVideo: string;
togglePlay: string;
resumePlay: string;
pause: string;
seekBack: string;
seekForward: string;
insertTimestamp: string;
insertTimestampNoPause: string;
insertTimestampAndPause: string;
takeScreenshot: string;
takeScreenshotNoPause: string;
takeScreenshotAndPause: string;
};
modal: {
pickVideoPlaceholder: string;
};
notice: {
noVideoLoaded: string;
openNoteForTimestamp: string;
openNoteForScreenshot: string;
screenshotFailed: (msg: string) => string;
};
view: {
localVideo: string;
noVideoLoaded: string;
btnTimestamp: string;
btnScreenshot: string;
};
settings: {
offsetName: string;
offsetDesc: string;
seekStepName: string;
seekStepDesc: string;
pauseOnInsertName: string;
pauseOnInsertDesc: string;
seekOnInsertName: string;
seekOnInsertDesc: string;
playerLocationName: string;
playerLocationDesc: string;
playerLocationRight: string;
playerLocationBottom: string;
};
}
const en: Strings = {
commands: {
openVideo: "Open local video",
togglePlay: "Toggle play/pause",
resumePlay: "Resume playback",
pause: "Pause",
seekBack: "Seek backward",
seekForward: "Seek forward",
insertTimestamp: "Insert timestamp at current position",
insertTimestampNoPause: "Insert timestamp (keep playing)",
insertTimestampAndPause: "Insert timestamp and pause",
takeScreenshot: "Take screenshot at current position",
takeScreenshotNoPause: "Take screenshot (keep playing)",
takeScreenshotAndPause: "Take screenshot and pause",
},
modal: {
pickVideoPlaceholder: "Pick a video file from the vault",
},
notice: {
noVideoLoaded: "No video loaded",
openNoteForTimestamp: "Open a note to insert the timestamp",
openNoteForScreenshot: "Open a note to insert the screenshot",
screenshotFailed: (msg) => `Screenshot failed: ${msg}`,
},
view: {
localVideo: "Local video",
noVideoLoaded: "No video loaded",
btnTimestamp: "Timestamp",
btnScreenshot: "Screenshot",
},
settings: {
offsetName: "Timestamp offset (seconds)",
offsetDesc: "Subtract this many seconds from the current playback position when inserting a timestamp or taking a screenshot. Useful for reacting a few seconds after the interesting moment happened.",
seekStepName: "Seek step (seconds)",
seekStepDesc: "Step used by the Seek backward / Seek forward commands.",
pauseOnInsertName: "Pause on insert",
pauseOnInsertDesc: "Pause the player when a timestamp or screenshot is inserted.",
seekOnInsertName: "Seek on insert",
seekOnInsertDesc: "Also move the playback position to the inserted timestamp (after applying the offset).",
playerLocationName: "Player location",
playerLocationDesc: "Where to open a new video player when you click a timestamp link and none is open yet.",
playerLocationRight: "Right sidebar",
playerLocationBottom: "Bottom split of the current tab",
},
};
const ru: Strings = {
commands: {
openVideo: "Открыть локальное видео",
togglePlay: "Воспроизведение / пауза",
resumePlay: "Продолжить воспроизведение",
pause: "Пауза",
seekBack: "Перемотать назад",
seekForward: "Перемотать вперёд",
insertTimestamp: "Вставить метку времени в текущей позиции",
insertTimestampNoPause: "Вставить метку времени (без паузы)",
insertTimestampAndPause: "Вставить метку времени и поставить на паузу",
takeScreenshot: "Сделать скриншот в текущей позиции",
takeScreenshotNoPause: "Сделать скриншот (без паузы)",
takeScreenshotAndPause: "Сделать скриншот и поставить на паузу",
},
modal: {
pickVideoPlaceholder: "Выберите видеофайл из хранилища",
},
notice: {
noVideoLoaded: "Видео не загружено",
openNoteForTimestamp: "Откройте заметку для вставки метки времени",
openNoteForScreenshot: "Откройте заметку для вставки скриншота",
screenshotFailed: (msg) => `Не удалось сделать скриншот: ${msg}`,
},
view: {
localVideo: "Локальное видео",
noVideoLoaded: "Видео не загружено",
btnTimestamp: "Метка времени",
btnScreenshot: "Скриншот",
},
settings: {
offsetName: "Смещение метки времени (секунды)",
offsetDesc: "Вычитать это число секунд из текущей позиции воспроизведения при вставке метки времени или создании скриншота. Полезно, если вы реагируете на интересный момент с небольшой задержкой.",
seekStepName: "Шаг перемотки (секунды)",
seekStepDesc: "Шаг, используемый командами «Перемотать назад» и «Перемотать вперёд».",
pauseOnInsertName: "Пауза при вставке",
pauseOnInsertDesc: "Ставить плеер на паузу при вставке метки времени или скриншота.",
seekOnInsertName: "Перемотка при вставке",
seekOnInsertDesc: "Также перемещать позицию воспроизведения на вставленную метку времени (с учётом смещения).",
playerLocationName: "Расположение плеера",
playerLocationDesc: "Где открывать новый видеоплеер при клике на ссылку с меткой времени, если плеер ещё не открыт.",
playerLocationRight: "Правая боковая панель",
playerLocationBottom: "Снизу от текущей вкладки",
},
};
const LOCALES: Record<Locale, Strings> = { en, ru };
export function detectLocale(): Locale {
let raw = "";
try {
raw = getLanguage();
} catch {
raw = "";
}
if (raw && raw.toLowerCase().startsWith("ru")) return "ru";
return "en";
}
let current: Strings = LOCALES[detectLocale()];
export function t(): Strings {
return current;
}
export function setLocale(locale: Locale): void {
current = LOCALES[locale];
}

339
src/main.ts Normal file
View file

@ -0,0 +1,339 @@
import {
App,
FuzzySuggestModal,
MarkdownView,
Notice,
Plugin,
TFile,
WorkspaceLeaf,
} from "obsidian";
import {
DEFAULT_SETTINGS,
VideoNotesSettings,
VideoNotesSettingTab,
} from "./settings";
import {
VIEW_TYPE_VIDEO_NOTES,
VideoPlayerView,
} from "./view";
import {
buildTimestampWikiLink,
isVideoPath,
parseTimeFragment,
} from "./timestamp";
import { captureFrame, saveScreenshot, seekTo } from "./screenshot";
import { detectLocale, setLocale, t } from "./i18n";
class VideoFileSuggestModal extends FuzzySuggestModal<TFile> {
constructor(app: App, private onPick: (file: TFile) => void) {
super(app);
this.setPlaceholder(t().modal.pickVideoPlaceholder);
}
getItems(): TFile[] {
return this.app.vault.getFiles().filter((f) => isVideoPath(f.path));
}
getItemText(item: TFile): string {
return item.path;
}
onChooseItem(item: TFile): void {
this.onPick(item);
}
}
export default class VideoNotesPlugin extends Plugin {
settings!: VideoNotesSettings;
private lastMarkdownLeaf: WorkspaceLeaf | null = null;
async onload(): Promise<void> {
setLocale(detectLocale());
await this.loadSettings();
this.registerEvent(
this.app.workspace.on("active-leaf-change", (leaf) => {
if (leaf && leaf.view instanceof MarkdownView) {
this.lastMarkdownLeaf = leaf;
}
}),
);
this.registerView(
VIEW_TYPE_VIDEO_NOTES,
(leaf) => {
const view = new VideoPlayerView(leaf);
view.setActions({
onInsertTimestamp: () => this.insertTimestamp(),
onTakeScreenshot: () => void this.takeScreenshot(),
onToggle: () => this.togglePlay(),
onSeek: (delta) => this.seekBy(delta * this.settings.seekStep),
});
return view;
},
);
this.addSettingTab(new VideoNotesSettingTab(this.app, this));
const cmds = t().commands;
this.addCommand({
id: "open-video",
name: cmds.openVideo,
callback: () => {
new VideoFileSuggestModal(this.app, async (file) => {
const view = await this.ensureView();
await view.loadFile(file);
}).open();
},
});
this.addCommand({
id: "toggle-play",
name: cmds.togglePlay,
callback: () => this.togglePlay(),
});
this.addCommand({
id: "resume-play",
name: cmds.resumePlay,
callback: () => this.resumePlay(),
});
this.addCommand({
id: "pause",
name: cmds.pause,
callback: () => this.pausePlay(),
});
this.addCommand({
id: "seek-back",
name: cmds.seekBack,
callback: () => this.seekBy(-this.settings.seekStep),
});
this.addCommand({
id: "seek-forward",
name: cmds.seekForward,
callback: () => this.seekBy(this.settings.seekStep),
});
this.addCommand({
id: "insert-timestamp",
name: cmds.insertTimestamp,
callback: () => this.insertTimestamp(),
});
this.addCommand({
id: "insert-timestamp-no-pause",
name: cmds.insertTimestampNoPause,
callback: () => this.insertTimestamp({ pause: false }),
});
this.addCommand({
id: "insert-timestamp-and-pause",
name: cmds.insertTimestampAndPause,
callback: () => this.insertTimestamp({ pause: true }),
});
this.addCommand({
id: "take-screenshot",
name: cmds.takeScreenshot,
callback: () => void this.takeScreenshot(),
});
this.addCommand({
id: "take-screenshot-no-pause",
name: cmds.takeScreenshotNoPause,
callback: () => void this.takeScreenshot({ pause: false }),
});
this.addCommand({
id: "take-screenshot-and-pause",
name: cmds.takeScreenshotAndPause,
callback: () => void this.takeScreenshot({ pause: true }),
});
this.patchOpenLinkText();
}
onunload(): void {
this.app.workspace.detachLeavesOfType(VIEW_TYPE_VIDEO_NOTES);
}
private patchOpenLinkText() {
const workspace = this.app.workspace;
const original = workspace.openLinkText.bind(workspace);
workspace.openLinkText = async (
linktext: string,
sourcePath: string,
newLeaf?: boolean | "tab" | "split" | "window",
openViewState?: Record<string, unknown>,
) => {
if (linktext.includes("#t=")) {
const parsed = parseTimeFragment(linktext);
if (parsed.t !== null && isVideoPath(parsed.path)) {
const file = this.app.metadataCache.getFirstLinkpathDest(
parsed.path,
sourcePath,
);
if (file) {
const view = await this.ensureView();
await view.loadFile(file, parsed.t);
return;
}
}
}
return original(linktext, sourcePath, newLeaf as never, openViewState as never);
};
this.register(() => {
workspace.openLinkText = original;
});
}
async loadSettings() {
this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData());
}
async saveSettings() {
await this.saveData(this.settings);
}
private async ensureView(): Promise<VideoPlayerView> {
const existing = this.app.workspace.getLeavesOfType(VIEW_TYPE_VIDEO_NOTES);
let leaf: WorkspaceLeaf;
if (existing.length > 0) {
leaf = existing[0];
} else {
leaf = this.createPlayerLeaf();
await leaf.setViewState({ type: VIEW_TYPE_VIDEO_NOTES, active: true });
}
this.app.workspace.revealLeaf(leaf);
return leaf.view as VideoPlayerView;
}
private createPlayerLeaf(): WorkspaceLeaf {
if (this.settings.playerLocation === "bottom") {
const target =
(this.lastMarkdownLeaf &&
this.lastMarkdownLeaf.view instanceof MarkdownView &&
this.lastMarkdownLeaf) ||
this.app.workspace.getMostRecentLeaf() ||
this.app.workspace.getLeaf(true);
return this.app.workspace.createLeafBySplit(target, "horizontal", false);
}
const right = this.app.workspace.getRightLeaf(false);
if (!right) throw new Error("no right leaf available");
return right;
}
private getActiveView(): VideoPlayerView | null {
const leaves = this.app.workspace.getLeavesOfType(VIEW_TYPE_VIDEO_NOTES);
if (leaves.length === 0) return null;
return leaves[0].view as VideoPlayerView;
}
private getTargetMarkdownView(): MarkdownView | null {
const active = this.app.workspace.getActiveViewOfType(MarkdownView);
if (active) return active;
if (
this.lastMarkdownLeaf &&
this.lastMarkdownLeaf.view instanceof MarkdownView
) {
return this.lastMarkdownLeaf.view;
}
return null;
}
private togglePlay() {
const view = this.getActiveView();
if (!view) return;
const v = view.getVideoElement();
if (v.paused) void v.play().catch(() => {});
else v.pause();
}
private resumePlay() {
const view = this.getActiveView();
if (!view) return;
void view.getVideoElement().play().catch(() => {});
}
private pausePlay() {
const view = this.getActiveView();
if (!view) return;
view.getVideoElement().pause();
}
private seekBy(delta: number) {
const view = this.getActiveView();
if (!view) return;
const v = view.getVideoElement();
v.currentTime = Math.max(0, v.currentTime + delta);
}
private insertTimestamp(opts: { pause?: boolean } = {}) {
const view = this.getActiveView();
if (!view || !view.getCurrentFile()) {
new Notice(t().notice.noVideoLoaded);
return;
}
const file = view.getCurrentFile()!;
const videoEl = view.getVideoElement();
const offset = videoEl.paused ? 0 : this.settings.offsetSec;
const ts = Math.max(0, videoEl.currentTime - offset);
const md = this.getTargetMarkdownView();
if (!md) {
new Notice(t().notice.openNoteForTimestamp);
return;
}
const link = buildTimestampWikiLink(file.path, ts);
md.editor.replaceSelection(link);
if (offset > 0 && this.settings.seekOnInsert) videoEl.currentTime = ts;
const shouldPause = opts.pause ?? this.settings.pauseOnInsert;
if (shouldPause) videoEl.pause();
}
private async takeScreenshot(opts: { pause?: boolean } = {}) {
const view = this.getActiveView();
if (!view || !view.getCurrentFile()) {
new Notice(t().notice.noVideoLoaded);
return;
}
const md = this.getTargetMarkdownView();
if (!md) {
new Notice(t().notice.openNoteForScreenshot);
return;
}
const file = view.getCurrentFile()!;
const videoEl = view.getVideoElement();
const wasPlaying = !videoEl.paused;
const offset = wasPlaying ? this.settings.offsetSec : 0;
const rawT = videoEl.currentTime;
const ts = Math.max(0, rawT - offset);
const shouldPause = opts.pause ?? this.settings.pauseOnInsert;
videoEl.pause();
if (offset > 0 && Math.abs(rawT - ts) > 0.01) {
await seekTo(videoEl, ts);
}
let buffer: ArrayBuffer;
try {
buffer = await captureFrame(videoEl);
} catch (e) {
new Notice(t().notice.screenshotFailed((e as Error).message));
if (wasPlaying && !shouldPause) void videoEl.play().catch(() => {});
return;
}
const notePath = md.file?.path ?? "";
const imgFile = await saveScreenshot(this.app, file, ts, buffer, notePath);
const link = buildTimestampWikiLink(file.path, ts);
md.editor.replaceSelection(`![[${imgFile.path}]] ${link}\n`);
if (!shouldPause && wasPlaying) {
void videoEl.play().catch(() => {});
}
}
}

45
src/screenshot.ts Normal file
View file

@ -0,0 +1,45 @@
import { App, TFile, normalizePath } from "obsidian";
import { secondsToLabel } from "./timestamp";
export async function captureFrame(videoEl: HTMLVideoElement): Promise<ArrayBuffer> {
if (videoEl.readyState < 2) {
throw new Error("video not ready");
}
const canvas = document.createElement("canvas");
canvas.width = videoEl.videoWidth;
canvas.height = videoEl.videoHeight;
const ctx = canvas.getContext("2d");
if (!ctx) throw new Error("cannot get 2d context");
ctx.drawImage(videoEl, 0, 0, canvas.width, canvas.height);
const blob = await new Promise<Blob | null>((resolve) =>
canvas.toBlob((b) => resolve(b), "image/png"),
);
if (!blob) throw new Error("toBlob failed");
return await blob.arrayBuffer();
}
export async function seekTo(videoEl: HTMLVideoElement, seconds: number): Promise<void> {
if (Math.abs(videoEl.currentTime - seconds) < 0.001) return;
await new Promise<void>((resolve) => {
const onSeeked = () => {
videoEl.removeEventListener("seeked", onSeeked);
resolve();
};
videoEl.addEventListener("seeked", onSeeked);
videoEl.currentTime = seconds;
});
}
export async function saveScreenshot(
app: App,
videoFile: TFile,
seconds: number,
buffer: ArrayBuffer,
sourceNotePath: string,
): Promise<TFile> {
const label = secondsToLabel(seconds).replace(/:/g, "-");
const baseName = `${videoFile.basename}-${label}.png`;
const availablePath = await app.fileManager.getAvailablePathForAttachment(baseName, sourceNotePath);
const path = normalizePath(availablePath);
return await app.vault.createBinary(path, buffer);
}

105
src/settings.ts Normal file
View file

@ -0,0 +1,105 @@
import { App, PluginSettingTab, Setting } from "obsidian";
import type VideoNotesPlugin from "./main";
import { t } from "./i18n";
export type PlayerLocation = "right" | "bottom";
export interface VideoNotesSettings {
offsetSec: number;
seekStep: number;
pauseOnInsert: boolean;
seekOnInsert: boolean;
playerLocation: PlayerLocation;
}
export const DEFAULT_SETTINGS: VideoNotesSettings = {
offsetSec: 3,
seekStep: 5,
pauseOnInsert: true,
seekOnInsert: true,
playerLocation: "right",
};
export class VideoNotesSettingTab extends PluginSettingTab {
plugin: VideoNotesPlugin;
constructor(app: App, plugin: VideoNotesPlugin) {
super(app, plugin);
this.plugin = plugin;
}
display(): void {
const { containerEl } = this;
containerEl.empty();
const s = t().settings;
new Setting(containerEl)
.setName(s.offsetName)
.setDesc(s.offsetDesc)
.addText((input) =>
input
.setValue(String(this.plugin.settings.offsetSec))
.onChange(async (v) => {
const n = parseFloat(v);
if (Number.isFinite(n) && n >= 0) {
this.plugin.settings.offsetSec = n;
await this.plugin.saveSettings();
}
}),
);
new Setting(containerEl)
.setName(s.seekStepName)
.setDesc(s.seekStepDesc)
.addText((input) =>
input
.setValue(String(this.plugin.settings.seekStep))
.onChange(async (v) => {
const n = parseFloat(v);
if (Number.isFinite(n) && n > 0) {
this.plugin.settings.seekStep = n;
await this.plugin.saveSettings();
}
}),
);
new Setting(containerEl)
.setName(s.pauseOnInsertName)
.setDesc(s.pauseOnInsertDesc)
.addToggle((toggle) =>
toggle
.setValue(this.plugin.settings.pauseOnInsert)
.onChange(async (v) => {
this.plugin.settings.pauseOnInsert = v;
await this.plugin.saveSettings();
}),
);
new Setting(containerEl)
.setName(s.seekOnInsertName)
.setDesc(s.seekOnInsertDesc)
.addToggle((toggle) =>
toggle
.setValue(this.plugin.settings.seekOnInsert)
.onChange(async (v) => {
this.plugin.settings.seekOnInsert = v;
await this.plugin.saveSettings();
}),
);
new Setting(containerEl)
.setName(s.playerLocationName)
.setDesc(s.playerLocationDesc)
.addDropdown((d) =>
d
.addOption("right", s.playerLocationRight)
.addOption("bottom", s.playerLocationBottom)
.setValue(this.plugin.settings.playerLocation)
.onChange(async (v) => {
this.plugin.settings.playerLocation = v as "right" | "bottom";
await this.plugin.saveSettings();
}),
);
}
}

46
src/timestamp.ts Normal file
View file

@ -0,0 +1,46 @@
export function secondsToLabel(totalSeconds: number): string {
const s = Math.max(0, Math.floor(totalSeconds));
const h = Math.floor(s / 3600);
const m = Math.floor((s % 3600) / 60);
const sec = s % 60;
const pad = (n: number) => n.toString().padStart(2, "0");
if (h > 0) return `${h}:${pad(m)}:${pad(sec)}`;
return `${pad(m)}:${pad(sec)}`;
}
export function labelToSeconds(label: string): number {
const parts = label.split(":").map((p) => parseInt(p, 10));
if (parts.some((n) => Number.isNaN(n))) throw new Error(`invalid label: ${label}`);
if (parts.length === 2) return parts[0] * 60 + parts[1];
if (parts.length === 3) return parts[0] * 3600 + parts[1] * 60 + parts[2];
throw new Error(`invalid label: ${label}`);
}
export interface ParsedTimeLink {
path: string;
t: number | null;
}
export function parseTimeFragment(linkPath: string): ParsedTimeLink {
const hashIdx = linkPath.indexOf("#");
if (hashIdx === -1) return { path: linkPath, t: null };
const path = linkPath.slice(0, hashIdx);
const frag = linkPath.slice(hashIdx + 1);
const match = frag.match(/(?:^|&)t=([0-9.]+)/);
if (!match) return { path, t: null };
const t = parseFloat(match[1]);
return { path, t: Number.isFinite(t) ? t : null };
}
export function buildTimestampWikiLink(path: string, seconds: number): string {
const t = Math.max(0, Math.floor(seconds));
return `[[${path}#t=${t}|${secondsToLabel(t)}]]`;
}
const VIDEO_EXTENSIONS = new Set(["mp4", "webm", "mkv", "mov", "m4v", "ogv"]);
export function isVideoPath(path: string): boolean {
const dot = path.lastIndexOf(".");
if (dot === -1) return false;
return VIDEO_EXTENSIONS.has(path.slice(dot + 1).toLowerCase());
}

124
src/view.ts Normal file
View file

@ -0,0 +1,124 @@
import { ItemView, TFile, WorkspaceLeaf } from "obsidian";
import { secondsToLabel } from "./timestamp";
import { t } from "./i18n";
export const VIEW_TYPE_VIDEO_NOTES = "video-notes-player";
export interface ViewActions {
onInsertTimestamp: () => void;
onTakeScreenshot: () => void;
onToggle: () => void;
onSeek: (delta: number) => void;
}
export class VideoPlayerView extends ItemView {
private videoEl!: HTMLVideoElement;
private statusEl!: HTMLElement;
private currentFile: TFile | null = null;
private actions: ViewActions | null = null;
constructor(leaf: WorkspaceLeaf) {
super(leaf);
}
getViewType(): string {
return VIEW_TYPE_VIDEO_NOTES;
}
getDisplayText(): string {
return this.currentFile ? this.currentFile.basename : t().view.localVideo;
}
getIcon(): string {
return "play-circle";
}
setActions(actions: ViewActions) {
this.actions = actions;
}
async onOpen(): Promise<void> {
const root = this.containerEl.children[1];
root.empty();
root.addClass("vn-view");
this.videoEl = root.createEl("video", { cls: "vn-video" });
this.videoEl.controls = true;
this.videoEl.preload = "metadata";
const toolbar = root.createDiv({ cls: "vn-toolbar" });
const mkBtn = (label: string, handler: () => void) => {
const b = toolbar.createEl("button", { text: label });
b.addEventListener("click", handler);
return b;
};
const tr = t().view;
mkBtn("▶/⏸", () => this.actions?.onToggle());
mkBtn("", () => this.actions?.onSeek(-1));
mkBtn("+", () => this.actions?.onSeek(1));
mkBtn(tr.btnTimestamp, () => this.actions?.onInsertTimestamp());
mkBtn(tr.btnScreenshot, () => this.actions?.onTakeScreenshot());
this.statusEl = root.createDiv({ cls: "vn-status" });
this.updateStatus();
this.videoEl.addEventListener("timeupdate", () => this.updateStatus());
this.videoEl.addEventListener("loadedmetadata", () => this.updateStatus());
}
async onClose(): Promise<void> {
if (this.videoEl) {
this.videoEl.pause();
this.videoEl.removeAttribute("src");
this.videoEl.load();
}
}
async loadFile(file: TFile, seekSeconds?: number): Promise<void> {
const url = this.app.vault.getResourcePath(file);
const sameFile = this.currentFile?.path === file.path;
this.currentFile = file;
if (!sameFile) {
this.videoEl.src = url;
await new Promise<void>((resolve) => {
const onLoaded = () => {
this.videoEl.removeEventListener("loadedmetadata", onLoaded);
resolve();
};
this.videoEl.addEventListener("loadedmetadata", onLoaded);
});
}
if (seekSeconds !== undefined && Number.isFinite(seekSeconds)) {
this.videoEl.currentTime = Math.max(0, seekSeconds);
}
void this.videoEl.play().catch(() => {});
this.updateStatus();
}
getCurrentFile(): TFile | null {
return this.currentFile;
}
getCurrentTime(): number {
return this.videoEl?.currentTime ?? 0;
}
getVideoElement(): HTMLVideoElement {
return this.videoEl;
}
private updateStatus() {
if (!this.statusEl) return;
if (!this.currentFile) {
this.statusEl.setText(t().view.noVideoLoaded);
return;
}
const cur = secondsToLabel(this.videoEl.currentTime || 0);
const total = this.videoEl.duration
? secondsToLabel(this.videoEl.duration)
: "--:--";
this.statusEl.setText(`${this.currentFile.basename} ${cur} / ${total}`);
}
}

40
styles.css Normal file
View file

@ -0,0 +1,40 @@
.vn-view {
display: flex;
flex-direction: column;
gap: 8px;
padding: 8px;
height: 100%;
}
.vn-video {
width: 100%;
max-height: 60%;
background: #000;
border-radius: 4px;
}
.vn-toolbar {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.vn-toolbar button {
flex: 1 1 auto;
min-width: 64px;
padding: 4px 8px;
cursor: pointer;
}
.vn-status {
font-family: var(--font-monospace);
font-size: 12px;
color: var(--text-muted);
}
.vn-empty {
color: var(--text-muted);
font-style: italic;
padding: 16px;
text-align: center;
}

80
tests/timestamp.test.ts Normal file
View file

@ -0,0 +1,80 @@
import {
secondsToLabel,
labelToSeconds,
parseTimeFragment,
buildTimestampWikiLink,
isVideoPath,
} from "../src/timestamp";
describe("secondsToLabel", () => {
test("under an hour → MM:SS", () => {
expect(secondsToLabel(0)).toBe("00:00");
expect(secondsToLabel(9)).toBe("00:09");
expect(secondsToLabel(60)).toBe("01:00");
expect(secondsToLabel(736)).toBe("12:16");
expect(secondsToLabel(3599)).toBe("59:59");
});
test("≥ one hour → H:MM:SS", () => {
expect(secondsToLabel(3600)).toBe("1:00:00");
expect(secondsToLabel(3661)).toBe("1:01:01");
});
test("floors fractional seconds", () => {
expect(secondsToLabel(736.7)).toBe("12:16");
});
test("clamps negatives", () => {
expect(secondsToLabel(-5)).toBe("00:00");
});
});
describe("labelToSeconds", () => {
test("MM:SS", () => {
expect(labelToSeconds("12:16")).toBe(736);
expect(labelToSeconds("00:00")).toBe(0);
});
test("H:MM:SS", () => {
expect(labelToSeconds("1:01:01")).toBe(3661);
});
test("throws on garbage", () => {
expect(() => labelToSeconds("abc")).toThrow();
});
});
describe("parseTimeFragment", () => {
test("extracts path and t", () => {
expect(parseTimeFragment("video.mp4#t=736")).toEqual({ path: "video.mp4", t: 736 });
});
test("returns t=null for missing fragment", () => {
expect(parseTimeFragment("video.mp4")).toEqual({ path: "video.mp4", t: null });
});
test("handles decimal seconds", () => {
expect(parseTimeFragment("v.mp4#t=12.5")).toEqual({ path: "v.mp4", t: 12.5 });
});
test("ignores fragments without t=", () => {
expect(parseTimeFragment("v.mp4#anchor")).toEqual({ path: "v.mp4", t: null });
});
});
describe("buildTimestampWikiLink", () => {
test("round trip 12:16", () => {
expect(buildTimestampWikiLink("video.mp4", 736)).toBe("[[video.mp4#t=736|12:16]]");
});
test("floors before building", () => {
expect(buildTimestampWikiLink("v.mp4", 736.9)).toBe("[[v.mp4#t=736|12:16]]");
});
});
describe("isVideoPath", () => {
test.each([
["movie.mp4", true],
["clip.MKV", true],
["x.webm", true],
["note.md", false],
["readme", false],
["image.png", false],
])("%s → %s", (path, expected) => {
expect(isVideoPath(path)).toBe(expected);
});
});

20
tsconfig.json Normal file
View file

@ -0,0 +1,20 @@
{
"compilerOptions": {
"baseUrl": ".",
"inlineSourceMap": true,
"inlineSources": true,
"module": "ESNext",
"target": "ES2020",
"allowJs": true,
"noImplicitAny": true,
"moduleResolution": "node",
"importHelpers": true,
"isolatedModules": true,
"strictNullChecks": true,
"esModuleInterop": true,
"skipLibCheck": true,
"lib": ["DOM", "ES2020"],
"types": ["node", "jest"]
},
"include": ["src/**/*.ts", "demo/**/*.ts", "tests/**/*.ts"]
}

12
version-bump.mjs Normal file
View file

@ -0,0 +1,12 @@
import { readFileSync, writeFileSync } from "fs";
const targetVersion = process.env.npm_package_version;
const manifest = JSON.parse(readFileSync("manifest.json", "utf8"));
const { minAppVersion } = manifest;
manifest.version = targetVersion;
writeFileSync("manifest.json", JSON.stringify(manifest, null, "\t") + "\n");
const versions = JSON.parse(readFileSync("versions.json", "utf8"));
versions[targetVersion] = minAppVersion;
writeFileSync("versions.json", JSON.stringify(versions, null, "\t") + "\n");

3
versions.json Normal file
View file

@ -0,0 +1,3 @@
{
"0.0.1": "1.4.0"
}

42
wdio.demo.conf.mjs Normal file
View file

@ -0,0 +1,42 @@
import fs from "node:fs";
import path from "node:path";
import process from "node:process";
import { fileURLToPath } from "node:url";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const manifest = JSON.parse(
fs.readFileSync(path.join(__dirname, "manifest.json"), "utf8"),
);
const cacheDir = path.resolve(".obsidian-cache");
const obsidianVersion = process.env.OBSIDIAN_TEST_VERSION ?? manifest.minAppVersion;
const installerVersion = process.env.OBSIDIAN_INSTALLER_VERSION ?? manifest.minAppVersion;
export const config = {
runner: "local",
framework: "mocha",
specs: ["./obsidian-tests/demo/readme-screenshots.e2e.mjs"],
maxInstances: 1,
capabilities: [
{
browserName: "obsidian",
browserVersion: obsidianVersion,
"wdio:obsidianOptions": {
installerVersion,
plugins: ["."],
vault: "./obsidian-tests/demo-vault",
},
},
],
services: ["obsidian"],
reporters: ["obsidian"],
mochaOpts: {
ui: "bdd",
timeout: 180000,
},
waitforInterval: 250,
waitforTimeout: 20000,
logLevel: "info",
injectGlobals: false,
cacheDir,
};