mirror of
https://github.com/rekby/obsidian-video-notes.git
synced 2026-07-22 06:08:35 +00:00
init
This commit is contained in:
commit
fb28bd4545
34 changed files with 13982 additions and 0 deletions
21
.eslintrc.js
Normal file
21
.eslintrc.js
Normal 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
72
.github/workflows/publish.yml
vendored
Normal 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
34
.github/workflows/test.yml
vendored
Normal 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
14
.gitignore
vendored
Normal 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
94
README.md
Normal 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
|
||||
|
||||

|
||||

|
||||

|
||||
|
||||
> 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
94
README.ru.md
Normal 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, перемотка назад/вперёд на настраиваемый шаг. Все команды можно повесить на хоткеи.
|
||||
|
||||
## Скриншоты
|
||||
|
||||

|
||||

|
||||

|
||||
|
||||
> Видео для демо: *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
|
||||
BIN
docs/screenshots/en/player.png
Normal file
BIN
docs/screenshots/en/player.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 96 KiB |
BIN
docs/screenshots/en/screenshot-inserted.png
Normal file
BIN
docs/screenshots/en/screenshot-inserted.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 266 KiB |
BIN
docs/screenshots/en/timestamp-inserted.png
Normal file
BIN
docs/screenshots/en/timestamp-inserted.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 107 KiB |
BIN
docs/screenshots/ru/player.png
Normal file
BIN
docs/screenshots/ru/player.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 103 KiB |
BIN
docs/screenshots/ru/screenshot-inserted.png
Normal file
BIN
docs/screenshots/ru/screenshot-inserted.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 273 KiB |
BIN
docs/screenshots/ru/timestamp-inserted.png
Normal file
BIN
docs/screenshots/ru/timestamp-inserted.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 113 KiB |
47
esbuild.config.mjs
Normal file
47
esbuild.config.mjs
Normal 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
10
jest.config.js
Normal 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
10
manifest.json
Normal 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
|
||||
}
|
||||
3
obsidian-tests/demo-vault/Caminandes notes.md
Normal file
3
obsidian-tests/demo-vault/Caminandes notes.md
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
# Caminandes 3 — watching notes
|
||||
|
||||
Taking timestamps and frame grabs from the Blender short.
|
||||
3
obsidian-tests/demo-vault/Заметки к Caminandes.md
Normal file
3
obsidian-tests/demo-vault/Заметки к Caminandes.md
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
# Caminandes 3 — заметки по просмотру
|
||||
|
||||
Делаю таймкоды и стоп-кадры по короткометражке Blender.
|
||||
234
obsidian-tests/demo/readme-screenshots.e2e.mjs
Normal file
234
obsidian-tests/demo/readme-screenshots.e2e.mjs
Normal 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
12189
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
45
package.json
Normal file
45
package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
31
scripts/demo-video-meta.mjs
Normal file
31
scripts/demo-video-meta.mjs
Normal 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(" ");
|
||||
61
scripts/fetch-demo-video.mjs
Normal file
61
scripts/fetch-demo-video.mjs
Normal 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
164
src/i18n.ts
Normal 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
339
src/main.ts
Normal 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
45
src/screenshot.ts
Normal 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
105
src/settings.ts
Normal 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
46
src/timestamp.ts
Normal 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
124
src/view.ts
Normal 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
40
styles.css
Normal 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
80
tests/timestamp.test.ts
Normal 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
20
tsconfig.json
Normal 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
12
version-bump.mjs
Normal 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
3
versions.json
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"0.0.1": "1.4.0"
|
||||
}
|
||||
42
wdio.demo.conf.mjs
Normal file
42
wdio.demo.conf.mjs
Normal 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,
|
||||
};
|
||||
Loading…
Reference in a new issue