mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-10-27 11:35:23 -07:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
203a61a20f
|
||
|
|
a681bbaf25
|
||
|
|
6219a81144 | ||
|
|
cc9af79d6e | ||
|
|
d3a94536a8
|
||
|
|
3068d02f89
|
||
|
|
9150a60630 | ||
|
|
3549e0ed93 |
No files matched your search
@@ -67,26 +67,26 @@ jobs:
|
|||||||
- name: Build unversioned AppImage
|
- name: Build unversioned AppImage
|
||||||
run: |
|
run: |
|
||||||
shopt -s nullglob
|
shopt -s nullglob
|
||||||
appimages=(release/SubMiner-*.AppImage)
|
appimages=(dist/release/SubMiner-*.AppImage)
|
||||||
if [ "${#appimages[@]}" -eq 0 ]; then
|
if [ "${#appimages[@]}" -eq 0 ]; then
|
||||||
echo "No versioned AppImage found to create unversioned artifact."
|
echo "No versioned AppImage found to create unversioned artifact."
|
||||||
ls -la release
|
ls -la dist/release
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
cp "${appimages[0]}" release/SubMiner.AppImage
|
cp "${appimages[0]}" dist/release/SubMiner.AppImage
|
||||||
|
|
||||||
- name: Smoke packaged runtime assets
|
- name: Smoke packaged runtime assets
|
||||||
shell: bash
|
shell: bash
|
||||||
run: xvfb-run -a bun run test:package "release/linux-unpacked/resources"
|
run: xvfb-run -a bun run test:package "dist/release/linux-unpacked/resources"
|
||||||
|
|
||||||
- name: Upload AppImage artifact
|
- name: Upload AppImage artifact
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: appimage
|
name: appimage
|
||||||
path: |
|
path: |
|
||||||
release/*.AppImage
|
dist/release/*.AppImage
|
||||||
release/latest*.yml
|
dist/release/latest*.yml
|
||||||
release/*.blockmap
|
dist/release/*.blockmap
|
||||||
if-no-files-found: error
|
if-no-files-found: error
|
||||||
|
|
||||||
build-macos:
|
build-macos:
|
||||||
@@ -159,17 +159,17 @@ jobs:
|
|||||||
|
|
||||||
- name: Smoke packaged runtime assets
|
- name: Smoke packaged runtime assets
|
||||||
shell: bash
|
shell: bash
|
||||||
run: bun run test:package "release/mac-arm64/SubMiner.app/Contents/Resources"
|
run: bun run test:package "dist/release/mac-arm64/SubMiner.app/Contents/Resources"
|
||||||
|
|
||||||
- name: Upload macOS artifacts
|
- name: Upload macOS artifacts
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: macos
|
name: macos
|
||||||
path: |
|
path: |
|
||||||
release/*.dmg
|
dist/release/*.dmg
|
||||||
release/*.zip
|
dist/release/*.zip
|
||||||
release/latest*.yml
|
dist/release/latest*.yml
|
||||||
release/*.blockmap
|
dist/release/*.blockmap
|
||||||
if-no-files-found: error
|
if-no-files-found: error
|
||||||
|
|
||||||
build-windows:
|
build-windows:
|
||||||
@@ -224,15 +224,15 @@ jobs:
|
|||||||
|
|
||||||
- name: Smoke packaged runtime assets
|
- name: Smoke packaged runtime assets
|
||||||
shell: bash
|
shell: bash
|
||||||
run: bun run test:package "release/win-unpacked/resources"
|
run: bun run test:package "dist/release/win-unpacked/resources"
|
||||||
|
|
||||||
- name: Upload Windows artifacts
|
- name: Upload Windows artifacts
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: windows
|
name: windows
|
||||||
path: |
|
path: |
|
||||||
release/*.exe
|
dist/release/*.exe
|
||||||
release/*.zip
|
dist/release/*.zip
|
||||||
release/latest*.yml
|
dist/release/latest*.yml
|
||||||
release/*.blockmap
|
dist/release/*.blockmap
|
||||||
if-no-files-found: error
|
if-no-files-found: error
|
||||||
@@ -44,19 +44,19 @@ jobs:
|
|||||||
uses: actions/download-artifact@v4
|
uses: actions/download-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: appimage
|
name: appimage
|
||||||
path: release
|
path: dist/release
|
||||||
|
|
||||||
- name: Download macOS artifacts
|
- name: Download macOS artifacts
|
||||||
uses: actions/download-artifact@v4
|
uses: actions/download-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: macos
|
name: macos
|
||||||
path: release
|
path: dist/release
|
||||||
|
|
||||||
- name: Download Windows artifacts
|
- name: Download Windows artifacts
|
||||||
uses: actions/download-artifact@v4
|
uses: actions/download-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: windows
|
name: windows
|
||||||
path: release
|
path: dist/release
|
||||||
|
|
||||||
- name: Setup Bun
|
- name: Setup Bun
|
||||||
uses: oven-sh/setup-bun@v2
|
uses: oven-sh/setup-bun@v2
|
||||||
@@ -90,13 +90,15 @@ jobs:
|
|||||||
|
|
||||||
- name: Package optional assets bundle
|
- name: Package optional assets bundle
|
||||||
run: |
|
run: |
|
||||||
tar -czf "release/subminer-assets.tar.gz" \
|
# Keep the archive layout stable for the AUR PKGBUILD.
|
||||||
|
tar -czf "dist/release/subminer-assets.tar.gz" \
|
||||||
|
--transform 's,^packaging/bun/licenses,resources/bun/licenses,' \
|
||||||
config.example.jsonc \
|
config.example.jsonc \
|
||||||
plugin/subminer \
|
plugin/subminer \
|
||||||
plugin/subminer.conf \
|
plugin/subminer.conf \
|
||||||
assets/themes/subminer.rasi \
|
assets/themes/subminer.rasi \
|
||||||
assets/thumbnailers/subminer-ffmpegthumbnailer.thumbnailer \
|
assets/thumbnailers/subminer-ffmpegthumbnailer.thumbnailer \
|
||||||
resources/bun/licenses
|
packaging/bun/licenses
|
||||||
|
|
||||||
- name: Package Bun corresponding source
|
- name: Package Bun corresponding source
|
||||||
run: bun scripts/package-bun-source.mjs
|
run: bun scripts/package-bun-source.mjs
|
||||||
@@ -104,16 +106,16 @@ jobs:
|
|||||||
- name: Generate checksums
|
- name: Generate checksums
|
||||||
run: |
|
run: |
|
||||||
shopt -s nullglob
|
shopt -s nullglob
|
||||||
files=(release/*.AppImage release/*.dmg release/*.exe release/*.zip release/*.tar.gz release/latest*.yml release/*.blockmap dist/launcher/subminer dist/launcher/subminer.cmd)
|
files=(dist/release/*.AppImage dist/release/*.dmg dist/release/*.exe dist/release/*.zip dist/release/*.tar.gz dist/release/latest*.yml dist/release/*.blockmap dist/launcher/subminer dist/launcher/subminer.cmd)
|
||||||
if [ "${#files[@]}" -eq 0 ]; then
|
if [ "${#files[@]}" -eq 0 ]; then
|
||||||
echo "No release artifacts found for checksum generation."
|
echo "No release artifacts found for checksum generation."
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
: > release/SHA256SUMS.txt
|
: > dist/release/SHA256SUMS.txt
|
||||||
for file in "${files[@]}"; do
|
for file in "${files[@]}"; do
|
||||||
printf '%s %s\n' \
|
printf '%s %s\n' \
|
||||||
"$(sha256sum "$file" | awk '{print $1}')" \
|
"$(sha256sum "$file" | awk '{print $1}')" \
|
||||||
"${file##*/}" >> release/SHA256SUMS.txt
|
"${file##*/}" >> dist/release/SHA256SUMS.txt
|
||||||
done
|
done
|
||||||
|
|
||||||
- name: Get version from tag
|
- name: Get version from tag
|
||||||
@@ -124,12 +126,12 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
RELEASE_VERSION: ${{ steps.version.outputs.VERSION }}
|
RELEASE_VERSION: ${{ steps.version.outputs.VERSION }}
|
||||||
run: |
|
run: |
|
||||||
if [ ! -s release/prerelease-notes.md ]; then
|
if [ ! -s packaging/release-notes/prerelease-notes.md ]; then
|
||||||
echo "::error::release/prerelease-notes.md is missing or empty. Run 'bun run changelog:prerelease-notes --version <version>' locally and commit the file before tagging."
|
echo "::error::packaging/release-notes/prerelease-notes.md is missing or empty. Run 'bun run changelog:prerelease-notes --version <version>' locally and commit the file before tagging."
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
if ! bun run changelog:check-prerelease-notes --version "$RELEASE_VERSION"; then
|
if ! bun run changelog:check-prerelease-notes --version "$RELEASE_VERSION"; then
|
||||||
echo "::error::release/prerelease-notes.md was not generated for $RELEASE_VERSION. Rerun 'bun run changelog:prerelease-notes --version $RELEASE_VERSION' locally, commit, and retag."
|
echo "::error::packaging/release-notes/prerelease-notes.md was not generated for $RELEASE_VERSION. Rerun 'bun run changelog:prerelease-notes --version $RELEASE_VERSION' locally, commit, and retag."
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -142,15 +144,15 @@ jobs:
|
|||||||
|
|
||||||
shopt -s nullglob
|
shopt -s nullglob
|
||||||
artifacts=(
|
artifacts=(
|
||||||
release/*.AppImage
|
dist/release/*.AppImage
|
||||||
release/*.dmg
|
dist/release/*.dmg
|
||||||
release/*.exe
|
dist/release/*.exe
|
||||||
release/*.zip
|
dist/release/*.zip
|
||||||
release/*.tar.gz
|
dist/release/*.tar.gz
|
||||||
release/*.tar.gz.sha256
|
dist/release/*.tar.gz.sha256
|
||||||
release/latest*.yml
|
dist/release/latest*.yml
|
||||||
release/*.blockmap
|
dist/release/*.blockmap
|
||||||
release/SHA256SUMS.txt
|
dist/release/SHA256SUMS.txt
|
||||||
dist/launcher/subminer
|
dist/launcher/subminer
|
||||||
dist/launcher/subminer.cmd
|
dist/launcher/subminer.cmd
|
||||||
)
|
)
|
||||||
@@ -165,14 +167,14 @@ jobs:
|
|||||||
--draft \
|
--draft \
|
||||||
--prerelease \
|
--prerelease \
|
||||||
--title "$RELEASE_VERSION" \
|
--title "$RELEASE_VERSION" \
|
||||||
--notes-file release/prerelease-notes.md
|
--notes-file packaging/release-notes/prerelease-notes.md
|
||||||
else
|
else
|
||||||
gh release create "$RELEASE_VERSION" \
|
gh release create "$RELEASE_VERSION" \
|
||||||
--draft \
|
--draft \
|
||||||
--latest=false \
|
--latest=false \
|
||||||
--prerelease \
|
--prerelease \
|
||||||
--title "$RELEASE_VERSION" \
|
--title "$RELEASE_VERSION" \
|
||||||
--notes-file release/prerelease-notes.md
|
--notes-file packaging/release-notes/prerelease-notes.md
|
||||||
fi
|
fi
|
||||||
|
|
||||||
for asset in "${artifacts[@]}"; do
|
for asset in "${artifacts[@]}"; do
|
||||||
@@ -183,4 +185,4 @@ jobs:
|
|||||||
--draft=false \
|
--draft=false \
|
||||||
--prerelease \
|
--prerelease \
|
||||||
--title "$RELEASE_VERSION" \
|
--title "$RELEASE_VERSION" \
|
||||||
--notes-file release/prerelease-notes.md
|
--notes-file packaging/release-notes/prerelease-notes.md
|
||||||
@@ -45,19 +45,19 @@ jobs:
|
|||||||
uses: actions/download-artifact@v4
|
uses: actions/download-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: appimage
|
name: appimage
|
||||||
path: release
|
path: dist/release
|
||||||
|
|
||||||
- name: Download macOS artifacts
|
- name: Download macOS artifacts
|
||||||
uses: actions/download-artifact@v4
|
uses: actions/download-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: macos
|
name: macos
|
||||||
path: release
|
path: dist/release
|
||||||
|
|
||||||
- name: Download Windows artifacts
|
- name: Download Windows artifacts
|
||||||
uses: actions/download-artifact@v4
|
uses: actions/download-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: windows
|
name: windows
|
||||||
path: release
|
path: dist/release
|
||||||
|
|
||||||
- name: Setup Bun
|
- name: Setup Bun
|
||||||
uses: oven-sh/setup-bun@v2
|
uses: oven-sh/setup-bun@v2
|
||||||
@@ -91,13 +91,15 @@ jobs:
|
|||||||
|
|
||||||
- name: Package optional assets bundle
|
- name: Package optional assets bundle
|
||||||
run: |
|
run: |
|
||||||
tar -czf "release/subminer-assets.tar.gz" \
|
# Keep the archive layout stable for the AUR PKGBUILD.
|
||||||
|
tar -czf "dist/release/subminer-assets.tar.gz" \
|
||||||
|
--transform 's,^packaging/bun/licenses,resources/bun/licenses,' \
|
||||||
config.example.jsonc \
|
config.example.jsonc \
|
||||||
plugin/subminer \
|
plugin/subminer \
|
||||||
plugin/subminer.conf \
|
plugin/subminer.conf \
|
||||||
assets/themes/subminer.rasi \
|
assets/themes/subminer.rasi \
|
||||||
assets/thumbnailers/subminer-ffmpegthumbnailer.thumbnailer \
|
assets/thumbnailers/subminer-ffmpegthumbnailer.thumbnailer \
|
||||||
resources/bun/licenses
|
packaging/bun/licenses
|
||||||
|
|
||||||
- name: Package Bun corresponding source
|
- name: Package Bun corresponding source
|
||||||
run: bun scripts/package-bun-source.mjs
|
run: bun scripts/package-bun-source.mjs
|
||||||
@@ -105,16 +107,16 @@ jobs:
|
|||||||
- name: Generate checksums
|
- name: Generate checksums
|
||||||
run: |
|
run: |
|
||||||
shopt -s nullglob
|
shopt -s nullglob
|
||||||
files=(release/*.AppImage release/*.dmg release/*.exe release/*.zip release/*.tar.gz release/latest*.yml release/*.blockmap dist/launcher/subminer dist/launcher/subminer.cmd)
|
files=(dist/release/*.AppImage dist/release/*.dmg dist/release/*.exe dist/release/*.zip dist/release/*.tar.gz dist/release/latest*.yml dist/release/*.blockmap dist/launcher/subminer dist/launcher/subminer.cmd)
|
||||||
if [ "${#files[@]}" -eq 0 ]; then
|
if [ "${#files[@]}" -eq 0 ]; then
|
||||||
echo "No release artifacts found for checksum generation."
|
echo "No release artifacts found for checksum generation."
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
: > release/SHA256SUMS.txt
|
: > dist/release/SHA256SUMS.txt
|
||||||
for file in "${files[@]}"; do
|
for file in "${files[@]}"; do
|
||||||
printf '%s %s\n' \
|
printf '%s %s\n' \
|
||||||
"$(sha256sum "$file" | awk '{print $1}')" \
|
"$(sha256sum "$file" | awk '{print $1}')" \
|
||||||
"${file##*/}" >> release/SHA256SUMS.txt
|
"${file##*/}" >> dist/release/SHA256SUMS.txt
|
||||||
done
|
done
|
||||||
|
|
||||||
- name: Get version from tag
|
- name: Get version from tag
|
||||||
@@ -152,24 +154,24 @@ jobs:
|
|||||||
gh release edit "$RELEASE_VERSION" \
|
gh release edit "$RELEASE_VERSION" \
|
||||||
--draft=false \
|
--draft=false \
|
||||||
--title "$RELEASE_VERSION" \
|
--title "$RELEASE_VERSION" \
|
||||||
--notes-file release/release-notes.md
|
--notes-file packaging/release-notes/release-notes.md
|
||||||
else
|
else
|
||||||
gh release create "$RELEASE_VERSION" \
|
gh release create "$RELEASE_VERSION" \
|
||||||
--title "$RELEASE_VERSION" \
|
--title "$RELEASE_VERSION" \
|
||||||
--notes-file release/release-notes.md
|
--notes-file packaging/release-notes/release-notes.md
|
||||||
fi
|
fi
|
||||||
|
|
||||||
shopt -s nullglob
|
shopt -s nullglob
|
||||||
artifacts=(
|
artifacts=(
|
||||||
release/*.AppImage
|
dist/release/*.AppImage
|
||||||
release/*.dmg
|
dist/release/*.dmg
|
||||||
release/*.exe
|
dist/release/*.exe
|
||||||
release/*.zip
|
dist/release/*.zip
|
||||||
release/*.tar.gz
|
dist/release/*.tar.gz
|
||||||
release/*.tar.gz.sha256
|
dist/release/*.tar.gz.sha256
|
||||||
release/latest*.yml
|
dist/release/latest*.yml
|
||||||
release/*.blockmap
|
dist/release/*.blockmap
|
||||||
release/SHA256SUMS.txt
|
dist/release/SHA256SUMS.txt
|
||||||
dist/launcher/subminer
|
dist/launcher/subminer
|
||||||
dist/launcher/subminer.cmd
|
dist/launcher/subminer.cmd
|
||||||
)
|
)
|
||||||
|
|||||||
+1
-7
@@ -8,11 +8,6 @@ node_modules/
|
|||||||
# Electron build output
|
# Electron build output
|
||||||
out/
|
out/
|
||||||
dist/
|
dist/
|
||||||
release/*
|
|
||||||
!release/
|
|
||||||
!release/release-notes.md
|
|
||||||
!release/prerelease-notes.md
|
|
||||||
build/yomitan/
|
|
||||||
coverage/
|
coverage/
|
||||||
|
|
||||||
# Launcher build artifact (produced by make build-launcher)
|
# Launcher build artifact (produced by make build-launcher)
|
||||||
@@ -20,8 +15,6 @@ coverage/
|
|||||||
/main-entry.js
|
/main-entry.js
|
||||||
/main-entry.js.map
|
/main-entry.js.map
|
||||||
|
|
||||||
/build/hachidori/
|
|
||||||
|
|
||||||
# Logs
|
# Logs
|
||||||
*.log
|
*.log
|
||||||
npm-debug.log*
|
npm-debug.log*
|
||||||
@@ -43,6 +36,7 @@ environment.toml
|
|||||||
.vitepress/dist/
|
.vitepress/dist/
|
||||||
docs/.vitepress/cache/
|
docs/.vitepress/cache/
|
||||||
docs/.vitepress/dist/
|
docs/.vitepress/dist/
|
||||||
|
# Local test media and scripts
|
||||||
tests/*
|
tests/*
|
||||||
.worktrees/
|
.worktrees/
|
||||||
.tmp/
|
.tmp/
|
||||||
|
|||||||
+3
-1
@@ -1,7 +1,9 @@
|
|||||||
node_modules
|
node_modules
|
||||||
dist
|
dist
|
||||||
release
|
|
||||||
coverage
|
coverage
|
||||||
vendor
|
vendor
|
||||||
*.log
|
*.log
|
||||||
src/core/services/tokenizer/__fixtures__/golden/*.json
|
src/core/services/tokenizer/__fixtures__/golden/*.json
|
||||||
|
# Reviewed release text and vendored license texts; never reformat
|
||||||
|
packaging/release-notes
|
||||||
|
packaging/bun/licenses
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
{
|
|
||||||
"printWidth": 100,
|
|
||||||
"singleQuote": true,
|
|
||||||
"trailingComma": "all"
|
|
||||||
}
|
|
||||||
@@ -1,5 +1,28 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## v0.21.1 (2026-10-26)
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **Stats Session View**: Redesigned the expanded session view in the stats dashboard.
|
||||||
|
- New stat tiles show pace, lines, known-word rate, lookups, and paused time.
|
||||||
|
- A reading density chart of known and unknown words replaces the cumulative chart.
|
||||||
|
- Yomitan lookups and mined cards now appear in their own lane below the chart.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **Subtitle Retiming**: Mining, the subtitle sidebar, subtitle navigation, and replay now use the timing you set with mpv's subtitle delay keys (z/x and sub-step), so mined audio and images match the retimed line.
|
||||||
|
- **Subtitle Sidebar**: mpv keys and mouse actions, like volume keys and double-click fullscreen, work again while the sidebar is open.
|
||||||
|
|
||||||
|
### Docs
|
||||||
|
- **README**: Refreshed the anime screenshots using the current build.
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Internal changes</summary>
|
||||||
|
|
||||||
|
### Internal
|
||||||
|
- **Packaging Layout**: Packaging inputs now live under `packaging/`, and all build output lands in `dist/`, with packaged apps in `dist/release/`.
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
## v0.21.0 (2026-10-24)
|
## v0.21.0 (2026-10-24)
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -21,11 +21,10 @@ MACOS_DATA_DIR ?= $(HOME)/Library/Application Support/SubMiner
|
|||||||
MACOS_APP_DIR ?= $(HOME)/Applications
|
MACOS_APP_DIR ?= $(HOME)/Applications
|
||||||
MACOS_APP_DEST ?= $(MACOS_APP_DIR)/SubMiner.app
|
MACOS_APP_DEST ?= $(MACOS_APP_DIR)/SubMiner.app
|
||||||
|
|
||||||
# If building from source, the AppImage will typically land in release/.
|
# If building from source, the AppImage will typically land in dist/release/.
|
||||||
APPIMAGE_SRC = $(firstword $(wildcard release/SubMiner-*.AppImage))
|
APPIMAGE_SRC = $(firstword $(wildcard dist/release/SubMiner-*.AppImage))
|
||||||
MACOS_APP_SRC = $(firstword $(wildcard release/*.app release/*/*.app))
|
MACOS_APP_SRC = $(firstword $(wildcard dist/release/*.app dist/release/*/*.app))
|
||||||
MACOS_ZIP_SRC = $(firstword $(wildcard release/SubMiner-*.zip))
|
MACOS_ZIP_SRC = $(firstword $(wildcard dist/release/SubMiner-*.zip))
|
||||||
PRERELEASE_NOTES := release/prerelease-notes.md
|
|
||||||
|
|
||||||
UNAME_S := $(shell uname -s 2>/dev/null || echo Unknown)
|
UNAME_S := $(shell uname -s 2>/dev/null || echo Unknown)
|
||||||
ifeq ($(OS),Windows_NT)
|
ifeq ($(OS),Windows_NT)
|
||||||
@@ -57,7 +56,7 @@ help:
|
|||||||
" build-linux Build Linux AppImage" \
|
" build-linux Build Linux AppImage" \
|
||||||
" build-macos Build macOS DMG/ZIP (signed if configured)" \
|
" build-macos Build macOS DMG/ZIP (signed if configured)" \
|
||||||
" build-macos-unsigned Build macOS DMG/ZIP without signing/notarization" \
|
" build-macos-unsigned Build macOS DMG/ZIP without signing/notarization" \
|
||||||
" clean Remove build artifacts (dist/, release/, AppImage, binary)" \
|
" clean Remove build artifacts (dist/, AppImage, binary)" \
|
||||||
" dev-start Build and launch local Electron app" \
|
" dev-start Build and launch local Electron app" \
|
||||||
" dev-start-macos Build and launch local Electron app with macOS tracker backend" \
|
" dev-start-macos Build and launch local Electron app with macOS tracker backend" \
|
||||||
" dev-watch Start fast watch loop (tsc + renderer + Electron dev app)" \
|
" dev-watch Start fast watch loop (tsc + renderer + Electron dev app)" \
|
||||||
@@ -165,15 +164,7 @@ build-launcher:
|
|||||||
|
|
||||||
clean:
|
clean:
|
||||||
@printf '%s\n' "[INFO] Removing build artifacts"
|
@printf '%s\n' "[INFO] Removing build artifacts"
|
||||||
@if [ -f "$(PRERELEASE_NOTES)" ]; then \
|
@rm -rf dist
|
||||||
PRERELEASE_NOTES_BACKUP="$$(mktemp -t subminer-prerelease-notes.XXXXXX)" && \
|
|
||||||
cp "$(PRERELEASE_NOTES)" "$$PRERELEASE_NOTES_BACKUP" && \
|
|
||||||
rm -rf dist release && \
|
|
||||||
install -d release && \
|
|
||||||
mv "$$PRERELEASE_NOTES_BACKUP" "$(PRERELEASE_NOTES)"; \
|
|
||||||
else \
|
|
||||||
rm -rf dist release; \
|
|
||||||
fi
|
|
||||||
@rm -f "$(BINDIR)/subminer" "$(BINDIR)/SubMiner.AppImage"
|
@rm -f "$(BINDIR)/subminer" "$(BINDIR)/SubMiner.AppImage"
|
||||||
|
|
||||||
generate-config: ensure-bun
|
generate-config: ensure-bun
|
||||||
@@ -229,7 +220,7 @@ install-linux: build-launcher
|
|||||||
@if [ -n "$(APPIMAGE_SRC)" ]; then \
|
@if [ -n "$(APPIMAGE_SRC)" ]; then \
|
||||||
install -m 0755 "$(APPIMAGE_SRC)" "$(BINDIR)/SubMiner.AppImage"; \
|
install -m 0755 "$(APPIMAGE_SRC)" "$(BINDIR)/SubMiner.AppImage"; \
|
||||||
else \
|
else \
|
||||||
printf '%s\n' "[WARN] No release/SubMiner-*.AppImage found; skipping AppImage install"; \
|
printf '%s\n' "[WARN] No dist/release/SubMiner-*.AppImage found; skipping AppImage install"; \
|
||||||
printf '%s\n' " Build one with: make build"; \
|
printf '%s\n' " Build one with: make build"; \
|
||||||
fi
|
fi
|
||||||
@printf '%s\n' "Installed to:" " $(BINDIR)/subminer" " $(LINUX_DATA_DIR)/themes/$(THEME_FILE)" " $(LINUX_DATA_DIR)/thumbnailers/$(THUMBNAILER_FILE)"
|
@printf '%s\n' "Installed to:" " $(BINDIR)/subminer" " $(LINUX_DATA_DIR)/themes/$(THEME_FILE)" " $(LINUX_DATA_DIR)/thumbnailers/$(THUMBNAILER_FILE)"
|
||||||
@@ -252,7 +243,7 @@ install-macos: build-launcher
|
|||||||
ditto -x -k "$(MACOS_ZIP_SRC)" "$(MACOS_APP_DIR)"; \
|
ditto -x -k "$(MACOS_ZIP_SRC)" "$(MACOS_APP_DIR)"; \
|
||||||
printf '%s\n' "[INFO] Installed app bundle from $(MACOS_ZIP_SRC)"; \
|
printf '%s\n' "[INFO] Installed app bundle from $(MACOS_ZIP_SRC)"; \
|
||||||
else \
|
else \
|
||||||
printf '%s\n' "[WARN] No macOS app bundle or zip found in release/; skipping app install"; \
|
printf '%s\n' "[WARN] No macOS app bundle or zip found in dist/release/; skipping app install"; \
|
||||||
printf '%s\n' " Build one with: make build"; \
|
printf '%s\n' " Build one with: make build"; \
|
||||||
fi
|
fi
|
||||||
@printf '%s\n' "Installed to:" " $(BINDIR)/subminer" " $(MACOS_DATA_DIR)/themes/$(THEME_FILE)" " $(MACOS_APP_DEST)"
|
@printf '%s\n' "Installed to:" " $(BINDIR)/subminer" " $(MACOS_DATA_DIR)/themes/$(THEME_FILE)" " $(MACOS_APP_DEST)"
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ Look up words with Yomitan or Hachidori, mine them to Anki, and track your immer
|
|||||||
|
|
||||||
Hover over any word in the subtitles to open the full Yomitan popup with definitions, pitch accent, and frequency data. SubMiner bundles its own Yomitan, separate from any browser install.
|
Hover over any word in the subtitles to open the full Yomitan popup with definitions, pitch accent, and frequency data. SubMiner bundles its own Yomitan, separate from any browser install.
|
||||||
|
|
||||||
Yomitan remains the default. Select the bundled Hachidori backend with `dictionaryBackend: "hachidori"` and restart SubMiner. The tray opens the selected backend's settings. See [dictionary setup](https://docs.subminer.moe/usage#hachidori-setup) for importing dictionaries, linking an external Hachidori host, and configuring Anki.
|
Yomitan remains the default. Select the bundled [Hachidori](https://github.com/bee-san/hachidori) backend with `dictionaryBackend: "hachidori"` and restart SubMiner. The tray opens the selected backend's settings. See [dictionary setup](https://docs.subminer.moe/usage#hachidori-setup) for importing dictionaries, linking an external Hachidori host, and configuring Anki.
|
||||||
|
|
||||||
<div align="center">
|
<div align="center">
|
||||||
<img src="docs-site/public/screenshots/yomitan-lookup.png" width="800" alt="Yomitan dictionary popup over annotated subtitles in mpv">
|
<img src="docs-site/public/screenshots/yomitan-lookup.png" width="800" alt="Yomitan dictionary popup over annotated subtitles in mpv">
|
||||||
@@ -38,7 +38,7 @@ Yomitan remains the default. Select the bundled Hachidori backend with `dictiona
|
|||||||
Create an Anki card from the exact playback moment with one key press, click, or controller input. SubMiner fills in the sentence, an audio clip, and a screenshot or animated image.
|
Create an Anki card from the exact playback moment with one key press, click, or controller input. SubMiner fills in the sentence, an audio clip, and a screenshot or animated image.
|
||||||
|
|
||||||
<div align="center">
|
<div align="center">
|
||||||
<img src="docs-site/public/screenshots/one-key-mining.png" width="800" alt="Anki card created from SubMiner with sentence, audio, and screenshot">
|
<img src="docs-site/public/screenshots/one-key-mining.png" width="800" alt="Yomitan lookup over an anime scene with Anki mining controls">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<br>
|
<br>
|
||||||
@@ -48,7 +48,7 @@ Create an Anki card from the exact playback moment with one key press, click, or
|
|||||||
Subtitles are annotated as they play with frequency highlighting, JLPT tags, N+1 targeting, and character names from a generated dictionary. Particles and grammar-only tokens stay plain so the words worth learning stand out.
|
Subtitles are annotated as they play with frequency highlighting, JLPT tags, N+1 targeting, and character names from a generated dictionary. Particles and grammar-only tokens stay plain so the words worth learning stand out.
|
||||||
|
|
||||||
<div align="center">
|
<div align="center">
|
||||||
<img src="docs-site/public/screenshots/annotations.png" width="800" alt="Annotated subtitles with frequency coloring, JLPT underlines, and N+1 targets">
|
<img src="docs-site/public/screenshots/annotations.png" width="800" alt="Annotated subtitles with frequency colors, character portraits, and JLPT underlines">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<br>
|
<br>
|
||||||
|
|||||||
+2
-2
@@ -44,11 +44,11 @@ How fragments turn into a release:
|
|||||||
- The polish step treats pending fragments as the final release outcome, not prerelease history. If a feature is added and then renamed or fixed before the stable cut, ship the final feature bullet instead of separate prerelease-only breaking/fix entries.
|
- The polish step treats pending fragments as the final release outcome, not prerelease history. If a feature is added and then renamed or fixed before the stable cut, ship the final feature bullet instead of separate prerelease-only breaking/fix entries.
|
||||||
- `CHANGELOG.md`, GitHub release notes, and prerelease notes all use one item per feature or area, inline by default: `- **Name**: one or two short sentences.` Items with several separate changes may use a few short nested bullets instead of a run-on sentence. Items say what changed and how to reach it; implementation details, edge cases, and step-by-step usage belong in the docs, not the release body.
|
- `CHANGELOG.md`, GitHub release notes, and prerelease notes all use one item per feature or area, inline by default: `- **Name**: one or two short sentences.` Items with several separate changes may use a few short nested bullets instead of a run-on sentence. Items say what changed and how to reach it; implementation details, edge cases, and step-by-step usage belong in the docs, not the release body.
|
||||||
- `internal` fragments stay in `CHANGELOG.md` (inside a collapsed `<details>` block) but are dropped from the GitHub release notes entirely.
|
- `internal` fragments stay in `CHANGELOG.md` (inside a collapsed `<details>` block) but are dropped from the GitHub release notes entirely.
|
||||||
- The polished `CHANGELOG.md` and `release/release-notes.md` are committed and reviewed before tagging — edit the Markdown by hand if Claude misses something.
|
- The polished `CHANGELOG.md` and `packaging/release-notes/release-notes.md` are committed and reviewed before tagging — edit the Markdown by hand if Claude misses something.
|
||||||
|
|
||||||
Prerelease notes:
|
Prerelease notes:
|
||||||
|
|
||||||
- prerelease tags like `v0.11.3-beta.1` and `v0.11.3-rc.1` reuse the current pending fragments to generate `release/prerelease-notes.md`
|
- prerelease tags like `v0.11.3-beta.1` and `v0.11.3-rc.1` reuse the current pending fragments to generate `packaging/release-notes/prerelease-notes.md`
|
||||||
- from the second prerelease of a base version onward, the notes also open with a `## Changes since <previous tag>` section generated from the fragment diff against the previous beta/RC tag; keep fragment edits meaningful. Editorial-only rewording is filtered out of that section, while genuinely changed behavior and deleted fragments (reverted changes) are reported
|
- from the second prerelease of a base version onward, the notes also open with a `## Changes since <previous tag>` section generated from the fragment diff against the previous beta/RC tag; keep fragment edits meaningful. Editorial-only rewording is filtered out of that section, while genuinely changed behavior and deleted fragments (reverted changes) are reported
|
||||||
- existing prerelease notes are a reviewed baseline; later prerelease runs should replace stale beta/RC wording with the current outcome instead of appending fix churn
|
- existing prerelease notes are a reviewed baseline; later prerelease runs should replace stale beta/RC wording with the current outcome instead of appending fix churn
|
||||||
- prerelease note generation does not consume fragments and does not update `CHANGELOG.md` or `docs-site/changelog.md`
|
- prerelease note generation does not consume fragments and does not update `CHANGELOG.md` or `docs-site/changelog.md`
|
||||||
|
|||||||
@@ -1,5 +1,28 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## v0.21.1 (2026-10-26)
|
||||||
|
|
||||||
|
**Changed**
|
||||||
|
- **Stats Session View**: Redesigned the expanded session view in the stats dashboard.
|
||||||
|
- New stat tiles show pace, lines, known-word rate, lookups, and paused time.
|
||||||
|
- A reading density chart of known and unknown words replaces the cumulative chart.
|
||||||
|
- Yomitan lookups and mined cards now appear in their own lane below the chart.
|
||||||
|
|
||||||
|
**Fixed**
|
||||||
|
- **Subtitle Retiming**: Mining, the subtitle sidebar, subtitle navigation, and replay now use the timing you set with mpv's subtitle delay keys (z/x and sub-step), so mined audio and images match the retimed line.
|
||||||
|
- **Subtitle Sidebar**: mpv keys and mouse actions, like volume keys and double-click fullscreen, work again while the sidebar is open.
|
||||||
|
|
||||||
|
**Docs**
|
||||||
|
- **README**: Refreshed the anime screenshots using the current build.
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Internal changes</summary>
|
||||||
|
|
||||||
|
**Internal**
|
||||||
|
- **Packaging Layout**: Packaging inputs now live under `packaging/`, and all build output lands in `dist/`, with packaged apps in `dist/release/`.
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
## v0.21.0 (2026-10-24)
|
## v0.21.0 (2026-10-24)
|
||||||
|
|
||||||
**Added**
|
**Added**
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ Charts for watch time, cards, words, and sessions per day or month, running tota
|
|||||||
|
|
||||||
### Sessions
|
### Sessions
|
||||||
|
|
||||||
Session history with new-word activity and pause, seek, and card markers. The **↗** button on a row opens that show's detail view.
|
Session history. Expand a session for its pace, lines, known-word rate, lookups, and paused time, plus a reading density chart of known and unknown words over the session with Yomitan lookup ticks and clickable card-mined markers underneath. The **↗** button on a row opens that show's detail view.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
|
|||||||
@@ -226,7 +226,7 @@ make deps
|
|||||||
make build-macos
|
make build-macos
|
||||||
```
|
```
|
||||||
|
|
||||||
The `.dmg` and `.zip` land in `release/`. For an unsigned local build, run `bun run build:mac:unsigned`.
|
The `.dmg` and `.zip` land in `dist/release/`. For an unsigned local build, run `bun run build:mac:unsigned`.
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 42 KiB After Width: | Height: | Size: 110 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 614 KiB After Width: | Height: | Size: 1.9 MiB |
Binary file not shown.
|
Before Width: | Height: | Size: 1.4 MiB After Width: | Height: | Size: 2.2 MiB |
@@ -86,7 +86,7 @@ SubMiner detected that a previously non-empty Yomitan profile suddenly appears e
|
|||||||
|
|
||||||
## "Yomitan extension not found in any search path"
|
## "Yomitan extension not found in any search path"
|
||||||
|
|
||||||
The bundled Yomitan is missing. Re-download the AppImage, or place an unpacked Yomitan extension in `~/.config/SubMiner/yomitan`. Source builds must run `bun run build` first to produce `build/yomitan`.
|
The bundled Yomitan is missing. Re-download the AppImage, or place an unpacked Yomitan extension in `~/.config/SubMiner/yomitan`. Source builds must run `bun run build` first to produce `dist/yomitan`.
|
||||||
|
|
||||||
## "MeCab not found on system"
|
## "MeCab not found on system"
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -22,7 +22,7 @@ The bundled Yomitan is separate from any Yomitan in your browser. It has its own
|
|||||||
|
|
||||||
### Hachidori setup
|
### Hachidori setup
|
||||||
|
|
||||||
Hachidori is an alternative lookup backend. Set `dictionaryBackend` to `"hachidori"` in settings or `config.jsonc`, then restart SubMiner. Set it back to `"yomitan"` and restart to switch back.
|
[Hachidori](https://github.com/bee-san/hachidori) is an alternative lookup backend. Set `dictionaryBackend` to `"hachidori"` in settings or `config.jsonc`, then restart SubMiner. Set it back to `"yomitan"` and restart to switch back.
|
||||||
|
|
||||||
Open Hachidori settings with `Alt+Shift+Y`, the tray's **Open Hachidori Settings**, or `subminer app --hachidori`. Import dictionary ZIPs or use Hachidori's recommended dictionary installer, then set up its Anki template (SubMiner [fills in what it can](/anki-integration#hachidori-settings-from-subminer)). Yomitan and Hachidori keep separate dictionaries and settings. Yomitan profiles, custom Handlebars templates, and `yomitan.externalProfilePath` do not carry over. Subtitle annotations and lookups wait while Hachidori imports a dictionary, so large imports pause them until the import finishes.
|
Open Hachidori settings with `Alt+Shift+Y`, the tray's **Open Hachidori Settings**, or `subminer app --hachidori`. Import dictionary ZIPs or use Hachidori's recommended dictionary installer, then set up its Anki template (SubMiner [fills in what it can](/anki-integration#hachidori-settings-from-subminer)). Yomitan and Hachidori keep separate dictionaries and settings. Yomitan profiles, custom Handlebars templates, and `yomitan.externalProfilePath` do not carry over. Subtitle annotations and lookups wait while Hachidori imports a dictionary, so large imports pause them until the import finishes.
|
||||||
|
|
||||||
|
|||||||
+10
-10
@@ -54,10 +54,10 @@ files before signing and generating updater hashes, never from a signed app.
|
|||||||
4. Bump `package.json` to the release version.
|
4. Bump `package.json` to the release version.
|
||||||
5. Build release metadata before tagging (this calls `claude -p` locally):
|
5. Build release metadata before tagging (this calls `claude -p` locally):
|
||||||
`bun run changelog:build --version <version> --date <yyyy-mm-dd>`
|
`bun run changelog:build --version <version> --date <yyyy-mm-dd>`
|
||||||
- The polished `CHANGELOG.md` and `release/release-notes.md` are committed
|
- The polished `CHANGELOG.md` and `packaging/release-notes/release-notes.md` are committed
|
||||||
before tagging. Release CI no longer auto-builds the changelog; it fails
|
before tagging. Release CI no longer auto-builds the changelog; it fails
|
||||||
fast if `changes/*.md` fragments are still present on a tag-based run.
|
fast if `changes/*.md` fragments are still present on a tag-based run.
|
||||||
6. Review `CHANGELOG.md` and `release/release-notes.md`. Edit by hand if Claude
|
6. Review `CHANGELOG.md` and `packaging/release-notes/release-notes.md`. Edit by hand if Claude
|
||||||
missed something — the committed Markdown is what ships.
|
missed something — the committed Markdown is what ships.
|
||||||
7. Run release gate locally:
|
7. Run release gate locally:
|
||||||
`bun run changelog:check --version <version>`
|
`bun run changelog:check --version <version>`
|
||||||
@@ -73,9 +73,9 @@ files before signing and generating updater hashes, never from a signed app.
|
|||||||
`bun-v<version>-source.tar.gz` plus its `.sha256` file. The script
|
`bun-v<version>-source.tar.gz` plus its `.sha256` file. The script
|
||||||
fails if Bun's CMake dependency pins, WebKit pin, source checksums, patch
|
fails if Bun's CMake dependency pins, WebKit pin, source checksums, patch
|
||||||
inputs, or collected license files differ from
|
inputs, or collected license files differ from
|
||||||
`build/bun-source-manifest.json`.
|
`packaging/bun/bun-source-manifest.json`.
|
||||||
When validating auto-update metadata, also run the relevant platform package
|
When validating auto-update metadata, also run the relevant platform package
|
||||||
build and confirm `release/` contains the generated updater metadata
|
build and confirm `dist/release/` contains the generated updater metadata
|
||||||
(`latest*.yml`) and blockmaps (`*.blockmap`).
|
(`latest*.yml`) and blockmaps (`*.blockmap`).
|
||||||
8. If `docs-site/` changed, also run:
|
8. If `docs-site/` changed, also run:
|
||||||
`bun run docs:test`
|
`bun run docs:test`
|
||||||
@@ -103,9 +103,9 @@ files before signing and generating updater hashes, never from a signed app.
|
|||||||
source archive. A missing source repository or license file fails the
|
source archive. A missing source repository or license file fails the
|
||||||
release instead of publishing only the executable.
|
release instead of publishing only the executable.
|
||||||
When validating packaged updater output, confirm the platform build writes
|
When validating packaged updater output, confirm the platform build writes
|
||||||
`latest*.yml` and `*.blockmap` files under `release/`.
|
`latest*.yml` and `*.blockmap` files under `dist/release/`.
|
||||||
5. Commit the prerelease prep (package.json version bump + the generated
|
5. Commit the prerelease prep (package.json version bump + the generated
|
||||||
`release/prerelease-notes.md`). CI does not regenerate notes — it uses the
|
`packaging/release-notes/prerelease-notes.md`). CI does not regenerate notes — it uses the
|
||||||
committed file — so review it before committing. Rerun
|
committed file — so review it before committing. Rerun
|
||||||
`bun run changelog:prerelease-notes --version <version>` for every later
|
`bun run changelog:prerelease-notes --version <version>` for every later
|
||||||
beta/RC, even if no fragments changed: the notes carry a hidden
|
beta/RC, even if no fragments changed: the notes carry a hidden
|
||||||
@@ -128,14 +128,14 @@ Notes:
|
|||||||
- Supported prerelease channels are `beta` and `rc`, with versions like `0.11.3-beta.1` and `0.11.3-rc.1`.
|
- Supported prerelease channels are `beta` and `rc`, with versions like `0.11.3-beta.1` and `0.11.3-rc.1`.
|
||||||
- Pass `--date` explicitly when you want the release stamped with the local cut date; otherwise the generator uses the current ISO date, which can roll over to the next UTC day late at night.
|
- Pass `--date` explicitly when you want the release stamped with the local cut date; otherwise the generator uses the current ISO date, which can roll over to the next UTC day late at night.
|
||||||
- `changelog:check` now rejects tag/package version mismatches.
|
- `changelog:check` now rejects tag/package version mismatches.
|
||||||
- `changelog:prerelease-notes` also rejects tag/package version mismatches and writes `release/prerelease-notes.md` without mutating tracked changelog files. When that file already exists, the generator includes it in the Claude prompt so later beta/RC notes reuse the reviewed text instead of starting over.
|
- `changelog:prerelease-notes` also rejects tag/package version mismatches and writes `packaging/release-notes/prerelease-notes.md` without mutating tracked changelog files. When that file already exists, the generator includes it in the Claude prompt so later beta/RC notes reuse the reviewed text instead of starting over.
|
||||||
- From the second prerelease of a base version onward, the notes open with a `## Changes since <previous tag>` section above the cumulative `## Highlights`. The generator locates the newest preceding beta/RC tag for the same base version (semver order: all betas before all RCs), diffs `changes/*.md` between that tag and the working tree, and asks Claude to describe only the behavioral beta-to-beta differences — added fragments as new changes, modified fragments by their before/after difference (editorial-only edits are dropped), deleted fragments as removed/reverted changes. If no fragments changed (for example a packaging-only rebuild), the section states that explicitly without a Claude call. The delta section carries no separate contributor attribution; `## What's Changed` stays cumulative like `## Highlights`.
|
- From the second prerelease of a base version onward, the notes open with a `## Changes since <previous tag>` section above the cumulative `## Highlights`. The generator locates the newest preceding beta/RC tag for the same base version (semver order: all betas before all RCs), diffs `changes/*.md` between that tag and the working tree, and asks Claude to describe only the behavioral beta-to-beta differences — added fragments as new changes, modified fragments by their before/after difference (editorial-only edits are dropped), deleted fragments as removed/reverted changes. If no fragments changed (for example a packaging-only rebuild), the section states that explicitly without a Claude call. The delta section carries no separate contributor attribution; `## What's Changed` stays cumulative like `## Highlights`.
|
||||||
- `changelog:check-prerelease-notes --version <version>` verifies the committed notes' `prerelease-version` marker matches the version being tagged; the prerelease workflow runs it and fails the release on stale notes.
|
- `changelog:check-prerelease-notes --version <version>` verifies the committed notes' `prerelease-version` marker matches the version being tagged; the prerelease workflow runs it and fails the release on stale notes.
|
||||||
- `changelog:build` generates `CHANGELOG.md` + `release/release-notes.md` (both polished by `claude -p`) and removes the released `changes/*.md` fragments. The CHANGELOG keeps internal notes inside a `<details><summary>Internal changes</summary>` collapse; the release notes drop them entirely.
|
- `changelog:build` generates `CHANGELOG.md` + `packaging/release-notes/release-notes.md` (both polished by `claude -p`) and removes the released `changes/*.md` fragments. The CHANGELOG keeps internal notes inside a `<details><summary>Internal changes</summary>` collapse; the release notes drop them entirely.
|
||||||
- `release/release-notes.md` (and `release/prerelease-notes.md`) include GitHub-style attribution after `## Highlights`: a `## What's Changed` list crediting each released fragment as `by @<author> in #<pr>`, plus a `## New Contributors` section for first-time authors. Attribution is resolved per fragment via `git log` (the commit that added the fragment) + `gh api .../commits/<sha>/pulls`, with one `gh` search per author for the first-contribution check. It needs `gh` installed and authenticated; if `gh` is unavailable or a lookup fails, the generator warns and emits notes without the attribution sections rather than failing. The CHANGELOG itself stays attribution-free.
|
- `packaging/release-notes/release-notes.md` (and `packaging/release-notes/prerelease-notes.md`) include GitHub-style attribution after `## Highlights`: a `## What's Changed` list crediting each released fragment as `by @<author> in #<pr>`, plus a `## New Contributors` section for first-time authors. Attribution is resolved per fragment via `git log` (the commit that added the fragment) + `gh api .../commits/<sha>/pulls`, with one `gh` search per author for the first-contribution check. It needs `gh` installed and authenticated; if `gh` is unavailable or a lookup fails, the generator warns and emits notes without the attribution sections rather than failing. The CHANGELOG itself stays attribution-free.
|
||||||
- The release workflow no longer auto-runs `changelog:build`. If pending `changes/*.md` fragments are present on a tag-based run, CI exits with a clear `::error::` pointing at the local fix. Run `bun run changelog:build --version <version>` locally, commit the polished output, then tag.
|
- The release workflow no longer auto-runs `changelog:build`. If pending `changes/*.md` fragments are present on a tag-based run, CI exits with a clear `::error::` pointing at the local fix. Run `bun run changelog:build --version <version>` locally, commit the polished output, then tag.
|
||||||
- Do not tag while `changes/*.md` fragments still exist.
|
- Do not tag while `changes/*.md` fragments still exist.
|
||||||
- Prerelease tags intentionally keep `changes/*.md` fragments in place so multiple prereleases can reuse the same cumulative pending notes until the final stable cut. `make clean` preserves `release/prerelease-notes.md` while deleting generated build artifacts.
|
- Prerelease tags intentionally keep `changes/*.md` fragments in place so multiple prereleases can reuse the same cumulative pending notes until the final stable cut.
|
||||||
- If you need to repair a published release body (for example, a prior version’s section was omitted), regenerate notes from `CHANGELOG.md` and re-edit the release with `gh release edit --notes-file`.
|
- If you need to repair a published release body (for example, a prior version’s section was omitted), regenerate notes from `CHANGELOG.md` and re-edit the release with `gh release edit --notes-file`.
|
||||||
- Prerelease tags are handled by `.github/workflows/prerelease.yml`, which always publishes a GitHub prerelease with all current release platforms and never runs the AUR sync job.
|
- Prerelease tags are handled by `.github/workflows/prerelease.yml`, which always publishes a GitHub prerelease with all current release platforms and never runs the AUR sync job.
|
||||||
- CI, stable release, and prerelease workflows call the shared `.github/workflows/quality-gate.yml`; update that reusable workflow when changing common test coverage. Its environment suite includes the Lua mpv plugin tests.
|
- CI, stable release, and prerelease workflows call the shared `.github/workflows/quality-gate.yml`; update that reusable workflow when changing common test coverage. Its environment suite includes the Lua mpv plugin tests.
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ bun run docs:build
|
|||||||
then `bun run test:e2e` (see [Headless End-to-End Harness](#headless-end-to-end-harness))
|
then `bun run test:e2e` (see [Headless End-to-End Harness](#headless-end-to-end-harness))
|
||||||
- Build/release scripts (`scripts/**`): `bun run test:scripts`
|
- Build/release scripts (`scripts/**`): `bun run test:scripts`
|
||||||
- Packaging: build the platform package, then run `bun run test:package <resources-directory>`.
|
- Packaging: build the platform package, then run `bun run test:package <resources-directory>`.
|
||||||
On headless Linux: `xvfb-run -a bun run test:package release/linux-unpacked/resources`.
|
On headless Linux: `xvfb-run -a bun run test:package dist/release/linux-unpacked/resources`.
|
||||||
Content checks run inside the electron-builder afterPack hook. See the
|
Content checks run inside the electron-builder afterPack hook. See the
|
||||||
[release guide](../RELEASING.md#package-contents-checks) for the
|
[release guide](../RELEASING.md#package-contents-checks) for the
|
||||||
installed-app verification checklist.
|
installed-app verification checklist.
|
||||||
|
|||||||
@@ -37,10 +37,12 @@ function isRecord(value: unknown): value is Record<string, unknown> {
|
|||||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
// A recording AnkiConnect stand-in: an empty collection with one deck and one
|
// A recording AnkiConnect stand-in: one deck and one Lapis-shaped note type.
|
||||||
// Lapis-shaped note type. Scenarios assert on `requests`, not on stored notes.
|
// Added notes (with later `updateNoteFields` writes) are echoed back by `notesInfo`
|
||||||
|
// so the app can resolve their fields; scenarios still assert on `requests`.
|
||||||
export async function startFakeAnki(): Promise<FakeAnki> {
|
export async function startFakeAnki(): Promise<FakeAnki> {
|
||||||
const requests: AnkiRequest[] = [];
|
const requests: AnkiRequest[] = [];
|
||||||
|
const addedNotes = new Map<number, Record<string, unknown>>();
|
||||||
let nextNoteId = 1000;
|
let nextNoteId = 1000;
|
||||||
|
|
||||||
const handle = (body: unknown): unknown => {
|
const handle = (body: unknown): unknown => {
|
||||||
@@ -59,10 +61,38 @@ export async function startFakeAnki(): Promise<FakeAnki> {
|
|||||||
return [FAKE_ANKI_MODEL];
|
return [FAKE_ANKI_MODEL];
|
||||||
case 'modelFieldNames':
|
case 'modelFieldNames':
|
||||||
return MODEL_FIELDS;
|
return MODEL_FIELDS;
|
||||||
case 'addNote':
|
case 'addNote': {
|
||||||
return nextNoteId++;
|
const noteId = nextNoteId++;
|
||||||
|
const note = isRecord(params.note) ? params.note : {};
|
||||||
|
addedNotes.set(noteId, isRecord(note.fields) ? { ...note.fields } : {});
|
||||||
|
return noteId;
|
||||||
|
}
|
||||||
|
case 'updateNoteFields': {
|
||||||
|
const note = isRecord(params.note) ? params.note : {};
|
||||||
|
const fields = typeof note.id === 'number' ? addedNotes.get(note.id) : undefined;
|
||||||
|
if (fields && isRecord(note.fields)) Object.assign(fields, note.fields);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
case 'notesInfo': {
|
||||||
|
// Like AnkiConnect: one entry per requested id, `{}` for unknown notes.
|
||||||
|
const ids = Array.isArray(params.notes) ? params.notes : [];
|
||||||
|
return ids.map((id) => {
|
||||||
|
const fields = typeof id === 'number' ? addedNotes.get(id) : undefined;
|
||||||
|
if (!fields) return {};
|
||||||
|
return {
|
||||||
|
noteId: id,
|
||||||
|
modelName: FAKE_ANKI_MODEL,
|
||||||
|
tags: [],
|
||||||
|
fields: Object.fromEntries(
|
||||||
|
MODEL_FIELDS.map((name, order) => [
|
||||||
|
name,
|
||||||
|
{ value: String(fields[name] ?? ''), order },
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
case 'findNotes':
|
case 'findNotes':
|
||||||
case 'notesInfo':
|
|
||||||
return [];
|
return [];
|
||||||
case 'storeMediaFile':
|
case 'storeMediaFile':
|
||||||
return params.filename ?? null;
|
return params.filename ?? null;
|
||||||
|
|||||||
@@ -91,6 +91,14 @@ async function showCue(cue: (typeof FIXTURE_CUES)[number]): Promise<RenderedToke
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Card-kind flags are written before media, so wait for the update carrying audio.
|
||||||
|
function findMediaUpdate(session: E2eSession) {
|
||||||
|
return session.anki.requests.find(
|
||||||
|
(request) =>
|
||||||
|
request.action === 'updateNoteFields' && JSON.stringify(request.params).includes('[sound:'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function hasToken(tokens: RenderedToken[], surface: string, headword: string): boolean {
|
function hasToken(tokens: RenderedToken[], surface: string, headword: string): boolean {
|
||||||
return tokens.some((token) => token.surface === surface && token.headword === headword);
|
return tokens.some((token) => token.surface === surface && token.headword === headword);
|
||||||
}
|
}
|
||||||
@@ -126,10 +134,10 @@ test(
|
|||||||
session.anki.requests.length = 0;
|
session.anki.requests.length = 0;
|
||||||
|
|
||||||
await session.runAppCommand('--mine-sentence');
|
await session.runAppCommand('--mine-sentence');
|
||||||
const update = await waitUntil(
|
const update = await waitUntil(() => findMediaUpdate(session), {
|
||||||
() => session.anki.requests.find((request) => request.action === 'updateNoteFields'),
|
description: 'media fields to be written to the mined note',
|
||||||
{ description: 'media fields to be written to the mined note', timeoutMs: 30_000 },
|
timeoutMs: 30_000,
|
||||||
);
|
});
|
||||||
|
|
||||||
const added = session.anki.requests.filter((request) => request.action === 'addNote');
|
const added = session.anki.requests.filter((request) => request.action === 'addNote');
|
||||||
assert.equal(added.length, 1);
|
assert.equal(added.length, 1);
|
||||||
@@ -156,6 +164,38 @@ test(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
test(
|
||||||
|
'mining a line retimed with sub-delay clips it where mpv plays it',
|
||||||
|
{ timeout: TEST_TIMEOUT_MS },
|
||||||
|
async () => {
|
||||||
|
const { session, overlay } = await useSession();
|
||||||
|
const cue = FIXTURE_CUES[1];
|
||||||
|
const delay = 2;
|
||||||
|
await session.mpv.command('set_property', 'sub-delay', delay);
|
||||||
|
try {
|
||||||
|
// Past the cue's own end, so the line is only on screen because of the delay.
|
||||||
|
await session.mpv.command('seek', cue.end + delay / 2, 'absolute+exact');
|
||||||
|
await overlay.waitFor(
|
||||||
|
`document.getElementById('subtitleRoot').textContent === ${JSON.stringify(cue.text)}`,
|
||||||
|
{ description: 'overlay to show the delayed line' },
|
||||||
|
);
|
||||||
|
session.anki.requests.length = 0;
|
||||||
|
|
||||||
|
await session.runAppCommand('--mine-sentence');
|
||||||
|
await waitUntil(() => findMediaUpdate(session), {
|
||||||
|
description: 'media fields to be written to the mined note',
|
||||||
|
timeoutMs: 30_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
// MiscInfo stamps the clip start: the cue's start on the video clock.
|
||||||
|
const written = JSON.stringify(session.anki.requests);
|
||||||
|
assert.ok(written.includes(`(00:00:0${cue.start + delay})`), written);
|
||||||
|
} finally {
|
||||||
|
await session.mpv.command('set_property', 'sub-delay', 0);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
test(
|
test(
|
||||||
'open subtitle sidebar lists the parsed cues and follows playback',
|
'open subtitle sidebar lists the parsed cues and follows playback',
|
||||||
{ timeout: TEST_TIMEOUT_MS },
|
{ timeout: TEST_TIMEOUT_MS },
|
||||||
@@ -184,6 +224,49 @@ test(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
test(
|
||||||
|
'subtitle sidebar follows and seeks a sub-delay retimed track',
|
||||||
|
{ timeout: TEST_TIMEOUT_MS },
|
||||||
|
async () => {
|
||||||
|
const { session, overlay } = await useSession();
|
||||||
|
const delay = 2;
|
||||||
|
await session.mpv.command('set_property', 'sub-delay', delay);
|
||||||
|
try {
|
||||||
|
// At 9s cue 2 starts on the file's clock, but mpv is still showing cue 1.
|
||||||
|
await session.mpv.command('seek', FIXTURE_CUES[2].start, 'absolute+exact');
|
||||||
|
await overlay.waitFor(
|
||||||
|
`document.getElementById('subtitleRoot').textContent === ${JSON.stringify(FIXTURE_CUES[1].text)}`,
|
||||||
|
{ description: 'overlay to show the delayed line' },
|
||||||
|
);
|
||||||
|
await session.runAppCommand('--toggle-subtitle-sidebar');
|
||||||
|
await overlay.waitFor(
|
||||||
|
`document.querySelector('#subtitleSidebarList .subtitle-sidebar-item.active')?.dataset.index === '1'`,
|
||||||
|
{ description: 'sidebar to highlight the delayed line' },
|
||||||
|
);
|
||||||
|
|
||||||
|
await overlay.evaluate(
|
||||||
|
`document.querySelector('#subtitleSidebarList .subtitle-sidebar-item[data-index="2"]').click()`,
|
||||||
|
);
|
||||||
|
const target = FIXTURE_CUES[2].start + delay;
|
||||||
|
await waitUntil(
|
||||||
|
async () => {
|
||||||
|
const time = Number(await session.mpv.command('get_property', 'time-pos'));
|
||||||
|
return time >= target && time < target + 0.5;
|
||||||
|
},
|
||||||
|
{ description: `click to seek to cue 2 at ${target}s on the video clock` },
|
||||||
|
);
|
||||||
|
|
||||||
|
await session.runAppCommand('--toggle-subtitle-sidebar');
|
||||||
|
await overlay.waitFor(
|
||||||
|
`document.getElementById('subtitleSidebarModal').classList.contains('hidden')`,
|
||||||
|
{ description: 'sidebar to close' },
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
await session.mpv.command('set_property', 'sub-delay', 0);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
// Runs last: it leaves the lookup popup open over the overlay.
|
// Runs last: it leaves the lookup popup open over the overlay.
|
||||||
test(
|
test(
|
||||||
'holding Shift over a word opens the Yomitan popup with its dictionary entry',
|
'holding Shift over a word opens the Yomitan popup with its dictionary entry',
|
||||||
|
|||||||
@@ -174,8 +174,9 @@ test('runAppCommandCaptureOutput strips ELECTRON_RUN_AS_NODE from app child env'
|
|||||||
|
|
||||||
test(
|
test(
|
||||||
'runAppCommandCaptureOutput transports Linux AppImage args through environment',
|
'runAppCommandCaptureOutput transports Linux AppImage args through environment',
|
||||||
{ skip: process.platform !== 'linux' },
|
|
||||||
async () => {
|
async () => {
|
||||||
|
// Bun's node:test shim ignores the `skip` option, so gate the platform here.
|
||||||
|
if (process.platform !== 'linux') return;
|
||||||
await withTempCase(({ dir }) => {
|
await withTempCase(({ dir }) => {
|
||||||
const appPath = writeFakeApp(
|
const appPath = writeFakeApp(
|
||||||
dir,
|
dir,
|
||||||
|
|||||||
+14
-7
@@ -2,7 +2,7 @@
|
|||||||
"name": "subminer",
|
"name": "subminer",
|
||||||
"productName": "SubMiner",
|
"productName": "SubMiner",
|
||||||
"desktopName": "SubMiner.desktop",
|
"desktopName": "SubMiner.desktop",
|
||||||
"version": "0.21.0",
|
"version": "0.21.1",
|
||||||
"description": "All-in-one sentence mining overlay with AnkiConnect and dictionary integration",
|
"description": "All-in-one sentence mining overlay with AnkiConnect and dictionary integration",
|
||||||
"packageManager": "bun@1.3.5",
|
"packageManager": "bun@1.3.5",
|
||||||
"main": "dist/main-entry.js",
|
"main": "dist/main-entry.js",
|
||||||
@@ -146,7 +146,8 @@
|
|||||||
"afterPack": "scripts/electron-builder-after-pack.cjs",
|
"afterPack": "scripts/electron-builder-after-pack.cjs",
|
||||||
"icon": "assets/SubMiner-square.png",
|
"icon": "assets/SubMiner-square.png",
|
||||||
"directories": {
|
"directories": {
|
||||||
"output": "release"
|
"buildResources": "packaging/electron-builder",
|
||||||
|
"output": "dist/release"
|
||||||
},
|
},
|
||||||
"publish": [
|
"publish": [
|
||||||
{
|
{
|
||||||
@@ -178,8 +179,8 @@
|
|||||||
"category": "public.app-category.video",
|
"category": "public.app-category.video",
|
||||||
"icon": "assets/SubMiner-square.png",
|
"icon": "assets/SubMiner-square.png",
|
||||||
"hardenedRuntime": true,
|
"hardenedRuntime": true,
|
||||||
"entitlements": "build/entitlements.mac.plist",
|
"entitlements": "packaging/electron-builder/entitlements.mac.plist",
|
||||||
"entitlementsInherit": "build/entitlements.mac.plist",
|
"entitlementsInherit": "packaging/electron-builder/entitlements.mac.plist",
|
||||||
"extraResources": [
|
"extraResources": [
|
||||||
{
|
{
|
||||||
"from": "dist/scripts/get-mpv-window-macos",
|
"from": "dist/scripts/get-mpv-window-macos",
|
||||||
@@ -211,7 +212,7 @@
|
|||||||
"oneClick": false,
|
"oneClick": false,
|
||||||
"perMachine": false,
|
"perMachine": false,
|
||||||
"allowToChangeInstallationDirectory": true,
|
"allowToChangeInstallationDirectory": true,
|
||||||
"include": "build/installer.nsh"
|
"include": "packaging/electron-builder/installer.nsh"
|
||||||
},
|
},
|
||||||
"files": [
|
"files": [
|
||||||
"dist/**/*",
|
"dist/**/*",
|
||||||
@@ -225,17 +226,18 @@
|
|||||||
"!**/{test,tests,__tests__,fixture,fixtures,__fixtures__}{,/**/*}",
|
"!**/{test,tests,__tests__,fixture,fixtures,__fixtures__}{,/**/*}",
|
||||||
"!dist/launcher{,/**/*}",
|
"!dist/launcher{,/**/*}",
|
||||||
"!dist/scripts{,/**/*}",
|
"!dist/scripts{,/**/*}",
|
||||||
|
"!dist/{yomitan,hachidori,release}{,/**/*}",
|
||||||
"!dist/{renderer,settings,syncui}/fonts{,/**/*}",
|
"!dist/{renderer,settings,syncui}/fonts{,/**/*}",
|
||||||
"!node_modules/koffi/{src,vendor,doc}{,/**/*}",
|
"!node_modules/koffi/{src,vendor,doc}{,/**/*}",
|
||||||
"!node_modules/@libsql/linux-x64-musl{,/**/*}"
|
"!node_modules/@libsql/linux-x64-musl{,/**/*}"
|
||||||
],
|
],
|
||||||
"extraResources": [
|
"extraResources": [
|
||||||
{
|
{
|
||||||
"from": "build/yomitan",
|
"from": "dist/yomitan",
|
||||||
"to": "yomitan"
|
"to": "yomitan"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"from": "build/hachidori",
|
"from": "dist/hachidori",
|
||||||
"to": "hachidori"
|
"to": "hachidori"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -277,6 +279,11 @@
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"prettier": {
|
||||||
|
"printWidth": 100,
|
||||||
|
"singleQuote": true,
|
||||||
|
"trailingComma": "all"
|
||||||
|
},
|
||||||
"patchedDependencies": {
|
"patchedDependencies": {
|
||||||
"@discordjs/rest@2.6.1": "patches/@discordjs%2Frest@2.6.1.patch"
|
"@discordjs/rest@2.6.1": "patches/@discordjs%2Frest@2.6.1.patch"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,6 +26,6 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"licenseInventoryStatus": "source-and-notices-pinned-to-binary-revision",
|
"licenseInventoryStatus": "source-and-notices-pinned-to-binary-revision",
|
||||||
"sourceManifest": "build/bun-source-manifest.json",
|
"sourceManifest": "packaging/bun/bun-source-manifest.json",
|
||||||
"correspondingSourceAsset": "bun-v1.3.5-source.tar.gz"
|
"correspondingSourceAsset": "bun-v1.3.5-source.tar.gz"
|
||||||
}
|
}
|
||||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -10,7 +10,7 @@ The SubMiner GitHub release containing this application also contains `bun-v1.3.
|
|||||||
- every external source repository registered by that Bun revision's CMake build, at its exact commit
|
- every external source repository registered by that Bun revision's CMake build, at its exact commit
|
||||||
- Bun's dependency patches, build scripts, lockfiles, collected third-party license files, and instructions for rebuilding Bun against a modified JavaScriptCore
|
- Bun's dependency patches, build scripts, lockfiles, collected third-party license files, and instructions for rebuilding Bun against a modified JavaScriptCore
|
||||||
|
|
||||||
The machine-readable inventory lives at `SOURCE-INVENTORY.json` inside the source archive and at `build/bun-source-manifest.json` in SubMiner's source repository.
|
The machine-readable inventory lives at `SOURCE-INVENTORY.json` inside the source archive and at `packaging/bun/bun-source-manifest.json` in SubMiner's source repository.
|
||||||
|
|
||||||
The archive vendors the source repositories linked through Bun's CMake build. It preserves Bun's package-manager lockfiles and lol-html's `Cargo.lock`, but it does not vendor npm packages, crates.io packages, compilers, SDKs, or other build tools. Rebuilding needs network access for those package-manager and toolchain inputs. The archive's rebuild README records the known tool versions and the remaining unpinned Rust nightly input.
|
The archive vendors the source repositories linked through Bun's CMake build. It preserves Bun's package-manager lockfiles and lol-html's `Cargo.lock`, but it does not vendor npm packages, crates.io packages, compilers, SDKs, or other build tools. Rebuilding needs network access for those package-manager and toolchain inputs. The archive's rebuild README records the known tool versions and the remaining unpinned Rust nightly input.
|
||||||
|
|
||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
# Bun 1.3.5 third-party notices
|
# Bun 1.3.5 third-party notices
|
||||||
|
|
||||||
This file collects the license texts shipped by the external source repositories used by Bun 1.3.5 at the revisions in build/bun-source-manifest.json. The corresponding-source release asset also preserves license notices embedded in individual Bun and WebKit source files. Bun’s own LICENSE.md lists additional embedded libraries and JavaScript polyfills whose source and per-file notices are included in that asset.
|
This file collects the license texts shipped by the external source repositories used by Bun 1.3.5 at the revisions in packaging/bun/bun-source-manifest.json. The corresponding-source release asset also preserves license notices embedded in individual Bun and WebKit source files. Bun’s own LICENSE.md lists additional embedded libraries and JavaScript polyfills whose source and per-file notices are included in that asset.
|
||||||
|
|
||||||
## BoringSSL
|
## BoringSSL
|
||||||
|
|
||||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -0,0 +1,32 @@
|
|||||||
|
## Highlights
|
||||||
|
### Changed
|
||||||
|
- **Stats Session View**: The expanded session view in the stats dashboard has been redesigned. It shows stat tiles for pace, lines, known-word rate, lookups, and paused time, plus a reading density chart of known and unknown words with lookup and card-mined markers.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **Subtitle Delay**: Retiming subtitles in mpv (z/x or sub-step) now carries through SubMiner. Mined card audio, images, and timestamps match the retimed line, and so do the subtitle sidebar, subtitle navigation, and replay.
|
||||||
|
- **Subtitle Sidebar**: mpv keys and mouse buttons, such as volume keys and double-click fullscreen, work again while the sidebar is open.
|
||||||
|
|
||||||
|
### Docs
|
||||||
|
- **README**: Refreshed the anime screenshots using the current build.
|
||||||
|
|
||||||
|
## What's Changed
|
||||||
|
|
||||||
|
- fix(overlay): forward mpv input while subtitle sidebar is open by @ksyasuda in #300
|
||||||
|
- fix(subtitles): follow mpv sub-delay in mining, sidebar, and navigation by @ksyasuda in #302
|
||||||
|
- feat(stats): redesign expanded session view with density chart by @ksyasuda in #304
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
See the README and docs/installation guide for full setup steps.
|
||||||
|
|
||||||
|
## Assets
|
||||||
|
|
||||||
|
- Linux: `SubMiner.AppImage`
|
||||||
|
- macOS: `SubMiner-*.dmg` and `SubMiner-*.zip`
|
||||||
|
- Windows: `SubMiner-*.exe` and `SubMiner-*-win.zip`
|
||||||
|
- Optional extras: `subminer-assets.tar.gz`, the `subminer` launcher, and the Windows `subminer.cmd` launcher
|
||||||
|
- Bun corresponding source: `bun-v1.3.5-source.tar.gz` and its `.sha256` file
|
||||||
|
|
||||||
|
Both launcher downloads use Bun included with the SubMiner app. Download `subminer` on Linux or macOS and `subminer.cmd` on Windows.
|
||||||
|
|
||||||
|
The app bundles an unmodified Bun 1.3.5 runtime. Bun is MIT licensed and statically links JavaScriptCore (LGPL 2.0) and TinyCC (LGPL 2.1). License texts and third-party notices ship inside the app under `resources/bun/licenses`, and the source archive above contains the matching Bun, WebKit, and dependency sources for relinking.
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
## Highlights
|
|
||||||
### Added
|
|
||||||
- **Hachidori Dictionary Backend**: Bundled Hachidori backend as an alternative to Yomitan. Select it with `dictionaryBackend` and restart SubMiner.
|
|
||||||
- Works with subtitle scanning, popups, frequency highlighting, character dictionaries, Anki mining, and stats dashboard mining.
|
|
||||||
- First-run setup builds Hachidori's Anki template from your existing SubMiner deck, tags, and field mappings.
|
|
||||||
- Can link an external Hachidori app, browser, or Docker host while Anki mining stays in SubMiner.
|
|
||||||
- The tray and dictionary-settings shortcut open the selected backend's settings. `--hachidori` and `--yomitan` open a specific one.
|
|
||||||
- **Jellyfin Subtitle Generation**: Japanese subtitle generation now works while playing Jellyfin episodes, with both direct-play and transcoded playback.
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
- **Media Timing Review**: Opens on the first try and keeps playback paused while open, even when a dictionary popup closes.
|
|
||||||
- **Jellyfin Terminal Playback**:
|
|
||||||
- `subminer jellyfin -p` lists libraries when SubMiner is already running, and picker image previews work again.
|
|
||||||
- Real errors, like an expired session or an unreachable server, now appear instead of an empty list.
|
|
||||||
- Watch progress and played status reach Jellyfin even when cast discovery is off.
|
|
||||||
- When it starts SubMiner itself, SubMiner now shows a tray icon and quits when mpv closes.
|
|
||||||
- The rofi search step is now a compact input box.
|
|
||||||
- **macOS Casting**: Jellyfin casting finds mpv when SubMiner is opened from Finder, the Dock, or Spotlight. `mpv.executablePath` and `SUBMINER_MPV_PATH` are now honored on macOS and Linux.
|
|
||||||
- **macOS Launcher PATH**: The command-line launcher no longer shows "Not on PATH" when SubMiner is opened from Finder or the Dock.
|
|
||||||
- **Keyboard-Only Mode**: `h`, `l`, and the arrow keys type normally in modals and text fields instead of seeking mpv. Toggling the mode inside a modal no longer turns on a hidden keyboard-only mode.
|
|
||||||
- **Halfwidth Katakana**: Halfwidth katakana in subtitles now displays as fullwidth, including in mined cards and the stats dashboard. Stats sentence search matches katakana in either width.
|
|
||||||
- **Subtitle Annotations**: Lines shown while two cues overlap now get annotations right away. Prefetching no longer restarts during startup.
|
|
||||||
- **Subtitle Sidebar**: The open sidebar does far less work during playback.
|
|
||||||
- **Hyprland and Sway**: A slow or hung `hyprctl` or `swaymsg` no longer freezes the app.
|
|
||||||
- **Stats Mining**: Cards mined from the stats dashboard keep the selected history line and media.
|
|
||||||
- **Security Updates**: Updated Electron, undici, and follow-redirects to fix new security advisories.
|
|
||||||
|
|
||||||
### Docs
|
|
||||||
- **Hachidori Setup**: Covers backend selection, Hachidori dictionary and Anki setup, settings flags and shortcuts, and linking an external host.
|
|
||||||
- **Jellyfin**: The docs now say SubMiner joins the cast menu at startup only when both `startupWarmups.jellyfinRemoteSession` and `jellyfin.remoteControlAutoConnect` are on. Otherwise, enable **Jellyfin Discovery** in the tray.
|
|
||||||
- **Casting and mpv**: Added how casting finds mpv on each platform, a troubleshooting entry for casts that never start mpv, and a note on the macOS 27 Accessibility list rename.
|
|
||||||
- **Jimaku Environment Variables**: Removed the unused `SUBMINER_JIMAKU_*` variables from the docs. Set the key with `jimaku.apiKey` or `jimaku.apiKeyCommand` instead.
|
|
||||||
|
|
||||||
## What's Changed
|
|
||||||
|
|
||||||
- feat(dictionary): add Hachidori backend support by @ksyasuda in #266
|
|
||||||
- fix(jellyfin): make terminal play work with a running app and report progress by @ksyasuda in #288
|
|
||||||
- fix(overlay): stop Hyprland/Sway tracker polls from blocking the main thread by @ksyasuda in #289
|
|
||||||
- fix(overlay): poll cheap playback state in the open subtitle sidebar by @ksyasuda in #290
|
|
||||||
- fix(overlay): keep keyboard mode from swallowing modal and text-field keys by @ksyasuda in #291
|
|
||||||
- refactor: remove dead code and enable noUnusedLocals by @ksyasuda in #292
|
|
||||||
- feat(subtitles): generate Japanese subtitles during Jellyfin playback by @ksyasuda in #294
|
|
||||||
- fix(subtitles): widen halfwidth katakana where subtitle text enters the app by @ksyasuda in #295
|
|
||||||
- fix(jellyfin): find mpv when SubMiner is launched from Finder by @ksyasuda in #298
|
|
||||||
|
|
||||||
## Installation
|
|
||||||
|
|
||||||
See the README and docs/installation guide for full setup steps.
|
|
||||||
|
|
||||||
## Assets
|
|
||||||
|
|
||||||
- Linux: `SubMiner.AppImage`
|
|
||||||
- macOS: `SubMiner-*.dmg` and `SubMiner-*.zip`
|
|
||||||
- Windows: `SubMiner-*.exe` and `SubMiner-*-win.zip`
|
|
||||||
- Optional extras: `subminer-assets.tar.gz`, the `subminer` launcher, and the Windows `subminer.cmd` launcher
|
|
||||||
- Bun corresponding source: `bun-v1.3.5-source.tar.gz` and its `.sha256` file
|
|
||||||
|
|
||||||
Both launcher downloads use Bun included with the SubMiner app. Download `subminer` on Linux or macOS and `subminer.cmd` on Windows.
|
|
||||||
|
|
||||||
The app bundles an unmodified Bun 1.3.5 runtime. Bun is MIT licensed and statically links JavaScriptCore (LGPL 2.0) and TinyCC (LGPL 2.1). License texts and third-party notices ship inside the app under `resources/bun/licenses`, and the source archive above contains the matching Bun, WebKit, and dependency sources for relinking.
|
|
||||||
@@ -203,7 +203,7 @@ test('writeChangelogArtifacts ignores README, groups fragments by type, writes r
|
|||||||
assert.match(changelog, /### Fixed\n- Polished: fixed entry\./);
|
assert.match(changelog, /### Fixed\n- Polished: fixed entry\./);
|
||||||
assert.match(changelog, /## v0\.4\.0 \(2026-03-01\)\n- Existing fix\n$/);
|
assert.match(changelog, /## v0\.4\.0 \(2026-03-01\)\n- Existing fix\n$/);
|
||||||
|
|
||||||
const releaseNotes = project.read('release/release-notes.md');
|
const releaseNotes = project.read('packaging/release-notes/release-notes.md');
|
||||||
assert.match(releaseNotes, /## Highlights\n### Added\n- Polished: added entry\./);
|
assert.match(releaseNotes, /## Highlights\n### Added\n- Polished: added entry\./);
|
||||||
assert.match(releaseNotes, /### Fixed\n- Polished: fixed entry\./);
|
assert.match(releaseNotes, /### Fixed\n- Polished: fixed entry\./);
|
||||||
assert.match(releaseNotes, /## Installation\n\nSee the README and docs\/installation guide/);
|
assert.match(releaseNotes, /## Installation\n\nSee the README and docs\/installation guide/);
|
||||||
@@ -240,7 +240,7 @@ test('writeChangelogArtifacts skips changelog prepend when release section alrea
|
|||||||
assert.equal(project.exists('changes/001.md'), false);
|
assert.equal(project.exists('changes/001.md'), false);
|
||||||
assert.equal(project.read('CHANGELOG.md'), existingChangelog);
|
assert.equal(project.read('CHANGELOG.md'), existingChangelog);
|
||||||
assert.match(
|
assert.match(
|
||||||
project.read('release/release-notes.md'),
|
project.read('packaging/release-notes/release-notes.md'),
|
||||||
/## Highlights\n### Added\n- Existing release bullet\./,
|
/## Highlights\n### Added\n- Existing release bullet\./,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -268,7 +268,7 @@ test('writeStableReleaseArtifacts reuses the requested version and date for chan
|
|||||||
});
|
});
|
||||||
|
|
||||||
assert.deepEqual(result.outputPaths, [project.at('CHANGELOG.md')]);
|
assert.deepEqual(result.outputPaths, [project.at('CHANGELOG.md')]);
|
||||||
assert.equal(result.releaseNotesPath, project.at('release/release-notes.md'));
|
assert.equal(result.releaseNotesPath, project.at('packaging/release-notes/release-notes.md'));
|
||||||
assert.equal(result.docsChangelogPath, project.at('docs-site/changelog.md'));
|
assert.equal(result.docsChangelogPath, project.at('docs-site/changelog.md'));
|
||||||
|
|
||||||
assert.match(project.read('CHANGELOG.md'), /## v0\.4\.1 \(2026-03-07\)/);
|
assert.match(project.read('CHANGELOG.md'), /## v0\.4\.1 \(2026-03-07\)/);
|
||||||
@@ -471,7 +471,7 @@ test('writePrereleaseNotesForVersion writes cumulative beta notes without mutati
|
|||||||
deps: { runClaude: stub.runClaude, listPrereleaseTags: () => [] },
|
deps: { runClaude: stub.runClaude, listPrereleaseTags: () => [] },
|
||||||
});
|
});
|
||||||
|
|
||||||
assert.equal(outputPath, project.at('release/prerelease-notes.md'));
|
assert.equal(outputPath, project.at('packaging/release-notes/prerelease-notes.md'));
|
||||||
assert.equal(
|
assert.equal(
|
||||||
project.read('CHANGELOG.md'),
|
project.read('CHANGELOG.md'),
|
||||||
existingChangelog,
|
existingChangelog,
|
||||||
@@ -527,7 +527,7 @@ test('writePrereleaseNotesForVersion reuses existing prerelease notes when addin
|
|||||||
'prerelease-reuse-existing-notes',
|
'prerelease-reuse-existing-notes',
|
||||||
{
|
{
|
||||||
'package.json': packageJson('0.11.3-beta.2'),
|
'package.json': packageJson('0.11.3-beta.2'),
|
||||||
'release/prerelease-notes.md': existingNotes,
|
'packaging/release-notes/prerelease-notes.md': existingNotes,
|
||||||
'changes/001.md': fragment('fixed', 'launcher', '- Fixed launcher prerelease packaging.'),
|
'changes/001.md': fragment('fixed', 'launcher', '- Fixed launcher prerelease packaging.'),
|
||||||
},
|
},
|
||||||
(project) => {
|
(project) => {
|
||||||
@@ -574,7 +574,7 @@ test('writePrereleaseNotesForVersion ignores unmarked prerelease notes from an o
|
|||||||
'prerelease-ignore-unmarked-old-notes',
|
'prerelease-ignore-unmarked-old-notes',
|
||||||
{
|
{
|
||||||
'package.json': packageJson('0.17.0-beta.1'),
|
'package.json': packageJson('0.17.0-beta.1'),
|
||||||
'release/prerelease-notes.md': existingNotes,
|
'packaging/release-notes/prerelease-notes.md': existingNotes,
|
||||||
'changes/001.md': fragment(
|
'changes/001.md': fragment(
|
||||||
'changed',
|
'changed',
|
||||||
'overlay',
|
'overlay',
|
||||||
@@ -785,7 +785,7 @@ test('writeChangelogArtifacts filters internal fragments from the release-notes
|
|||||||
'release-notes mode drops internal fragments',
|
'release-notes mode drops internal fragments',
|
||||||
);
|
);
|
||||||
|
|
||||||
const releaseNotes = project.read('release/release-notes.md');
|
const releaseNotes = project.read('packaging/release-notes/release-notes.md');
|
||||||
assert.doesNotMatch(releaseNotes, /<details>/);
|
assert.doesNotMatch(releaseNotes, /<details>/);
|
||||||
assert.doesNotMatch(releaseNotes, /### Internal/);
|
assert.doesNotMatch(releaseNotes, /### Internal/);
|
||||||
assert.match(
|
assert.match(
|
||||||
@@ -841,7 +841,7 @@ test('writeChangelogArtifacts appends contributor attribution and a new-contribu
|
|||||||
project.at('changes/002.md'),
|
project.at('changes/002.md'),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const releaseNotes = project.read('release/release-notes.md');
|
const releaseNotes = project.read('packaging/release-notes/release-notes.md');
|
||||||
assert.match(releaseNotes, /## What's Changed\n\n/);
|
assert.match(releaseNotes, /## What's Changed\n\n/);
|
||||||
assert.match(releaseNotes, /- feat\(overlay\): add a feature by @ksyasuda in #110\n/);
|
assert.match(releaseNotes, /- feat\(overlay\): add a feature by @ksyasuda in #110\n/);
|
||||||
assert.match(releaseNotes, /- fix\(jellyfin\): restart remote session by @bee-san in #112\n/);
|
assert.match(releaseNotes, /- fix\(jellyfin\): restart remote session by @bee-san in #112\n/);
|
||||||
@@ -904,7 +904,10 @@ test('writeChangelogArtifacts skips contributor attribution in GitHub Actions wi
|
|||||||
});
|
});
|
||||||
|
|
||||||
assert.deepEqual(warnings, []);
|
assert.deepEqual(warnings, []);
|
||||||
assert.doesNotMatch(project.read('release/release-notes.md'), /## What's Changed/);
|
assert.doesNotMatch(
|
||||||
|
project.read('packaging/release-notes/release-notes.md'),
|
||||||
|
/## What's Changed/,
|
||||||
|
);
|
||||||
} finally {
|
} finally {
|
||||||
console.warn = originalWarn;
|
console.warn = originalWarn;
|
||||||
for (const key of envKeys) {
|
for (const key of envKeys) {
|
||||||
@@ -990,7 +993,10 @@ test('writeReleaseNotesForVersion preserves committed contributor attribution be
|
|||||||
|
|
||||||
await withProject(
|
await withProject(
|
||||||
'release-notes-preserve-attribution',
|
'release-notes-preserve-attribution',
|
||||||
{ 'CHANGELOG.md': existingChangelog, 'release/release-notes.md': committedReleaseNotes },
|
{
|
||||||
|
'CHANGELOG.md': existingChangelog,
|
||||||
|
'packaging/release-notes/release-notes.md': committedReleaseNotes,
|
||||||
|
},
|
||||||
(project) => {
|
(project) => {
|
||||||
const outputPath = writeReleaseNotesForVersion({
|
const outputPath = writeReleaseNotesForVersion({
|
||||||
cwd: project.projectRoot,
|
cwd: project.projectRoot,
|
||||||
@@ -1055,7 +1061,7 @@ test('writeChangelogArtifacts strips <details> blocks from release notes when re
|
|||||||
'no Claude calls should fire when the section already exists',
|
'no Claude calls should fire when the section already exists',
|
||||||
);
|
);
|
||||||
|
|
||||||
const releaseNotes = project.read('release/release-notes.md');
|
const releaseNotes = project.read('packaging/release-notes/release-notes.md');
|
||||||
assert.match(releaseNotes, /## Highlights\n### Added\n- Polished: previously committed\./);
|
assert.match(releaseNotes, /## Highlights\n### Added\n- Polished: previously committed\./);
|
||||||
assert.doesNotMatch(releaseNotes, /<details>/);
|
assert.doesNotMatch(releaseNotes, /<details>/);
|
||||||
assert.doesNotMatch(releaseNotes, /### Internal/);
|
assert.doesNotMatch(releaseNotes, /### Internal/);
|
||||||
@@ -1237,7 +1243,7 @@ test('writePrereleaseNotesForVersion strips the stale delta section from the reu
|
|||||||
'prerelease-reuse-strips-delta',
|
'prerelease-reuse-strips-delta',
|
||||||
{
|
{
|
||||||
'package.json': packageJson('0.12.0-beta.3'),
|
'package.json': packageJson('0.12.0-beta.3'),
|
||||||
'release/prerelease-notes.md': existingNotes,
|
'packaging/release-notes/prerelease-notes.md': existingNotes,
|
||||||
'changes/001.md': fragment('added', 'overlay', '- Added overlay coverage.'),
|
'changes/001.md': fragment('added', 'overlay', '- Added overlay coverage.'),
|
||||||
},
|
},
|
||||||
(project) => {
|
(project) => {
|
||||||
@@ -1271,7 +1277,7 @@ test('verifyPrereleaseNotesMatchVersion accepts matching notes and rejects stale
|
|||||||
(project) => {
|
(project) => {
|
||||||
const verify = (version = '0.12.0-beta.2') =>
|
const verify = (version = '0.12.0-beta.2') =>
|
||||||
verifyPrereleaseNotesMatchVersion({ cwd: project.projectRoot, version });
|
verifyPrereleaseNotesMatchVersion({ cwd: project.projectRoot, version });
|
||||||
const notesPath = 'release/prerelease-notes.md';
|
const notesPath = 'packaging/release-notes/prerelease-notes.md';
|
||||||
|
|
||||||
assert.throws(() => verify(), /Missing .*prerelease-notes\.md/);
|
assert.throws(() => verify(), /Missing .*prerelease-notes\.md/);
|
||||||
|
|
||||||
|
|||||||
@@ -68,8 +68,8 @@ type PullRequestChangelogOptions = {
|
|||||||
changedLabels?: string[];
|
changedLabels?: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
const RELEASE_NOTES_PATH = path.join('release', 'release-notes.md');
|
const RELEASE_NOTES_PATH = path.join('packaging', 'release-notes', 'release-notes.md');
|
||||||
const PRERELEASE_NOTES_PATH = path.join('release', 'prerelease-notes.md');
|
const PRERELEASE_NOTES_PATH = path.join('packaging', 'release-notes', 'prerelease-notes.md');
|
||||||
const CHANGELOG_HEADER = '# Changelog';
|
const CHANGELOG_HEADER = '# Changelog';
|
||||||
const CHANGE_TYPES: FragmentType[] = ['added', 'changed', 'fixed', 'docs', 'internal'];
|
const CHANGE_TYPES: FragmentType[] = ['added', 'changed', 'fixed', 'docs', 'internal'];
|
||||||
const SKIP_CHANGELOG_LABEL = 'skip-changelog';
|
const SKIP_CHANGELOG_LABEL = 'skip-changelog';
|
||||||
@@ -1144,7 +1144,7 @@ function isFragmentPath(candidate: string): boolean {
|
|||||||
function isIgnoredPullRequestPath(candidate: string): boolean {
|
function isIgnoredPullRequestPath(candidate: string): boolean {
|
||||||
return (
|
return (
|
||||||
candidate === 'CHANGELOG.md' ||
|
candidate === 'CHANGELOG.md' ||
|
||||||
candidate === 'release/release-notes.md' ||
|
candidate === 'packaging/release-notes/release-notes.md' ||
|
||||||
candidate === 'AGENTS.md' ||
|
candidate === 'AGENTS.md' ||
|
||||||
candidate === 'README.md' ||
|
candidate === 'README.md' ||
|
||||||
candidate.startsWith('changes/') ||
|
candidate.startsWith('changes/') ||
|
||||||
@@ -1405,7 +1405,7 @@ export function verifyPrereleaseNotesMatchVersion(options?: ChangelogOptions): v
|
|||||||
const markerVersion = extractPrereleaseVersionMarker(readFileSync(prereleaseNotesPath, 'utf8'));
|
const markerVersion = extractPrereleaseVersionMarker(readFileSync(prereleaseNotesPath, 'utf8'));
|
||||||
if (markerVersion !== version) {
|
if (markerVersion !== version) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`release/prerelease-notes.md was generated for ${markerVersion ?? 'an unknown version (missing or legacy prerelease-version marker)'} but this release is ${version}. Rerun 'bun run changelog:prerelease-notes --version ${version}' and commit the result.`,
|
`packaging/release-notes/prerelease-notes.md was generated for ${markerVersion ?? 'an unknown version (missing or legacy prerelease-version marker)'} but this release is ${version}. Rerun 'bun run changelog:prerelease-notes --version ${version}' and commit the result.`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { fileURLToPath } from 'node:url';
|
|||||||
|
|
||||||
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||||
const source = path.join(repoRoot, 'vendor', 'hachidori');
|
const source = path.join(repoRoot, 'vendor', 'hachidori');
|
||||||
const output = path.join(repoRoot, 'build', 'hachidori');
|
const output = path.join(repoRoot, 'dist', 'hachidori');
|
||||||
const provenance = JSON.parse(fs.readFileSync(path.join(source, 'SOURCE.json'), 'utf8'));
|
const provenance = JSON.parse(fs.readFileSync(path.join(source, 'SOURCE.json'), 'utf8'));
|
||||||
|
|
||||||
// Upstream commits the engine binaries. Verify the pinned bytes before staging
|
// Upstream commits the engine binaries. Verify the pinned bytes before staging
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ test('Hachidori staging configures Electron without changing the fork source', a
|
|||||||
files.map((file) => readFileSync(source(file), 'utf8')),
|
files.map((file) => readFileSync(source(file), 'utf8')),
|
||||||
before,
|
before,
|
||||||
);
|
);
|
||||||
const staged = await import(new URL('../build/hachidori/overlay-mode.js', import.meta.url).href);
|
const staged = await import(new URL('../dist/hachidori/overlay-mode.js', import.meta.url).href);
|
||||||
assert.equal(staged.OVERLAY_MODE, true);
|
assert.equal(staged.OVERLAY_MODE, true);
|
||||||
assert.equal(staged.HOST_CAPABILITIES.customJavaScript, false);
|
assert.equal(staged.HOST_CAPABILITIES.customJavaScript, false);
|
||||||
const original = await import(source('overlay-mode.js').href);
|
const original = await import(source('overlay-mode.js').href);
|
||||||
@@ -28,13 +28,13 @@ test('Hachidori staging configures Electron without changing the fork source', a
|
|||||||
// SubMiner mines media from mpv, not the overlay viewport, so the lookup
|
// SubMiner mines media from mpv, not the overlay viewport, so the lookup
|
||||||
// highlight stays on for a first install.
|
// highlight stays on for a first install.
|
||||||
const stagedSetup = await import(
|
const stagedSetup = await import(
|
||||||
new URL('../build/hachidori/setup-state.js', import.meta.url).href
|
new URL('../dist/hachidori/setup-state.js', import.meta.url).href
|
||||||
);
|
);
|
||||||
assert.equal(stagedSetup.OVERLAY_MODE_OPTIONS.sourceHighlightEnabled, true);
|
assert.equal(stagedSetup.OVERLAY_MODE_OPTIONS.sourceHighlightEnabled, true);
|
||||||
const originalSetup = await import(source('setup-state.js').href);
|
const originalSetup = await import(source('setup-state.js').href);
|
||||||
assert.equal(originalSetup.OVERLAY_MODE_OPTIONS.sourceHighlightEnabled, false);
|
assert.equal(originalSetup.OVERLAY_MODE_OPTIONS.sourceHighlightEnabled, false);
|
||||||
const manifest = JSON.parse(
|
const manifest = JSON.parse(
|
||||||
readFileSync(new URL('../build/hachidori/manifest.json', import.meta.url), 'utf8'),
|
readFileSync(new URL('../dist/hachidori/manifest.json', import.meta.url), 'utf8'),
|
||||||
);
|
);
|
||||||
const originalManifest = JSON.parse(readFileSync(source('manifest.json'), 'utf8'));
|
const originalManifest = JSON.parse(readFileSync(source('manifest.json'), 'utf8'));
|
||||||
assert.deepEqual(manifest, {
|
assert.deepEqual(manifest, {
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ const repoRoot = path.resolve(dirname, '..');
|
|||||||
const submoduleDir = path.join(repoRoot, 'vendor', 'subminer-yomitan');
|
const submoduleDir = path.join(repoRoot, 'vendor', 'subminer-yomitan');
|
||||||
const submodulePackagePath = path.join(submoduleDir, 'package.json');
|
const submodulePackagePath = path.join(submoduleDir, 'package.json');
|
||||||
const submodulePackageLockPath = path.join(submoduleDir, 'package-lock.json');
|
const submodulePackageLockPath = path.join(submoduleDir, 'package-lock.json');
|
||||||
const buildOutputDir = path.join(repoRoot, 'build', 'yomitan');
|
const buildOutputDir = path.join(repoRoot, 'dist', 'yomitan');
|
||||||
const stampPath = path.join(buildOutputDir, '.subminer-build.json');
|
const stampPath = path.join(buildOutputDir, '.subminer-build.json');
|
||||||
const zipPath = path.join(submoduleDir, 'builds', 'yomitan-chrome.zip');
|
const zipPath = path.join(submoduleDir, 'builds', 'yomitan-chrome.zip');
|
||||||
const bunCommand = process.versions.bun ? process.execPath : 'bun';
|
const bunCommand = process.versions.bun ? process.execPath : 'bun';
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ app
|
|||||||
.then(async () => {
|
.then(async () => {
|
||||||
const targetSession = session.fromPartition('persist:hachidori-check');
|
const targetSession = session.fromPartition('persist:hachidori-check');
|
||||||
const extension = await targetSession.extensions.loadExtension(
|
const extension = await targetSession.extensions.loadExtension(
|
||||||
path.join(root, 'build/hachidori'),
|
path.join(root, 'dist/hachidori'),
|
||||||
{ allowFileAccess: true },
|
{ allowFileAccess: true },
|
||||||
);
|
);
|
||||||
// Keep a host window alive while the dictionary importer opens/closes its
|
// Keep a host window alive while the dictionary importer opens/closes its
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ test('platform packaging preserves the runtime allowlist after builder normalize
|
|||||||
'/tmp/subminer-filter-output',
|
'/tmp/subminer-filter-output',
|
||||||
{
|
{
|
||||||
defaultSrc: root,
|
defaultSrc: root,
|
||||||
globalOutDir: path.join(root, 'release'),
|
globalOutDir: path.join(root, 'dist', 'release'),
|
||||||
customBuildOptions: { files: config.build[platform].files },
|
customBuildOptions: { files: config.build[platform].files },
|
||||||
macroExpander: (value) => value.replaceAll('${arch}', 'x64'),
|
macroExpander: (value) => value.replaceAll('${arch}', 'x64'),
|
||||||
},
|
},
|
||||||
@@ -51,6 +51,9 @@ test('platform packaging preserves the runtime allowlist after builder normalize
|
|||||||
'stats/dist/nested/fixtures/data.json',
|
'stats/dist/nested/fixtures/data.json',
|
||||||
'vendor/texthooker-ui/docs/nested/component.tsx',
|
'vendor/texthooker-ui/docs/nested/component.tsx',
|
||||||
'dist/launcher/subminer',
|
'dist/launcher/subminer',
|
||||||
|
'dist/yomitan/manifest.json',
|
||||||
|
'dist/hachidori/manifest.json',
|
||||||
|
'dist/release/SubMiner-1.0.0.AppImage',
|
||||||
'dist/settings/fonts/MPLUS1[wght].ttf',
|
'dist/settings/fonts/MPLUS1[wght].ttf',
|
||||||
'vendor/subminer-yomitan/ext/manifest.json',
|
'vendor/subminer-yomitan/ext/manifest.json',
|
||||||
]) {
|
]) {
|
||||||
|
|||||||
@@ -10,14 +10,20 @@ import { Readable } from 'node:stream';
|
|||||||
const scriptDir = path.dirname(fileURLToPath(import.meta.url));
|
const scriptDir = path.dirname(fileURLToPath(import.meta.url));
|
||||||
const repoRoot = path.resolve(scriptDir, '..');
|
const repoRoot = path.resolve(scriptDir, '..');
|
||||||
|
|
||||||
export const DEFAULT_MANIFEST_PATH = path.join(repoRoot, 'build', 'bun-source-manifest.json');
|
export const DEFAULT_MANIFEST_PATH = path.join(
|
||||||
|
repoRoot,
|
||||||
|
'packaging',
|
||||||
|
'bun',
|
||||||
|
'bun-source-manifest.json',
|
||||||
|
);
|
||||||
export const DEFAULT_RUNTIME_MANIFEST_PATH = path.join(
|
export const DEFAULT_RUNTIME_MANIFEST_PATH = path.join(
|
||||||
repoRoot,
|
repoRoot,
|
||||||
'build',
|
'packaging',
|
||||||
|
'bun',
|
||||||
'bun-runtime-manifest.json',
|
'bun-runtime-manifest.json',
|
||||||
);
|
);
|
||||||
export const DEFAULT_PACKAGE_JSON_PATH = path.join(repoRoot, 'package.json');
|
export const DEFAULT_PACKAGE_JSON_PATH = path.join(repoRoot, 'package.json');
|
||||||
export const DEFAULT_OUTPUT_DIR = path.join(repoRoot, 'release');
|
export const DEFAULT_OUTPUT_DIR = path.join(repoRoot, 'dist', 'release');
|
||||||
export const DEFAULT_CACHE_DIR = path.join(repoRoot, '.tmp', 'bun-corresponding-source');
|
export const DEFAULT_CACHE_DIR = path.join(repoRoot, '.tmp', 'bun-corresponding-source');
|
||||||
|
|
||||||
function isRecord(value) {
|
function isRecord(value) {
|
||||||
|
|||||||
@@ -4,6 +4,9 @@ import fs from 'node:fs/promises';
|
|||||||
import os from 'node:os';
|
import os from 'node:os';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import {
|
import {
|
||||||
|
DEFAULT_MANIFEST_PATH,
|
||||||
|
DEFAULT_PACKAGE_JSON_PATH,
|
||||||
|
DEFAULT_RUNTIME_MANIFEST_PATH,
|
||||||
downloadArchive,
|
downloadArchive,
|
||||||
parseRegisteredRepositories,
|
parseRegisteredRepositories,
|
||||||
parseSourceManifest,
|
parseSourceManifest,
|
||||||
@@ -67,10 +70,26 @@ describe('Bun corresponding-source manifest', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('default manifest paths resolve to aligned checked-in manifests', async () => {
|
||||||
|
const [manifestText, runtimeManifestText, packageJsonText] = await Promise.all([
|
||||||
|
fs.readFile(DEFAULT_MANIFEST_PATH, 'utf8'),
|
||||||
|
fs.readFile(DEFAULT_RUNTIME_MANIFEST_PATH, 'utf8'),
|
||||||
|
fs.readFile(DEFAULT_PACKAGE_JSON_PATH, 'utf8'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(() =>
|
||||||
|
validateRuntimeAlignment(
|
||||||
|
parseSourceManifest(JSON.parse(manifestText)),
|
||||||
|
JSON.parse(packageJsonText),
|
||||||
|
JSON.parse(runtimeManifestText),
|
||||||
|
),
|
||||||
|
).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
test('rejects stale runtime or package-manager pins', async () => {
|
test('rejects stale runtime or package-manager pins', async () => {
|
||||||
const manifest = parseSourceManifest(
|
const manifest = parseSourceManifest(
|
||||||
JSON.parse(
|
JSON.parse(
|
||||||
await fs.readFile(path.join(projectRoot, 'build/bun-source-manifest.json'), 'utf8'),
|
await fs.readFile(path.join(projectRoot, 'packaging/bun/bun-source-manifest.json'), 'utf8'),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
const runtimeManifest = {
|
const runtimeManifest = {
|
||||||
@@ -100,7 +119,7 @@ describe('Bun corresponding-source manifest', () => {
|
|||||||
test('rejects drift in CMake dependency and WebKit pins', async () => {
|
test('rejects drift in CMake dependency and WebKit pins', async () => {
|
||||||
const manifest = parseSourceManifest(
|
const manifest = parseSourceManifest(
|
||||||
JSON.parse(
|
JSON.parse(
|
||||||
await fs.readFile(path.join(projectRoot, 'build/bun-source-manifest.json'), 'utf8'),
|
await fs.readFile(path.join(projectRoot, 'packaging/bun/bun-source-manifest.json'), 'utf8'),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
const registrations = manifest.sources
|
const registrations = manifest.sources
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ cat <<'EOF'
|
|||||||
patch-yomitan.sh is retired.
|
patch-yomitan.sh is retired.
|
||||||
|
|
||||||
SubMiner now uses the forked source submodule at vendor/subminer-yomitan and builds the
|
SubMiner now uses the forked source submodule at vendor/subminer-yomitan and builds the
|
||||||
Chromium extension artifact into build/yomitan.
|
Chromium extension artifact into dist/yomitan.
|
||||||
|
|
||||||
Use:
|
Use:
|
||||||
git submodule update --init --recursive
|
git submodule update --init --recursive
|
||||||
|
|||||||
@@ -9,9 +9,9 @@ paths=(
|
|||||||
"tsconfig.json"
|
"tsconfig.json"
|
||||||
"tsconfig.renderer.json"
|
"tsconfig.renderer.json"
|
||||||
"tsconfig.typecheck.json"
|
"tsconfig.typecheck.json"
|
||||||
".prettierrc.json"
|
|
||||||
".github"
|
".github"
|
||||||
"build"
|
"packaging/bun/bun-runtime-manifest.json"
|
||||||
|
"packaging/bun/bun-source-manifest.json"
|
||||||
"e2e"
|
"e2e"
|
||||||
"launcher"
|
"launcher"
|
||||||
"scripts"
|
"scripts"
|
||||||
|
|||||||
@@ -14,9 +14,14 @@ const scriptDir = path.dirname(fileURLToPath(import.meta.url));
|
|||||||
const repoRoot = path.resolve(scriptDir, '..');
|
const repoRoot = path.resolve(scriptDir, '..');
|
||||||
|
|
||||||
export const DEFAULT_PACKAGE_JSON_PATH = path.join(repoRoot, 'package.json');
|
export const DEFAULT_PACKAGE_JSON_PATH = path.join(repoRoot, 'package.json');
|
||||||
export const DEFAULT_MANIFEST_PATH = path.join(repoRoot, 'build', 'bun-runtime-manifest.json');
|
export const DEFAULT_MANIFEST_PATH = path.join(
|
||||||
|
repoRoot,
|
||||||
|
'packaging',
|
||||||
|
'bun',
|
||||||
|
'bun-runtime-manifest.json',
|
||||||
|
);
|
||||||
export const DEFAULT_CACHE_DIR = path.join(repoRoot, '.tmp', 'bun-runtime');
|
export const DEFAULT_CACHE_DIR = path.join(repoRoot, '.tmp', 'bun-runtime');
|
||||||
export const DEFAULT_LICENSES_SOURCE_DIR = path.join(repoRoot, 'resources', 'bun', 'licenses');
|
export const DEFAULT_LICENSES_SOURCE_DIR = path.join(repoRoot, 'packaging', 'bun', 'licenses');
|
||||||
export const STAGED_METADATA_FILE = 'metadata.json';
|
export const STAGED_METADATA_FILE = 'metadata.json';
|
||||||
export const REQUIRED_LICENSE_FILES = [
|
export const REQUIRED_LICENSE_FILES = [
|
||||||
'Bun-LICENSE.md',
|
'Bun-LICENSE.md',
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ function runtimeConfig() {
|
|||||||
'win32-x64': { file: 'bun-windows-x64-baseline.zip', sha256: '5'.repeat(64) },
|
'win32-x64': { file: 'bun-windows-x64-baseline.zip', sha256: '5'.repeat(64) },
|
||||||
},
|
},
|
||||||
licenseInventoryStatus: 'complete-for-bun-1.3.5-declared-linked-libraries',
|
licenseInventoryStatus: 'complete-for-bun-1.3.5-declared-linked-libraries',
|
||||||
sourceManifest: 'build/bun-source-manifest.json',
|
sourceManifest: 'packaging/bun/bun-source-manifest.json',
|
||||||
correspondingSourceAsset: 'bun-v1.3.5-source.tar.gz',
|
correspondingSourceAsset: 'bun-v1.3.5-source.tar.gz',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -96,7 +96,7 @@ test('manifest version must match the exact packageManager Bun pin', () => {
|
|||||||
releaseTagCommit: 'fa5a5bbe556a4bda5bde77b4013aa6c3bb4ec9ab',
|
releaseTagCommit: 'fa5a5bbe556a4bda5bde77b4013aa6c3bb4ec9ab',
|
||||||
artifacts: {},
|
artifacts: {},
|
||||||
licenseInventoryStatus: 'partial',
|
licenseInventoryStatus: 'partial',
|
||||||
sourceManifest: 'build/bun-source-manifest.json',
|
sourceManifest: 'packaging/bun/bun-source-manifest.json',
|
||||||
correspondingSourceAsset: 'bun-v1.3.5-source.tar.gz',
|
correspondingSourceAsset: 'bun-v1.3.5-source.tar.gz',
|
||||||
},
|
},
|
||||||
'1.3.5',
|
'1.3.5',
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ import {
|
|||||||
} from './anki-field-config';
|
} from './anki-field-config';
|
||||||
import { createLogger } from './logger';
|
import { createLogger } from './logger';
|
||||||
import { captureLiveSubtitleMiningContext } from './core/services/mining';
|
import { captureLiveSubtitleMiningContext } from './core/services/mining';
|
||||||
|
import { shiftSubtitleRangeToVideoClock } from './core/services/subtitle-clock';
|
||||||
import {
|
import {
|
||||||
createUiFeedbackState,
|
createUiFeedbackState,
|
||||||
beginUpdateProgress,
|
beginUpdateProgress,
|
||||||
@@ -1029,10 +1030,10 @@ export class AnkiIntegration {
|
|||||||
Number.isFinite(this.mpvClient.currentSubEnd) &&
|
Number.isFinite(this.mpvClient.currentSubEnd) &&
|
||||||
this.mpvClient.currentSubEnd > this.mpvClient.currentSubStart
|
this.mpvClient.currentSubEnd > this.mpvClient.currentSubStart
|
||||||
) {
|
) {
|
||||||
return {
|
return shiftSubtitleRangeToVideoClock(
|
||||||
startTime: this.mpvClient.currentSubStart,
|
{ startTime: this.mpvClient.currentSubStart, endTime: this.mpvClient.currentSubEnd },
|
||||||
endTime: this.mpvClient.currentSubEnd,
|
this.mpvClient.currentSubDelay,
|
||||||
};
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const currentTime = this.mpvClient.currentTimePos || 0;
|
const currentTime = this.mpvClient.currentTimePos || 0;
|
||||||
@@ -1672,7 +1673,7 @@ export class AnkiIntegration {
|
|||||||
if (this.config.fields?.miscInfo) {
|
if (this.config.fields?.miscInfo) {
|
||||||
result.miscInfoValue = this.formatMiscInfoPattern(
|
result.miscInfoValue = this.formatMiscInfoPattern(
|
||||||
audioFilename,
|
audioFilename,
|
||||||
this.mpvClient.currentSubStart,
|
this.mpvClient.currentSubStart + this.mpvClient.currentSubDelay,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1695,7 +1696,7 @@ export class AnkiIntegration {
|
|||||||
if (this.config.fields?.miscInfo && !result.miscInfoValue) {
|
if (this.config.fields?.miscInfo && !result.miscInfoValue) {
|
||||||
result.miscInfoValue = this.formatMiscInfoPattern(
|
result.miscInfoValue = this.formatMiscInfoPattern(
|
||||||
imageFilename,
|
imageFilename,
|
||||||
this.mpvClient.currentSubStart,
|
this.mpvClient.currentSubStart + this.mpvClient.currentSubDelay,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
type MediaTimingReviewRequest,
|
type MediaTimingReviewRequest,
|
||||||
type WordCardKind,
|
type WordCardKind,
|
||||||
} from '../types/anki';
|
} from '../types/anki';
|
||||||
|
import { shiftSubtitleRangeToVideoClock } from '../core/services/subtitle-clock';
|
||||||
import { createLogger } from '../logger';
|
import { createLogger } from '../logger';
|
||||||
import type { MediaInput } from '../media-input';
|
import type { MediaInput } from '../media-input';
|
||||||
import { SubtitleTimingTracker } from '../subtitle-timing-tracker';
|
import { SubtitleTimingTracker } from '../subtitle-timing-tracker';
|
||||||
@@ -428,10 +429,12 @@ export class CardCreationService {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let startTime = mpvClient.currentSubStart;
|
let { startTime, endTime } = shiftSubtitleRangeToVideoClock(
|
||||||
let endTime = mpvClient.currentSubEnd;
|
{ startTime: mpvClient.currentSubStart, endTime: mpvClient.currentSubEnd },
|
||||||
|
mpvClient.currentSubDelay,
|
||||||
|
);
|
||||||
|
|
||||||
if (startTime === undefined || endTime === undefined) {
|
if (mpvClient.currentSubStart === undefined || mpvClient.currentSubEnd === undefined) {
|
||||||
const currentTime = mpvClient.currentTimePos || 0;
|
const currentTime = mpvClient.currentTimePos || 0;
|
||||||
const fallback = this.deps.getFallbackDurationSeconds() / 2;
|
const fallback = this.deps.getFallbackDurationSeconds() / 2;
|
||||||
startTime = currentTime - fallback;
|
startTime = currentTime - fallback;
|
||||||
|
|||||||
@@ -11,9 +11,9 @@ test('Hachidori resolves development and packaged artifacts without falling back
|
|||||||
assert.equal(
|
assert.equal(
|
||||||
resolveHachidoriExtensionPath({
|
resolveHachidoriExtensionPath({
|
||||||
...options,
|
...options,
|
||||||
exists: (p) => p === '/app/build/hachidori/manifest.json',
|
exists: (p) => p === '/app/dist/hachidori/manifest.json',
|
||||||
}),
|
}),
|
||||||
'/app/build/hachidori',
|
'/app/dist/hachidori',
|
||||||
);
|
);
|
||||||
assert.equal(
|
assert.equal(
|
||||||
resolveHachidoriExtensionPath({
|
resolveHachidoriExtensionPath({
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ export function resolveHachidoriExtensionPath(options: {
|
|||||||
exists?: (candidate: string) => boolean;
|
exists?: (candidate: string) => boolean;
|
||||||
}): string {
|
}): string {
|
||||||
const candidates = [
|
const candidates = [
|
||||||
path.resolve(options.moduleDir, '../../../build/hachidori'),
|
path.resolve(options.moduleDir, '../../../dist/hachidori'),
|
||||||
path.join(options.resourcesPath, 'hachidori'),
|
path.join(options.resourcesPath, 'hachidori'),
|
||||||
];
|
];
|
||||||
const found = candidates.find((candidate) =>
|
const found = candidates.find((candidate) =>
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ export {
|
|||||||
subtitleCueListSeekTime,
|
subtitleCueListSeekTime,
|
||||||
subtitleCueSeekTime,
|
subtitleCueSeekTime,
|
||||||
} from './subtitle-cue-navigation';
|
} from './subtitle-cue-navigation';
|
||||||
|
export { shiftSubtitleRangeToVideoClock, toSubtitleClock, toVideoClock } from './subtitle-clock';
|
||||||
export { createFrequencyDictionaryLookup } from './frequency-dictionary';
|
export { createFrequencyDictionaryLookup } from './frequency-dictionary';
|
||||||
export { createJlptVocabularyLookup } from './jlpt-vocab';
|
export { createJlptVocabularyLookup } from './jlpt-vocab';
|
||||||
export {
|
export {
|
||||||
|
|||||||
@@ -89,6 +89,7 @@ function createSubtitleSidebarSnapshotFixture(): SubtitleSidebarSnapshot {
|
|||||||
cuesRevision: 1,
|
cuesRevision: 1,
|
||||||
cues: [],
|
cues: [],
|
||||||
currentTimeSec: null,
|
currentTimeSec: null,
|
||||||
|
subDelay: 0,
|
||||||
currentSubtitle: { text: '', startTime: null, endTime: null },
|
currentSubtitle: { text: '', startTime: null, endTime: null },
|
||||||
config: {
|
config: {
|
||||||
enabled: false,
|
enabled: false,
|
||||||
|
|||||||
@@ -77,6 +77,7 @@ test('mineSentenceCard handles missing integration and disconnected mpv', async
|
|||||||
currentSubText: 'line',
|
currentSubText: 'line',
|
||||||
currentSubStart: 1,
|
currentSubStart: 1,
|
||||||
currentSubEnd: 2,
|
currentSubEnd: 2,
|
||||||
|
currentSubDelay: 0,
|
||||||
},
|
},
|
||||||
showMpvOsd: (text) => osd.push(text),
|
showMpvOsd: (text) => osd.push(text),
|
||||||
}),
|
}),
|
||||||
@@ -109,6 +110,7 @@ test('mineSentenceCard creates sentence card from mpv subtitle state', async ()
|
|||||||
currentSubText: 'subtitle line',
|
currentSubText: 'subtitle line',
|
||||||
currentSubStart: 10,
|
currentSubStart: 10,
|
||||||
currentSubEnd: 12,
|
currentSubEnd: 12,
|
||||||
|
currentSubDelay: 0,
|
||||||
currentSecondarySubText: 'secondary line',
|
currentSecondarySubText: 'secondary line',
|
||||||
},
|
},
|
||||||
showMpvOsd: () => {},
|
showMpvOsd: () => {},
|
||||||
@@ -148,6 +150,7 @@ test('mineSentenceCard prefers a canonical primary subtitle snapshot', async ()
|
|||||||
currentSubText: '今今今手手手',
|
currentSubText: '今今今手手手',
|
||||||
currentSubStart: 11.4,
|
currentSubStart: 11.4,
|
||||||
currentSubEnd: 11.8,
|
currentSubEnd: 11.8,
|
||||||
|
currentSubDelay: 0,
|
||||||
currentSecondarySubText: 'English subtitle',
|
currentSecondarySubText: 'English subtitle',
|
||||||
},
|
},
|
||||||
primarySubtitle: {
|
primarySubtitle: {
|
||||||
@@ -187,6 +190,7 @@ test('mineSentenceCard uses normalized secondary subtitle state instead of raw m
|
|||||||
currentSubText: '日本語字幕',
|
currentSubText: '日本語字幕',
|
||||||
currentSubStart: 10,
|
currentSubStart: 10,
|
||||||
currentSubEnd: 12,
|
currentSubEnd: 12,
|
||||||
|
currentSubDelay: 0,
|
||||||
currentSecondarySubText: 'Your\nmosaic',
|
currentSecondarySubText: 'Your\nmosaic',
|
||||||
requestProperty: async () => {
|
requestProperty: async () => {
|
||||||
requestedRawSecondaryText = true;
|
requestedRawSecondaryText = true;
|
||||||
@@ -218,6 +222,7 @@ test('mineSentenceCard omits normalized secondary text that matches the primary
|
|||||||
currentSubText: '日本語字幕',
|
currentSubText: '日本語字幕',
|
||||||
currentSubStart: 10,
|
currentSubStart: 10,
|
||||||
currentSubEnd: 12,
|
currentSubEnd: 12,
|
||||||
|
currentSubDelay: 0,
|
||||||
currentSecondarySubText: '日本語字幕',
|
currentSecondarySubText: '日本語字幕',
|
||||||
},
|
},
|
||||||
showMpvOsd: () => {},
|
showMpvOsd: () => {},
|
||||||
@@ -396,7 +401,7 @@ test('handleMineSentenceDigit joins per-entry secondary subtitles when available
|
|||||||
|
|
||||||
test('captureLiveSubtitleMiningContext snapshots the current line and timings', () => {
|
test('captureLiveSubtitleMiningContext snapshots the current line and timings', () => {
|
||||||
const context = captureLiveSubtitleMiningContext(
|
const context = captureLiveSubtitleMiningContext(
|
||||||
{ currentSubText: ' 食べる ', currentSubStart: 12.5, currentSubEnd: 15.25 },
|
{ currentSubText: ' 食べる ', currentSubStart: 12.5, currentSubEnd: 15.25, currentSubDelay: 0 },
|
||||||
() => 1234,
|
() => 1234,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -409,6 +414,16 @@ test('captureLiveSubtitleMiningContext snapshots the current line and timings',
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('captureLiveSubtitleMiningContext moves sub-delay retimed lines onto the video clock', () => {
|
||||||
|
const context = captureLiveSubtitleMiningContext(
|
||||||
|
{ currentSubText: '食べる', currentSubStart: 5, currentSubEnd: 8, currentSubDelay: -1.5 },
|
||||||
|
() => 1234,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(context?.startTime, 3.5);
|
||||||
|
assert.equal(context?.endTime, 6.5);
|
||||||
|
});
|
||||||
|
|
||||||
test('captureLiveSubtitleMiningContext rejects missing client, empty text and bad timings', () => {
|
test('captureLiveSubtitleMiningContext rejects missing client, empty text and bad timings', () => {
|
||||||
assert.equal(captureLiveSubtitleMiningContext(null), null);
|
assert.equal(captureLiveSubtitleMiningContext(null), null);
|
||||||
assert.equal(
|
assert.equal(
|
||||||
@@ -416,6 +431,7 @@ test('captureLiveSubtitleMiningContext rejects missing client, empty text and ba
|
|||||||
currentSubText: ' ',
|
currentSubText: ' ',
|
||||||
currentSubStart: 1,
|
currentSubStart: 1,
|
||||||
currentSubEnd: 2,
|
currentSubEnd: 2,
|
||||||
|
currentSubDelay: 0,
|
||||||
}),
|
}),
|
||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
@@ -424,6 +440,7 @@ test('captureLiveSubtitleMiningContext rejects missing client, empty text and ba
|
|||||||
currentSubText: 'line',
|
currentSubText: 'line',
|
||||||
currentSubStart: 5,
|
currentSubStart: 5,
|
||||||
currentSubEnd: 5,
|
currentSubEnd: 5,
|
||||||
|
currentSubDelay: 0,
|
||||||
}),
|
}),
|
||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
@@ -432,6 +449,7 @@ test('captureLiveSubtitleMiningContext rejects missing client, empty text and ba
|
|||||||
currentSubText: 'line',
|
currentSubText: 'line',
|
||||||
currentSubStart: NaN,
|
currentSubStart: NaN,
|
||||||
currentSubEnd: 2,
|
currentSubEnd: 2,
|
||||||
|
currentSubDelay: 0,
|
||||||
}),
|
}),
|
||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { SubtitleTimingBlock } from '../../subtitle-timing-tracker';
|
import type { SubtitleTimingBlock } from '../../subtitle-timing-tracker';
|
||||||
import type { SubtitleMiningContext } from '../../types/subtitle';
|
import type { SubtitleMiningContext } from '../../types/subtitle';
|
||||||
|
import { shiftSubtitleRangeToVideoClock } from './subtitle-clock';
|
||||||
|
|
||||||
interface SubtitleTimingTrackerLike {
|
interface SubtitleTimingTrackerLike {
|
||||||
getRecentBlocks: (count: number) => string[];
|
getRecentBlocks: (count: number) => string[];
|
||||||
@@ -25,6 +26,7 @@ interface MpvClientLike {
|
|||||||
currentSubText: string;
|
currentSubText: string;
|
||||||
currentSubStart: number;
|
currentSubStart: number;
|
||||||
currentSubEnd: number;
|
currentSubEnd: number;
|
||||||
|
currentSubDelay: number;
|
||||||
currentSecondarySubText?: string;
|
currentSecondarySubText?: string;
|
||||||
requestProperty?: (name: string) => Promise<unknown>;
|
requestProperty?: (name: string) => Promise<unknown>;
|
||||||
}
|
}
|
||||||
@@ -62,7 +64,10 @@ export function handleMultiCopyDigit(
|
|||||||
* line is on screen by then; callers capture here at mining time instead.
|
* line is on screen by then; callers capture here at mining time instead.
|
||||||
*/
|
*/
|
||||||
export function captureLiveSubtitleMiningContext(
|
export function captureLiveSubtitleMiningContext(
|
||||||
client: Pick<MpvClientLike, 'currentSubText' | 'currentSubStart' | 'currentSubEnd'> | null,
|
client: Pick<
|
||||||
|
MpvClientLike,
|
||||||
|
'currentSubText' | 'currentSubStart' | 'currentSubEnd' | 'currentSubDelay'
|
||||||
|
> | null,
|
||||||
now: () => number = Date.now,
|
now: () => number = Date.now,
|
||||||
): SubtitleMiningContext | null {
|
): SubtitleMiningContext | null {
|
||||||
if (!client) {
|
if (!client) {
|
||||||
@@ -73,7 +78,10 @@ export function captureLiveSubtitleMiningContext(
|
|||||||
if (!text || !Number.isFinite(startTime) || !Number.isFinite(endTime) || endTime <= startTime) {
|
if (!text || !Number.isFinite(startTime) || !Number.isFinite(endTime) || endTime <= startTime) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return { source: 'overlay', text, startTime, endTime, capturedAtMs: now() };
|
return shiftSubtitleRangeToVideoClock<SubtitleMiningContext>(
|
||||||
|
{ source: 'overlay', text, startTime, endTime, capturedAtMs: now() },
|
||||||
|
client.currentSubDelay,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function copyCurrentSubtitle(deps: {
|
export function copyCurrentSubtitle(deps: {
|
||||||
@@ -167,6 +175,7 @@ export async function markLastCardAsAudioCard(deps: {
|
|||||||
export async function mineSentenceCard(deps: {
|
export async function mineSentenceCard(deps: {
|
||||||
ankiIntegration: AnkiIntegrationLike | null;
|
ankiIntegration: AnkiIntegrationLike | null;
|
||||||
mpvClient: MpvClientLike | null;
|
mpvClient: MpvClientLike | null;
|
||||||
|
/** Already on the video clock, like every `SubtitleMiningContext`. */
|
||||||
primarySubtitle?: Pick<SubtitleMiningContext, 'text' | 'startTime' | 'endTime'>;
|
primarySubtitle?: Pick<SubtitleMiningContext, 'text' | 'startTime' | 'endTime'>;
|
||||||
showMpvOsd: (text: string) => void;
|
showMpvOsd: (text: string) => void;
|
||||||
}): Promise<boolean> {
|
}): Promise<boolean> {
|
||||||
@@ -185,10 +194,16 @@ export async function mineSentenceCard(deps: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const secondarySubText = getCurrentSecondarySubTextForSentenceCard(mpvClient, primaryText);
|
const secondarySubText = getCurrentSecondarySubTextForSentenceCard(mpvClient, primaryText);
|
||||||
|
const range =
|
||||||
|
deps.primarySubtitle ??
|
||||||
|
shiftSubtitleRangeToVideoClock(
|
||||||
|
{ startTime: mpvClient.currentSubStart, endTime: mpvClient.currentSubEnd },
|
||||||
|
mpvClient.currentSubDelay,
|
||||||
|
);
|
||||||
return await anki.createSentenceCard(
|
return await anki.createSentenceCard(
|
||||||
primaryText,
|
primaryText,
|
||||||
deps.primarySubtitle?.startTime ?? mpvClient.currentSubStart,
|
range.startTime,
|
||||||
deps.primarySubtitle?.endTime ?? mpvClient.currentSubEnd,
|
range.endTime,
|
||||||
secondarySubText,
|
secondarySubText,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ const MPV_SUBTITLE_PROPERTY_OBSERVATIONS: string[] = [
|
|||||||
'path',
|
'path',
|
||||||
'sub-start',
|
'sub-start',
|
||||||
'sub-end',
|
'sub-end',
|
||||||
|
'sub-delay',
|
||||||
'time-pos',
|
'time-pos',
|
||||||
'secondary-sub-text',
|
'secondary-sub-text',
|
||||||
'aid',
|
'aid',
|
||||||
|
|||||||
@@ -73,6 +73,8 @@ function createDeps(overrides: Partial<MpvProtocolHandleMessageDeps> = {}): {
|
|||||||
getCurrentSubStart: () => 0,
|
getCurrentSubStart: () => 0,
|
||||||
setCurrentSubEnd: () => {},
|
setCurrentSubEnd: () => {},
|
||||||
getCurrentSubEnd: () => 0,
|
getCurrentSubEnd: () => 0,
|
||||||
|
setCurrentSubDelay: () => {},
|
||||||
|
getCurrentSubDelay: () => 0,
|
||||||
emitMediaPathChange: (payload) => {
|
emitMediaPathChange: (payload) => {
|
||||||
state.mediaPath = payload.path;
|
state.mediaPath = payload.path;
|
||||||
},
|
},
|
||||||
@@ -131,6 +133,22 @@ test('dispatchMpvProtocolMessage emits subtitle text on property change', async
|
|||||||
assert.deepEqual(state.events, [{ text: '字幕', isOverlayVisible: false }]);
|
assert.deepEqual(state.events, [{ text: '字幕', isOverlayVisible: false }]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('dispatchMpvProtocolMessage tracks sub-delay retiming', async () => {
|
||||||
|
const delays: number[] = [];
|
||||||
|
const { deps } = createDeps({ setCurrentSubDelay: (value) => delays.push(value) });
|
||||||
|
|
||||||
|
await dispatchMpvProtocolMessage(
|
||||||
|
{ event: 'property-change', name: 'sub-delay', data: 1.5 },
|
||||||
|
deps,
|
||||||
|
);
|
||||||
|
await dispatchMpvProtocolMessage(
|
||||||
|
{ event: 'property-change', name: 'sub-delay', data: null },
|
||||||
|
deps,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.deepEqual(delays, [1.5, 0]);
|
||||||
|
});
|
||||||
|
|
||||||
test('dispatchMpvProtocolMessage widens halfwidth katakana in subtitle text', async () => {
|
test('dispatchMpvProtocolMessage widens halfwidth katakana in subtitle text', async () => {
|
||||||
const { deps, state } = createDeps();
|
const { deps, state } = createDeps();
|
||||||
|
|
||||||
@@ -379,6 +397,29 @@ test('dispatchMpvProtocolMessage pauses on sub-end when pendingPauseAtSubEnd is
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('dispatchMpvProtocolMessage pauses at the sub-delay retimed sub-end', async () => {
|
||||||
|
let pauseAtTime: number | null = null;
|
||||||
|
let subDelay = 1.5;
|
||||||
|
const { deps } = createDeps({
|
||||||
|
getPendingPauseAtSubEnd: () => true,
|
||||||
|
getCurrentSubDelay: () => subDelay,
|
||||||
|
setCurrentSubDelay: (next) => {
|
||||||
|
subDelay = next;
|
||||||
|
},
|
||||||
|
getPauseAtTime: () => pauseAtTime,
|
||||||
|
setPauseAtTime: (next) => {
|
||||||
|
pauseAtTime = next;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await dispatchMpvProtocolMessage({ event: 'property-change', name: 'sub-end', data: 42 }, deps);
|
||||||
|
assert.equal(pauseAtTime, 43.5);
|
||||||
|
|
||||||
|
// Retiming before the replay reaches its end moves the pause with the line.
|
||||||
|
await dispatchMpvProtocolMessage({ event: 'property-change', name: 'sub-delay', data: 2 }, deps);
|
||||||
|
assert.equal(pauseAtTime, 44);
|
||||||
|
});
|
||||||
|
|
||||||
test('dispatchMpvProtocolMessage updates current time before emitting time-pos change', async () => {
|
test('dispatchMpvProtocolMessage updates current time before emitting time-pos change', async () => {
|
||||||
const calls: string[] = [];
|
const calls: string[] = [];
|
||||||
let currentTimePos = 0;
|
let currentTimePos = 0;
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { MpvSubtitleRenderMetrics } from '../../types';
|
import { MpvSubtitleRenderMetrics } from '../../types';
|
||||||
import { sanitizeMediaTitle } from '../../shared/media-identity';
|
import { sanitizeMediaTitle } from '../../shared/media-identity';
|
||||||
import { widenHalfwidthKana } from '../utils/halfwidth-kana';
|
import { widenHalfwidthKana } from '../utils/halfwidth-kana';
|
||||||
|
import { toVideoClock } from './subtitle-clock';
|
||||||
|
|
||||||
export type MpvMessage = {
|
export type MpvMessage = {
|
||||||
event?: string;
|
event?: string;
|
||||||
@@ -64,6 +65,8 @@ export interface MpvProtocolHandleMessageDeps {
|
|||||||
getCurrentSubStart: () => number;
|
getCurrentSubStart: () => number;
|
||||||
setCurrentSubEnd: (value: number) => void;
|
setCurrentSubEnd: (value: number) => void;
|
||||||
getCurrentSubEnd: () => number;
|
getCurrentSubEnd: () => number;
|
||||||
|
setCurrentSubDelay: (value: number) => void;
|
||||||
|
getCurrentSubDelay: () => number;
|
||||||
emitMediaPathChange: (payload: { path: string }) => void;
|
emitMediaPathChange: (payload: { path: string }) => void;
|
||||||
emitMediaTitleChange: (payload: { title: string | null }) => void;
|
emitMediaTitleChange: (payload: { title: string | null }) => void;
|
||||||
emitTimePosChange: (payload: { time: number }) => void;
|
emitTimePosChange: (payload: { time: number }) => void;
|
||||||
@@ -263,7 +266,8 @@ export async function dispatchMpvProtocolMessage(
|
|||||||
const subEnd = (msg.data as number) || 0;
|
const subEnd = (msg.data as number) || 0;
|
||||||
deps.setCurrentSubEnd(subEnd);
|
deps.setCurrentSubEnd(subEnd);
|
||||||
if (deps.getPendingPauseAtSubEnd() && subEnd > 0) {
|
if (deps.getPendingPauseAtSubEnd() && subEnd > 0) {
|
||||||
deps.setPauseAtTime(subEnd);
|
// `sub-end` is on the subtitle clock; the pause check compares it with `time-pos`.
|
||||||
|
deps.setPauseAtTime(toVideoClock(subEnd, deps.getCurrentSubDelay()));
|
||||||
deps.setPendingPauseAtSubEnd(false);
|
deps.setPendingPauseAtSubEnd(false);
|
||||||
deps.sendCommand({ command: ['set_property', 'pause', false] });
|
deps.sendCommand({ command: ['set_property', 'pause', false] });
|
||||||
}
|
}
|
||||||
@@ -272,6 +276,16 @@ export async function dispatchMpvProtocolMessage(
|
|||||||
start: deps.getCurrentSubStart(),
|
start: deps.getCurrentSubStart(),
|
||||||
end: deps.getCurrentSubEnd(),
|
end: deps.getCurrentSubEnd(),
|
||||||
});
|
});
|
||||||
|
} else if (msg.name === 'sub-delay') {
|
||||||
|
const parsed = typeof msg.data === 'number' ? msg.data : Number(msg.data);
|
||||||
|
const delay = Number.isFinite(parsed) ? parsed : 0;
|
||||||
|
// A scheduled replay pause sits at the line's end on the video clock; retiming
|
||||||
|
// mid-replay moves that end, so the pause moves with it.
|
||||||
|
const pauseAtTime = deps.getPauseAtTime();
|
||||||
|
if (pauseAtTime !== null) {
|
||||||
|
deps.setPauseAtTime(pauseAtTime + delay - deps.getCurrentSubDelay());
|
||||||
|
}
|
||||||
|
deps.setCurrentSubDelay(delay);
|
||||||
} else if (msg.name === 'secondary-sub-text') {
|
} else if (msg.name === 'secondary-sub-text') {
|
||||||
const nextSubText = (msg.data as string) || '';
|
const nextSubText = (msg.data as string) || '';
|
||||||
deps.setCurrentSecondarySubText(nextSubText);
|
deps.setCurrentSecondarySubText(nextSubText);
|
||||||
|
|||||||
@@ -160,6 +160,8 @@ export class MpvIpcClient implements MpvClient {
|
|||||||
public currentTimePos = 0;
|
public currentTimePos = 0;
|
||||||
public currentSubStart = 0;
|
public currentSubStart = 0;
|
||||||
public currentSubEnd = 0;
|
public currentSubEnd = 0;
|
||||||
|
/** mpv `sub-delay`: seconds each primary line plays after its `sub-start`/`sub-end`. */
|
||||||
|
public currentSubDelay = 0;
|
||||||
public currentSubText = '';
|
public currentSubText = '';
|
||||||
public currentSecondarySubText = '';
|
public currentSecondarySubText = '';
|
||||||
public currentAudioStreamIndex: number | null = null;
|
public currentAudioStreamIndex: number | null = null;
|
||||||
@@ -459,6 +461,10 @@ export class MpvIpcClient implements MpvClient {
|
|||||||
this.currentSubEnd = value;
|
this.currentSubEnd = value;
|
||||||
},
|
},
|
||||||
getCurrentSubEnd: () => this.currentSubEnd,
|
getCurrentSubEnd: () => this.currentSubEnd,
|
||||||
|
setCurrentSubDelay: (value: number) => {
|
||||||
|
this.currentSubDelay = value;
|
||||||
|
},
|
||||||
|
getCurrentSubDelay: () => this.currentSubDelay,
|
||||||
emitMediaPathChange: (payload) => {
|
emitMediaPathChange: (payload) => {
|
||||||
this.emit('media-path-change', payload);
|
this.emit('media-path-change', payload);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
/**
|
||||||
|
* mpv's `sub-start`/`sub-end` and parsed cues carry the subtitle file's own timestamps
|
||||||
|
* (the subtitle clock), but mpv plays each line `sub-delay` seconds later (z/x retiming,
|
||||||
|
* `sub-step`). Playback position, seeks, and media cuts are on the video clock, so any
|
||||||
|
* value crossing between the two converts here.
|
||||||
|
*/
|
||||||
|
|
||||||
|
function finiteDelay(subDelay: number): number {
|
||||||
|
return Number.isFinite(subDelay) ? subDelay : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Playback position (`time-pos`) on the subtitle clock, comparable with cue times. */
|
||||||
|
export function toSubtitleClock(videoTimeSec: number, subDelay: number): number {
|
||||||
|
return videoTimeSec - finiteDelay(subDelay);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Subtitle-clock seconds on the video clock, for seeks. */
|
||||||
|
export function toVideoClock(subtitleTimeSec: number, subDelay: number): number {
|
||||||
|
return subtitleTimeSec + finiteDelay(subDelay);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A subtitle-clock range on the video clock, for mining and media generation. */
|
||||||
|
export function shiftSubtitleRangeToVideoClock<T extends { startTime: number; endTime: number }>(
|
||||||
|
range: T,
|
||||||
|
subDelay: number,
|
||||||
|
): T {
|
||||||
|
const delay = finiteDelay(subDelay);
|
||||||
|
if (delay === 0) {
|
||||||
|
return range;
|
||||||
|
}
|
||||||
|
return { ...range, startTime: range.startTime + delay, endTime: range.endTime + delay };
|
||||||
|
}
|
||||||
@@ -26,7 +26,7 @@ test('next subtitle navigation skips generated ASS events and seeks to the next
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
assert.deepEqual(resolveSanitizedSubtitleSeekCommand(['sub-seek', 1], cues, 10.2), [
|
assert.deepEqual(resolveSanitizedSubtitleSeekCommand(['sub-seek', 1], cues, 10.2, 0), [
|
||||||
'seek',
|
'seek',
|
||||||
13.08,
|
13.08,
|
||||||
'absolute+exact',
|
'absolute+exact',
|
||||||
@@ -41,7 +41,7 @@ test('next subtitle navigation treats simultaneous sanitized cues as one line bo
|
|||||||
{ startTime: 13.02, endTime: 16, text: 'Next English' },
|
{ startTime: 13.02, endTime: 16, text: 'Next English' },
|
||||||
];
|
];
|
||||||
|
|
||||||
assert.deepEqual(resolveSanitizedSubtitleSeekCommand(['sub-seek', 1], cues, 10.1), [
|
assert.deepEqual(resolveSanitizedSubtitleSeekCommand(['sub-seek', 1], cues, 10.1, 0), [
|
||||||
'seek',
|
'seek',
|
||||||
13.08,
|
13.08,
|
||||||
'absolute+exact',
|
'absolute+exact',
|
||||||
@@ -55,7 +55,7 @@ test('next subtitle navigation advances past the latest overlapping lyric', () =
|
|||||||
{ startTime: 16, endTime: 19, text: 'next lyric' },
|
{ startTime: 16, endTime: 19, text: 'next lyric' },
|
||||||
];
|
];
|
||||||
|
|
||||||
assert.deepEqual(resolveSanitizedSubtitleSeekCommand(['sub-seek', 1], cues, 13.2), [
|
assert.deepEqual(resolveSanitizedSubtitleSeekCommand(['sub-seek', 1], cues, 13.2, 0), [
|
||||||
'seek',
|
'seek',
|
||||||
16.08,
|
16.08,
|
||||||
'absolute+exact',
|
'absolute+exact',
|
||||||
@@ -68,7 +68,7 @@ test('previous subtitle navigation leaves the current cue and seeks to the prior
|
|||||||
{ startTime: 13, endTime: 16, text: 'current line' },
|
{ startTime: 13, endTime: 16, text: 'current line' },
|
||||||
];
|
];
|
||||||
|
|
||||||
assert.deepEqual(resolveSanitizedSubtitleSeekCommand(['sub-seek', -1], cues, 14.5), [
|
assert.deepEqual(resolveSanitizedSubtitleSeekCommand(['sub-seek', -1], cues, 14.5, 0), [
|
||||||
'seek',
|
'seek',
|
||||||
10.08,
|
10.08,
|
||||||
'absolute+exact',
|
'absolute+exact',
|
||||||
@@ -78,8 +78,22 @@ test('previous subtitle navigation leaves the current cue and seeks to the prior
|
|||||||
test('subtitle navigation falls back when no sanitized destination exists', () => {
|
test('subtitle navigation falls back when no sanitized destination exists', () => {
|
||||||
const cues = [{ startTime: 10, endTime: 13, text: 'only line' }];
|
const cues = [{ startTime: 10, endTime: 13, text: 'only line' }];
|
||||||
|
|
||||||
assert.equal(resolveSanitizedSubtitleSeekCommand(['sub-seek', 1], cues, 10.2), null);
|
assert.equal(resolveSanitizedSubtitleSeekCommand(['sub-seek', 1], cues, 10.2, 0), null);
|
||||||
assert.equal(resolveSanitizedSubtitleSeekCommand(['seek', 5], cues, 10.2), null);
|
assert.equal(resolveSanitizedSubtitleSeekCommand(['seek', 5], cues, 10.2, 0), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('subtitle cue navigation finds and seeks cues on the sub-delay retimed timeline', () => {
|
||||||
|
const cues = [
|
||||||
|
{ startTime: 10, endTime: 13, text: 'one' },
|
||||||
|
{ startTime: 13, endTime: 16, text: 'two' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// At 14.5 with a 2s delay, mpv is showing "one" (12-15 on the video clock).
|
||||||
|
assert.deepEqual(resolveSanitizedSubtitleSeekCommand(['sub-seek', 1], cues, 14.5, 2), [
|
||||||
|
'seek',
|
||||||
|
15.08,
|
||||||
|
'absolute+exact',
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('sidebar cue seeks share the boundary-safe sanitized cue timestamp', () => {
|
test('sidebar cue seeks share the boundary-safe sanitized cue timestamp', () => {
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { toSubtitleClock, toVideoClock } from './subtitle-clock';
|
||||||
import type { SubtitleCue } from './subtitle-cue-parser';
|
import type { SubtitleCue } from './subtitle-cue-parser';
|
||||||
|
|
||||||
const CUE_START_GROUP_TOLERANCE_SECONDS = 0.05;
|
const CUE_START_GROUP_TOLERANCE_SECONDS = 0.05;
|
||||||
@@ -80,8 +81,10 @@ export function subtitleCueListSeekTime(
|
|||||||
export function resolveSanitizedSubtitleSeekCommand(
|
export function resolveSanitizedSubtitleSeekCommand(
|
||||||
command: readonly (string | number)[],
|
command: readonly (string | number)[],
|
||||||
cues: readonly SubtitleCue[],
|
cues: readonly SubtitleCue[],
|
||||||
currentTimeSec: number,
|
videoTimeSec: number,
|
||||||
|
subDelay: number,
|
||||||
): (string | number)[] | null {
|
): (string | number)[] | null {
|
||||||
|
const currentTimeSec = toSubtitleClock(videoTimeSec, subDelay);
|
||||||
if (
|
if (
|
||||||
command.length < 2 ||
|
command.length < 2 ||
|
||||||
command[0] !== 'sub-seek' ||
|
command[0] !== 'sub-seek' ||
|
||||||
@@ -124,5 +127,5 @@ export function resolveSanitizedSubtitleSeekCommand(
|
|||||||
if (!destination) {
|
if (!destination) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return ['seek', subtitleCueSeekTime(destination.cue), 'absolute+exact'];
|
return ['seek', toVideoClock(subtitleCueSeekTime(destination.cue), subDelay), 'absolute+exact'];
|
||||||
}
|
}
|
||||||
@@ -20,7 +20,7 @@ test('getYomitanExtensionSearchPaths prioritizes generated build output before p
|
|||||||
});
|
});
|
||||||
|
|
||||||
assert.deepEqual(searchPaths, [
|
assert.deepEqual(searchPaths, [
|
||||||
path.join(repoRoot, 'build', 'yomitan'),
|
path.join(repoRoot, 'dist', 'yomitan'),
|
||||||
path.join(resourcesPath, 'yomitan'),
|
path.join(resourcesPath, 'yomitan'),
|
||||||
'/usr/share/SubMiner/yomitan',
|
'/usr/share/SubMiner/yomitan',
|
||||||
path.join(userDataPath, 'yomitan'),
|
path.join(userDataPath, 'yomitan'),
|
||||||
@@ -29,19 +29,19 @@ test('getYomitanExtensionSearchPaths prioritizes generated build output before p
|
|||||||
|
|
||||||
test('resolveExistingYomitanExtensionPath returns first manifest-backed candidate', () => {
|
test('resolveExistingYomitanExtensionPath returns first manifest-backed candidate', () => {
|
||||||
const existing = new Set<string>([
|
const existing = new Set<string>([
|
||||||
path.join('/repo', 'build', 'yomitan', 'manifest.json'),
|
path.join('/repo', 'dist', 'yomitan', 'manifest.json'),
|
||||||
path.join('/repo', 'vendor', 'subminer-yomitan', 'ext', 'manifest.json'),
|
path.join('/repo', 'vendor', 'subminer-yomitan', 'ext', 'manifest.json'),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const resolved = resolveExistingYomitanExtensionPath(
|
const resolved = resolveExistingYomitanExtensionPath(
|
||||||
[
|
[
|
||||||
path.join('/repo', 'build', 'yomitan'),
|
path.join('/repo', 'dist', 'yomitan'),
|
||||||
path.join('/repo', 'vendor', 'subminer-yomitan', 'ext'),
|
path.join('/repo', 'vendor', 'subminer-yomitan', 'ext'),
|
||||||
],
|
],
|
||||||
(candidate) => existing.has(candidate),
|
(candidate) => existing.has(candidate),
|
||||||
);
|
);
|
||||||
|
|
||||||
assert.equal(resolved, path.join('/repo', 'build', 'yomitan'));
|
assert.equal(resolved, path.join('/repo', 'dist', 'yomitan'));
|
||||||
});
|
});
|
||||||
|
|
||||||
test('resolveExistingYomitanExtensionPath ignores source tree without built manifest', () => {
|
test('resolveExistingYomitanExtensionPath ignores source tree without built manifest', () => {
|
||||||
|
|||||||
@@ -22,12 +22,10 @@ export function getYomitanExtensionSearchPaths(
|
|||||||
const searchPaths: string[] = [];
|
const searchPaths: string[] = [];
|
||||||
|
|
||||||
pushUnique(searchPaths, options.explicitPath ? path.resolve(options.explicitPath) : null);
|
pushUnique(searchPaths, options.explicitPath ? path.resolve(options.explicitPath) : null);
|
||||||
pushUnique(searchPaths, options.cwd ? path.resolve(options.cwd, 'build', 'yomitan') : null);
|
pushUnique(searchPaths, options.cwd ? path.resolve(options.cwd, 'dist', 'yomitan') : null);
|
||||||
pushUnique(
|
pushUnique(
|
||||||
searchPaths,
|
searchPaths,
|
||||||
options.moduleDir
|
options.moduleDir ? path.resolve(options.moduleDir, '..', '..', '..', 'dist', 'yomitan') : null,
|
||||||
? path.resolve(options.moduleDir, '..', '..', '..', 'build', 'yomitan')
|
|
||||||
: null,
|
|
||||||
);
|
);
|
||||||
pushUnique(
|
pushUnique(
|
||||||
searchPaths,
|
searchPaths,
|
||||||
|
|||||||
+35
-12
@@ -328,9 +328,11 @@ import {
|
|||||||
setMpvSubVisibilityRuntime,
|
setMpvSubVisibilityRuntime,
|
||||||
setOverlayDebugVisualizationEnabledRuntime,
|
setOverlayDebugVisualizationEnabledRuntime,
|
||||||
setVisibleOverlayVisible as setVisibleOverlayVisibleCore,
|
setVisibleOverlayVisible as setVisibleOverlayVisibleCore,
|
||||||
|
shiftSubtitleRangeToVideoClock,
|
||||||
showMpvOsdRuntime,
|
showMpvOsdRuntime,
|
||||||
JellyfinPlaybackReporter,
|
JellyfinPlaybackReporter,
|
||||||
tokenizeSubtitle as tokenizeSubtitleCore,
|
tokenizeSubtitle as tokenizeSubtitleCore,
|
||||||
|
toSubtitleClock,
|
||||||
triggerFieldGrouping as triggerFieldGroupingCore,
|
triggerFieldGrouping as triggerFieldGroupingCore,
|
||||||
upsertYomitanDictionarySettings,
|
upsertYomitanDictionarySettings,
|
||||||
updateLastCardFromClipboard as updateLastCardFromClipboardCore,
|
updateLastCardFromClipboard as updateLastCardFromClipboardCore,
|
||||||
@@ -1937,7 +1939,10 @@ let tokenizeSubtitleDeferred: ((text: string) => Promise<SubtitleData>) | null =
|
|||||||
function withCurrentSubtitleTiming(payload: SubtitleData): SubtitleData {
|
function withCurrentSubtitleTiming(payload: SubtitleData): SubtitleData {
|
||||||
const canonical = resolveCanonicalPrimarySubtitle({
|
const canonical = resolveCanonicalPrimarySubtitle({
|
||||||
liveText: payload.text,
|
liveText: payload.text,
|
||||||
currentTimeSec: Number(appState.mpvClient?.currentTimePos),
|
currentTimeSec: toSubtitleClock(
|
||||||
|
Number(appState.mpvClient?.currentTimePos),
|
||||||
|
appState.mpvClient?.currentSubDelay ?? 0,
|
||||||
|
),
|
||||||
cues: appState.activeParsedSubtitleCues,
|
cues: appState.activeParsedSubtitleCues,
|
||||||
});
|
});
|
||||||
return {
|
return {
|
||||||
@@ -1951,9 +1956,11 @@ function captureCurrentPrimarySubtitleMiningContext(): SubtitleMiningContext | n
|
|||||||
// Mine what the overlay shows, not raw mpv `sub-text`: the raw text lists every active
|
// Mine what the overlay shows, not raw mpv `sub-text`: the raw text lists every active
|
||||||
// event, so a finished caption row lingering beside a fresh line would end up on the
|
// event, so a finished caption row lingering beside a fresh line would end up on the
|
||||||
// card. The parsed view also carries the cue's own timings for the clip range.
|
// card. The parsed view also carries the cue's own timings for the clip range.
|
||||||
|
// Cues sit on the subtitle clock, so the lookup undoes `sub-delay` first.
|
||||||
|
const subDelay = appState.mpvClient?.currentSubDelay ?? 0;
|
||||||
const resolved = resolvePrimarySubtitle({
|
const resolved = resolvePrimarySubtitle({
|
||||||
liveText: appState.mpvClient?.currentSubText ?? '',
|
liveText: appState.mpvClient?.currentSubText ?? '',
|
||||||
currentTimeSec: Number(appState.mpvClient?.currentTimePos),
|
currentTimeSec: toSubtitleClock(Number(appState.mpvClient?.currentTimePos), subDelay),
|
||||||
cues: appState.activeParsedSubtitleCues,
|
cues: appState.activeParsedSubtitleCues,
|
||||||
});
|
});
|
||||||
// Same validity bar as the live capture path: an unusable resolved span must fall
|
// Same validity bar as the live capture path: an unusable resolved span must fall
|
||||||
@@ -1968,13 +1975,16 @@ function captureCurrentPrimarySubtitleMiningContext(): SubtitleMiningContext | n
|
|||||||
) {
|
) {
|
||||||
return captureLiveSubtitleMiningContext(appState.mpvClient);
|
return captureLiveSubtitleMiningContext(appState.mpvClient);
|
||||||
}
|
}
|
||||||
return {
|
return shiftSubtitleRangeToVideoClock<SubtitleMiningContext>(
|
||||||
source: 'overlay',
|
{
|
||||||
text: resolvedText,
|
source: 'overlay',
|
||||||
startTime: resolved.startTime,
|
text: resolvedText,
|
||||||
endTime: resolved.endTime,
|
startTime: resolved.startTime,
|
||||||
capturedAtMs: Date.now(),
|
endTime: resolved.endTime,
|
||||||
};
|
capturedAtMs: Date.now(),
|
||||||
|
},
|
||||||
|
subDelay,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
function emitSubtitlePayload(payload: SubtitleData, options?: { resumePrefetch?: boolean }): void {
|
function emitSubtitlePayload(payload: SubtitleData, options?: { resumePrefetch?: boolean }): void {
|
||||||
const timedPayload = withCurrentSubtitleTiming(payload);
|
const timedPayload = withCurrentSubtitleTiming(payload);
|
||||||
@@ -2116,13 +2126,18 @@ function getSubtitleSidebarPlaybackState(): SubtitleSidebarPlaybackState {
|
|||||||
subtitleSidebarCuesRef = appState.activeParsedSubtitleCues;
|
subtitleSidebarCuesRef = appState.activeParsedSubtitleCues;
|
||||||
subtitleSidebarCuesRevision += 1;
|
subtitleSidebarCuesRevision += 1;
|
||||||
}
|
}
|
||||||
|
const subDelay = appState.mpvClient?.currentSubDelay ?? 0;
|
||||||
return {
|
return {
|
||||||
sourceKey: JSON.stringify([
|
sourceKey: JSON.stringify([
|
||||||
appState.activeParsedSubtitleMediaPath,
|
appState.activeParsedSubtitleMediaPath,
|
||||||
appState.activeParsedSubtitleSource,
|
appState.activeParsedSubtitleSource,
|
||||||
]),
|
]),
|
||||||
cuesRevision: subtitleSidebarCuesRevision,
|
cuesRevision: subtitleSidebarCuesRevision,
|
||||||
currentTimeSec: appState.mpvClient?.currentTimePos ?? null,
|
currentTimeSec:
|
||||||
|
appState.mpvClient?.currentTimePos === undefined
|
||||||
|
? null
|
||||||
|
: toSubtitleClock(appState.mpvClient.currentTimePos, subDelay),
|
||||||
|
subDelay,
|
||||||
currentSubtitle: {
|
currentSubtitle: {
|
||||||
text: appState.currentSubText,
|
text: appState.currentSubText,
|
||||||
startTime: appState.mpvClient?.currentSubStart ?? null,
|
startTime: appState.mpvClient?.currentSubStart ?? null,
|
||||||
@@ -2161,6 +2176,7 @@ function sendRendererMpvCommand(rawCommand: (string | number)[]): void {
|
|||||||
rawCommand,
|
rawCommand,
|
||||||
appState.activeParsedSubtitleCues,
|
appState.activeParsedSubtitleCues,
|
||||||
appState.mpvClient?.currentTimePos ?? Number.NaN,
|
appState.mpvClient?.currentTimePos ?? Number.NaN,
|
||||||
|
appState.mpvClient?.currentSubDelay ?? 0,
|
||||||
) ?? rawCommand;
|
) ?? rawCommand;
|
||||||
if (isExplicitMpvSeekCommand(command)) {
|
if (isExplicitMpvSeekCommand(command)) {
|
||||||
explicitSeekIntentExpiresAtMs = Date.now() + EXPLICIT_SEEK_INTENT_TTL_MS;
|
explicitSeekIntentExpiresAtMs = Date.now() + EXPLICIT_SEEK_INTENT_TTL_MS;
|
||||||
@@ -3115,7 +3131,10 @@ const mediaTimingReviewRuntime = createMediaTimingReviewRuntime({
|
|||||||
acquireMediaWindow: (source, range) => getSharedRemoteMediaWindowCache().acquire(source, range),
|
acquireMediaWindow: (source, range) => getSharedRemoteMediaWindowCache().acquire(source, range),
|
||||||
getSubtitleContextLines: (range) =>
|
getSubtitleContextLines: (range) =>
|
||||||
collectMediaTimingContextLines({
|
collectMediaTimingContextLines({
|
||||||
cues: appState.activeParsedSubtitleCues,
|
// Review ranges and tracker entries are on the video clock; parsed cues are not.
|
||||||
|
cues: appState.activeParsedSubtitleCues.map((cue) =>
|
||||||
|
shiftSubtitleRangeToVideoClock(cue, appState.mpvClient?.currentSubDelay ?? 0),
|
||||||
|
),
|
||||||
fallbackPrevious: appState.subtitleTimingTracker?.getRecentEntries(40) ?? [],
|
fallbackPrevious: appState.subtitleTimingTracker?.getRecentEntries(40) ?? [],
|
||||||
startTime: range.startTime,
|
startTime: range.startTime,
|
||||||
endTime: range.endTime,
|
endTime: range.endTime,
|
||||||
@@ -6028,7 +6047,11 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
|
|||||||
// live mpv sub timings at lookup time so media generation clips the mined line even
|
// live mpv sub timings at lookup time so media generation clips the mined line even
|
||||||
// when extraction finishes long after playback has moved on.
|
// when extraction finishes long after playback has moved on.
|
||||||
recordSubtitleMiningContext: (context) =>
|
recordSubtitleMiningContext: (context) =>
|
||||||
recordSubtitleMiningContext(context ?? captureCurrentPrimarySubtitleMiningContext()),
|
recordSubtitleMiningContext(
|
||||||
|
context
|
||||||
|
? shiftSubtitleRangeToVideoClock(context, appState.mpvClient?.currentSubDelay ?? 0)
|
||||||
|
: captureCurrentPrimarySubtitleMiningContext(),
|
||||||
|
),
|
||||||
tokenizeCurrentSubtitle: async () => {
|
tokenizeCurrentSubtitle: async () => {
|
||||||
const tokenizeSubtitleForCurrent = tokenizeSubtitleDeferred;
|
const tokenizeSubtitleForCurrent = tokenizeSubtitleDeferred;
|
||||||
return resolveCurrentSubtitleForRenderer({
|
return resolveCurrentSubtitleForRenderer({
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { toSubtitleClock } from '../../core/services/subtitle-clock';
|
||||||
import type { SubtitleCue, SubtitleData } from '../../types';
|
import type { SubtitleCue, SubtitleData } from '../../types';
|
||||||
import { selectAutoplayStartupCue } from './autoplay-subtitle-primer';
|
import { selectAutoplayStartupCue } from './autoplay-subtitle-primer';
|
||||||
import { primeVisibleOverlaySubtitleFromMpv } from './current-subtitle-snapshot';
|
import { primeVisibleOverlaySubtitleFromMpv } from './current-subtitle-snapshot';
|
||||||
@@ -12,6 +13,7 @@ type AutoplaySubtitlePrimingMpvClient = {
|
|||||||
requestProperty: (name: string) => Promise<unknown>;
|
requestProperty: (name: string) => Promise<unknown>;
|
||||||
currentVideoPath?: string;
|
currentVideoPath?: string;
|
||||||
currentTimePos?: number;
|
currentTimePos?: number;
|
||||||
|
currentSubDelay?: number;
|
||||||
currentSubText?: string;
|
currentSubText?: string;
|
||||||
currentSecondarySubText?: string;
|
currentSecondarySubText?: string;
|
||||||
setCurrentSecondarySubText?: (text: string) => void;
|
setCurrentSecondarySubText?: (text: string) => void;
|
||||||
@@ -156,7 +158,7 @@ export function createAutoplaySubtitlePrimingRuntime(deps: AutoplaySubtitlePrimi
|
|||||||
const currentTimeSec = Number(client?.currentTimePos ?? deps.getLastObservedTimePos());
|
const currentTimeSec = Number(client?.currentTimePos ?? deps.getLastObservedTimePos());
|
||||||
return resolvePrimarySubtitleText({
|
return resolvePrimarySubtitleText({
|
||||||
liveText: text,
|
liveText: text,
|
||||||
currentTimeSec,
|
currentTimeSec: toSubtitleClock(currentTimeSec, client?.currentSubDelay ?? 0),
|
||||||
cues: deps.getActiveParsedSubtitleCues(),
|
cues: deps.getActiveParsedSubtitleCues(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -270,7 +272,10 @@ export function createAutoplaySubtitlePrimingRuntime(deps: AutoplaySubtitlePrimi
|
|||||||
const currentTimeSeconds = Number(
|
const currentTimeSeconds = Number(
|
||||||
timePosRaw ?? client?.currentTimePos ?? deps.getLastObservedTimePos() ?? 0,
|
timePosRaw ?? client?.currentTimePos ?? deps.getLastObservedTimePos() ?? 0,
|
||||||
);
|
);
|
||||||
const resolvedTimeSeconds = Number.isFinite(currentTimeSeconds) ? currentTimeSeconds : 0;
|
const resolvedTimeSeconds = toSubtitleClock(
|
||||||
|
Number.isFinite(currentTimeSeconds) ? currentTimeSeconds : 0,
|
||||||
|
client?.currentSubDelay ?? 0,
|
||||||
|
);
|
||||||
const cue = selectAutoplayStartupCue(
|
const cue = selectAutoplayStartupCue(
|
||||||
cues,
|
cues,
|
||||||
resolvedTimeSeconds,
|
resolvedTimeSeconds,
|
||||||
|
|||||||
@@ -176,6 +176,21 @@ test('subtitle-track transitions ignore stale parsed cues until replacement cues
|
|||||||
assert.deepEqual(recordedStarts.slice(-1), [20]);
|
assert.deepEqual(recordedStarts.slice(-1), [20]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('subtitle timing history records lines on the video clock under sub-delay', () => {
|
||||||
|
const timing: Array<{ text: string; start: number; end: number }> = [];
|
||||||
|
const { handlers } = buildMainDeps({
|
||||||
|
mpvClient: { currentSubDelay: 2 },
|
||||||
|
subtitleTimingTracker: {
|
||||||
|
recordSubtitle: (text: string, start: number, end: number) =>
|
||||||
|
timing.push({ text, start, end }),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
handlers.recordSubtitleTiming('食べる', 5, 8);
|
||||||
|
|
||||||
|
assert.deepEqual(timing, [{ text: '食べる', start: 7, end: 10 }]);
|
||||||
|
});
|
||||||
|
|
||||||
test('canonical ASS cues replace live glyph spam for display, history, and immersion', () => {
|
test('canonical ASS cues replace live glyph spam for display, history, and immersion', () => {
|
||||||
const immersion: Array<{ text: string; start: number; end: number }> = [];
|
const immersion: Array<{ text: string; start: number; end: number }> = [];
|
||||||
const timing: Array<{ text: string; start: number; end: number }> = [];
|
const timing: Array<{ text: string; start: number; end: number }> = [];
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { toSubtitleClock } from '../../core/services/subtitle-clock';
|
||||||
import { createSubtitleLineDedupGate } from '../../core/services/subtitle-line-dedup-gate';
|
import { createSubtitleLineDedupGate } from '../../core/services/subtitle-line-dedup-gate';
|
||||||
import type { MergedToken, SubtitleCue, SubtitleData } from '../../types';
|
import type { MergedToken, SubtitleCue, SubtitleData } from '../../types';
|
||||||
import {
|
import {
|
||||||
@@ -23,6 +24,7 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
|
|||||||
currentSubText?: string;
|
currentSubText?: string;
|
||||||
currentSecondarySubText?: string;
|
currentSecondarySubText?: string;
|
||||||
currentTimePos?: number;
|
currentTimePos?: number;
|
||||||
|
currentSubDelay?: number;
|
||||||
requestProperty?: (name: string) => Promise<unknown>;
|
requestProperty?: (name: string) => Promise<unknown>;
|
||||||
} | null;
|
} | null;
|
||||||
immersionTracker: {
|
immersionTracker: {
|
||||||
@@ -161,7 +163,10 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
|
|||||||
resolveSubtitleText: (liveText: string) =>
|
resolveSubtitleText: (liveText: string) =>
|
||||||
resolvePrimarySubtitleText({
|
resolvePrimarySubtitleText({
|
||||||
liveText,
|
liveText,
|
||||||
currentTimeSec: Number(deps.appState.mpvClient?.currentTimePos),
|
currentTimeSec: toSubtitleClock(
|
||||||
|
Number(deps.appState.mpvClient?.currentTimePos),
|
||||||
|
deps.appState.mpvClient?.currentSubDelay ?? 0,
|
||||||
|
),
|
||||||
cues: deps.appState.activeParsedSubtitleCues,
|
cues: deps.appState.activeParsedSubtitleCues,
|
||||||
}),
|
}),
|
||||||
getCurrentLiveSubtitleText: () => deps.appState.mpvClient?.currentSubText ?? '',
|
getCurrentLiveSubtitleText: () => deps.appState.mpvClient?.currentSubText ?? '',
|
||||||
@@ -231,6 +236,9 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
|
|||||||
hasSubtitleTimingTracker: () => Boolean(deps.appState.subtitleTimingTracker),
|
hasSubtitleTimingTracker: () => Boolean(deps.appState.subtitleTimingTracker),
|
||||||
recordSubtitleTiming: (text: string, start: number, end: number) => {
|
recordSubtitleTiming: (text: string, start: number, end: number) => {
|
||||||
const secondaryText = deps.appState.mpvClient?.currentSecondarySubText || undefined;
|
const secondaryText = deps.appState.mpvClient?.currentSecondarySubText || undefined;
|
||||||
|
// The tracker feeds multi-line mining, so it stores video-clock times: the subtitle
|
||||||
|
// timings plus the `sub-delay` in effect while the line was on screen.
|
||||||
|
const subDelay = deps.appState.mpvClient?.currentSubDelay ?? 0;
|
||||||
const canonical = resolveCanonicalSample(text, start);
|
const canonical = resolveCanonicalSample(text, start);
|
||||||
if (!canonical) {
|
if (!canonical) {
|
||||||
const recordableText = resolveTextForRecording(text, start);
|
const recordableText = resolveTextForRecording(text, start);
|
||||||
@@ -239,8 +247,8 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
|
|||||||
}
|
}
|
||||||
deps.appState.subtitleTimingTracker?.recordSubtitle?.(
|
deps.appState.subtitleTimingTracker?.recordSubtitle?.(
|
||||||
recordableText,
|
recordableText,
|
||||||
start,
|
start + subDelay,
|
||||||
end,
|
end + subDelay,
|
||||||
secondaryText,
|
secondaryText,
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
@@ -253,8 +261,8 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
|
|||||||
recordedTimingCanonicalKeys.add(key);
|
recordedTimingCanonicalKeys.add(key);
|
||||||
deps.appState.subtitleTimingTracker?.recordSubtitle?.(
|
deps.appState.subtitleTimingTracker?.recordSubtitle?.(
|
||||||
cue.text,
|
cue.text,
|
||||||
cue.startTime,
|
cue.startTime + subDelay,
|
||||||
cue.endTime,
|
cue.endTime + subDelay,
|
||||||
secondaryText,
|
secondaryText,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -481,6 +481,8 @@ function installKeyboardTestGlobals() {
|
|||||||
setSessionBindings: (value: CompiledSessionBinding[]) => {
|
setSessionBindings: (value: CompiledSessionBinding[]) => {
|
||||||
sessionBindings = value;
|
sessionBindings = value;
|
||||||
},
|
},
|
||||||
|
// A plain overlay element over playback, outside any SubMiner UI.
|
||||||
|
createPlaybackTarget: () => new TestElement(),
|
||||||
createInteractiveTarget: () => {
|
createInteractiveTarget: () => {
|
||||||
const target = new TestElement();
|
const target = new TestElement();
|
||||||
target.closest = (selector: string) => (selector.includes('.modal') ? target : null);
|
target.closest = (selector: string) => (selector.includes('.modal') ? target : null);
|
||||||
@@ -1297,6 +1299,42 @@ test('Hachidori attention without an open popup pane still forwards imported mpv
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('an open subtitle sidebar only keeps imported mpv input aimed at the sidebar', async () => {
|
||||||
|
const { ctx, handlers, testGlobals } = createKeyboardHandlerHarness();
|
||||||
|
|
||||||
|
try {
|
||||||
|
testGlobals.setGetMpvInputBindings(async () => ({
|
||||||
|
keys: ['0', 'MBTN_LEFT_DBL'],
|
||||||
|
blockedKeys: [],
|
||||||
|
}));
|
||||||
|
await handlers.setupMpvInputForwarding();
|
||||||
|
await wait(0);
|
||||||
|
ctx.state.subtitleSidebarModalOpen = true;
|
||||||
|
const sidebarTarget = testGlobals.createInteractiveTarget();
|
||||||
|
const playbackTarget = testGlobals.createPlaybackTarget();
|
||||||
|
|
||||||
|
// Sidebar input runs first, while no key or button is held, so a held key cannot
|
||||||
|
// hide a forwarded press. Its release has no matching press to forward.
|
||||||
|
testGlobals.dispatchKeydown({ key: '0', code: 'Digit0', target: sidebarTarget });
|
||||||
|
testGlobals.dispatchMousedown({ button: 0, target: sidebarTarget });
|
||||||
|
testGlobals.dispatchMousedown({ button: 0, type: 'mouseup', target: sidebarTarget });
|
||||||
|
assert.deepEqual(testGlobals.mpvCommands, []);
|
||||||
|
|
||||||
|
testGlobals.dispatchKeydown({ key: '0', code: 'Digit0', target: playbackTarget });
|
||||||
|
testGlobals.dispatchMousedown({ button: 0, target: playbackTarget });
|
||||||
|
testGlobals.dispatchMousedown({ button: 0, type: 'mouseup', target: playbackTarget });
|
||||||
|
|
||||||
|
assert.deepEqual(testGlobals.mpvCommands, [
|
||||||
|
['keydown', '0'],
|
||||||
|
['keydown', 'MBTN_LEFT'],
|
||||||
|
['keyup', 'MBTN_LEFT'],
|
||||||
|
]);
|
||||||
|
} finally {
|
||||||
|
ctx.state.subtitleSidebarModalOpen = false;
|
||||||
|
testGlobals.restore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test('keyboard mode: lookup toggle opens a lookup during Hachidori attention without a pane', async () => {
|
test('keyboard mode: lookup toggle opens a lookup during Hachidori attention without a pane', async () => {
|
||||||
const { ctx, handlers, testGlobals } = createKeyboardHandlerHarness();
|
const { ctx, handlers, testGlobals } = createKeyboardHandlerHarness();
|
||||||
|
|
||||||
|
|||||||
@@ -123,12 +123,13 @@ export function createKeyboardHandlers(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Imported mpv bindings only run when no SubMiner UI could be the input's target.
|
// Imported mpv bindings only run when no SubMiner UI could be the input's target.
|
||||||
|
// The subtitle sidebar is a side panel over playback, so only input inside it (a
|
||||||
|
// `.modal` target) stays with SubMiner.
|
||||||
function canForwardToMpv(target: EventTarget | null): boolean {
|
function canForwardToMpv(target: EventTarget | null): boolean {
|
||||||
return (
|
return (
|
||||||
keyboardConfigLoaded &&
|
keyboardConfigLoaded &&
|
||||||
!ctx.state.playlistBrowserModalOpen &&
|
!ctx.state.playlistBrowserModalOpen &&
|
||||||
!ctx.state.youtubePickerModalOpen &&
|
!ctx.state.youtubePickerModalOpen &&
|
||||||
!ctx.state.subtitleSidebarModalOpen &&
|
|
||||||
!isLookupPopupOpen(ctx.state, document) &&
|
!isLookupPopupOpen(ctx.state, document) &&
|
||||||
!isInteractiveTarget(target)
|
!isInteractiveTarget(target)
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ export async function setup() {
|
|||||||
],
|
],
|
||||||
currentSubtitle: { text: '最初の台詞', startTime: 0, endTime: 1 },
|
currentSubtitle: { text: '最初の台詞', startTime: 0, endTime: 1 },
|
||||||
currentTimeSec: 0,
|
currentTimeSec: 0,
|
||||||
|
subDelay: 0,
|
||||||
config: {
|
config: {
|
||||||
...SUBTITLE_DEFAULT_CONFIG.subtitleSidebar,
|
...SUBTITLE_DEFAULT_CONFIG.subtitleSidebar,
|
||||||
enabled: true,
|
enabled: true,
|
||||||
|
|||||||
@@ -199,6 +199,7 @@ function createSidebarHarness(options: SidebarHarnessOptions = {}) {
|
|||||||
cues: options.cues ?? [{ startTime: 1, endTime: 2, text: 'first' }],
|
cues: options.cues ?? [{ startTime: 1, endTime: 2, text: 'first' }],
|
||||||
currentSubtitle: options.currentSubtitle ?? { text: 'first', startTime: 1, endTime: 2 },
|
currentSubtitle: options.currentSubtitle ?? { text: 'first', startTime: 1, endTime: 2 },
|
||||||
currentTimeSec: 'currentTimeSec' in options ? (options.currentTimeSec ?? null) : 1.1,
|
currentTimeSec: 'currentTimeSec' in options ? (options.currentTimeSec ?? null) : 1.1,
|
||||||
|
subDelay: 0,
|
||||||
config: { ...DEFAULT_SIDEBAR_CONFIG, ...options.config },
|
config: { ...DEFAULT_SIDEBAR_CONFIG, ...options.config },
|
||||||
};
|
};
|
||||||
const requests = { snapshot: 0, playback: 0 };
|
const requests = { snapshot: 0, playback: 0 };
|
||||||
@@ -226,8 +227,8 @@ function createSidebarHarness(options: SidebarHarnessOptions = {}) {
|
|||||||
},
|
},
|
||||||
getSubtitleSidebarPlaybackState: async (): Promise<SubtitleSidebarPlaybackState> => {
|
getSubtitleSidebarPlaybackState: async (): Promise<SubtitleSidebarPlaybackState> => {
|
||||||
requests.playback += 1;
|
requests.playback += 1;
|
||||||
const { sourceKey, cuesRevision, currentTimeSec, currentSubtitle } = snapshot;
|
const { sourceKey, cuesRevision, currentTimeSec, subDelay, currentSubtitle } = snapshot;
|
||||||
return { sourceKey, cuesRevision, currentTimeSec, currentSubtitle };
|
return { sourceKey, cuesRevision, currentTimeSec, subDelay, currentSubtitle };
|
||||||
},
|
},
|
||||||
getPlaybackPaused: async () => false,
|
getPlaybackPaused: async () => false,
|
||||||
sendMpvCommand: (command: MpvCommand) => {
|
sendMpvCommand: (command: MpvCommand) => {
|
||||||
@@ -528,6 +529,16 @@ test('subtitle sidebar seeks to the selected cue start using the overlap-aware s
|
|||||||
assert.deepEqual(h.mpvCommands.at(-1), ['seek', 3.48, 'absolute+exact']);
|
assert.deepEqual(h.mpvCommands.at(-1), ['seek', 3.48, 'absolute+exact']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('subtitle sidebar seeks to where mpv plays a sub-delay retimed cue', async () => {
|
||||||
|
const h = createSidebarHarness();
|
||||||
|
h.setSnapshot({ subDelay: 2 });
|
||||||
|
await h.modal.openSubtitleSidebarModal();
|
||||||
|
|
||||||
|
h.modal.seekToCue({ startTime: 1, endTime: 2, text: 'first' });
|
||||||
|
|
||||||
|
assert.deepEqual(h.mpvCommands.at(-1), ['seek', 3.08, 'absolute+exact']);
|
||||||
|
});
|
||||||
|
|
||||||
test('subtitle sidebar close reports hidden visibility and notifies the modal close', async () => {
|
test('subtitle sidebar close reports hidden visibility and notifies the modal close', async () => {
|
||||||
const h = createSidebarHarness();
|
const h = createSidebarHarness();
|
||||||
await h.modal.openSubtitleSidebarModal();
|
await h.modal.openSubtitleSidebarModal();
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import type {
|
|||||||
SubtitleMiningContext,
|
SubtitleMiningContext,
|
||||||
SubtitleSidebarSnapshot,
|
SubtitleSidebarSnapshot,
|
||||||
} from '../../types';
|
} from '../../types';
|
||||||
|
import { toVideoClock } from '../../core/services/subtitle-clock.js';
|
||||||
import { subtitleCueListSeekTime } from '../../core/services/subtitle-cue-navigation.js';
|
import { subtitleCueListSeekTime } from '../../core/services/subtitle-cue-navigation.js';
|
||||||
import type { ModalStateReader, RendererContext } from '../context';
|
import type { ModalStateReader, RendererContext } from '../context';
|
||||||
import { syncOverlayMouseIgnoreState } from '../overlay-mouse-ignore.js';
|
import { syncOverlayMouseIgnoreState } from '../overlay-mouse-ignore.js';
|
||||||
@@ -231,6 +232,8 @@ export function createSubtitleSidebarModal(
|
|||||||
let lastSubtitleSidebarLookupCueIndex = -1;
|
let lastSubtitleSidebarLookupCueIndex = -1;
|
||||||
let subtitleSourceKey: string | null = null;
|
let subtitleSourceKey: string | null = null;
|
||||||
let subtitleCuesRevision: number | null = null;
|
let subtitleCuesRevision: number | null = null;
|
||||||
|
// Latest mpv `sub-delay` from main; cue times plus this are where mpv plays the cue.
|
||||||
|
let subtitleSubDelay = 0;
|
||||||
|
|
||||||
function restoreEmbeddedSidebarPassthrough(): void {
|
function restoreEmbeddedSidebarPassthrough(): void {
|
||||||
syncOverlayMouseIgnoreState(ctx);
|
syncOverlayMouseIgnoreState(ctx);
|
||||||
@@ -416,7 +419,7 @@ export function createSubtitleSidebarModal(
|
|||||||
function seekToCue(cue: SubtitleCue): void {
|
function seekToCue(cue: SubtitleCue): void {
|
||||||
window.electronAPI.sendMpvCommand([
|
window.electronAPI.sendMpvCommand([
|
||||||
'seek',
|
'seek',
|
||||||
subtitleCueListSeekTime(ctx.state.subtitleSidebarCues, cue),
|
toVideoClock(subtitleCueListSeekTime(ctx.state.subtitleSidebarCues, cue), subtitleSubDelay),
|
||||||
'absolute+exact',
|
'absolute+exact',
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
@@ -591,6 +594,7 @@ export function createSubtitleSidebarModal(
|
|||||||
const snapshot = await window.electronAPI.getSubtitleSidebarSnapshot();
|
const snapshot = await window.electronAPI.getSubtitleSidebarSnapshot();
|
||||||
pollsSinceFullSnapshot = 0;
|
pollsSinceFullSnapshot = 0;
|
||||||
subtitleCuesRevision = snapshot.cuesRevision;
|
subtitleCuesRevision = snapshot.cuesRevision;
|
||||||
|
subtitleSubDelay = snapshot.subDelay;
|
||||||
if (snapshot.sourceKey !== subtitleSourceKey) {
|
if (snapshot.sourceKey !== subtitleSourceKey) {
|
||||||
clearSubtitleSidebarSelection(ctx.dom.subtitleSidebarList);
|
clearSubtitleSidebarSelection(ctx.dom.subtitleSidebarList);
|
||||||
lastSubtitleSidebarLookupCueIndex = -1;
|
lastSubtitleSidebarLookupCueIndex = -1;
|
||||||
@@ -640,6 +644,7 @@ export function createSubtitleSidebarModal(
|
|||||||
await refreshSnapshot();
|
await refreshSnapshot();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
subtitleSubDelay = playback.subDelay;
|
||||||
updateActiveCue(playback.currentSubtitle, playback.currentTimeSec);
|
updateActiveCue(playback.currentSubtitle, playback.currentTimeSec);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -90,6 +90,8 @@ export interface MpvClient {
|
|||||||
currentTimePos: number;
|
currentTimePos: number;
|
||||||
currentSubStart: number;
|
currentSubStart: number;
|
||||||
currentSubEnd: number;
|
currentSubEnd: number;
|
||||||
|
/** mpv `sub-delay`; `sub-start`/`sub-end` do not include it. */
|
||||||
|
currentSubDelay: number;
|
||||||
currentAudioStreamIndex: number | null;
|
currentAudioStreamIndex: number | null;
|
||||||
requestProperty?: (name: string) => Promise<unknown>;
|
requestProperty?: (name: string) => Promise<unknown>;
|
||||||
send(command: { command: unknown[]; request_id?: number }): boolean;
|
send(command: { command: unknown[]; request_id?: number }): boolean;
|
||||||
|
|||||||
@@ -235,7 +235,10 @@ export interface SubtitleSidebarPlaybackState {
|
|||||||
sourceKey: string | null;
|
sourceKey: string | null;
|
||||||
/** Bumps whenever main swaps in a different parsed cue array. */
|
/** Bumps whenever main swaps in a different parsed cue array. */
|
||||||
cuesRevision: number;
|
cuesRevision: number;
|
||||||
|
/** Playback position on the subtitle clock (`time-pos` minus `sub-delay`), like cue times. */
|
||||||
currentTimeSec: number | null;
|
currentTimeSec: number | null;
|
||||||
|
/** mpv `sub-delay`: cue times plus this give the video-clock seek target. */
|
||||||
|
subDelay: number;
|
||||||
currentSubtitle: {
|
currentSubtitle: {
|
||||||
text: string;
|
text: string;
|
||||||
startTime: number | null;
|
startTime: number | null;
|
||||||
@@ -251,6 +254,7 @@ export interface SubtitleSidebarSnapshot extends SubtitleSidebarPlaybackState {
|
|||||||
export interface SubtitleMiningContext {
|
export interface SubtitleMiningContext {
|
||||||
source: 'subtitle-sidebar' | 'overlay';
|
source: 'subtitle-sidebar' | 'overlay';
|
||||||
text: string;
|
text: string;
|
||||||
|
/** Video-clock seconds: subtitle timings plus mpv `sub-delay` at capture time. */
|
||||||
startTime: number;
|
startTime: number;
|
||||||
endTime: number;
|
endTime: number;
|
||||||
capturedAtMs?: number;
|
capturedAtMs?: number;
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import { buildSessionStatTiles, pickDensityTickIndexes } from './SessionDetail';
|
||||||
|
import type { SessionSummary } from '../../types/stats';
|
||||||
|
|
||||||
|
const session: SessionSummary = {
|
||||||
|
sessionId: 1,
|
||||||
|
canonicalTitle: 'Terrace House S01E08',
|
||||||
|
videoId: 1,
|
||||||
|
animeId: null,
|
||||||
|
animeTitle: null,
|
||||||
|
startedAtMs: 0,
|
||||||
|
endedAtMs: 1_860_000,
|
||||||
|
totalWatchedMs: 1_860_000,
|
||||||
|
activeWatchedMs: 1_500_000,
|
||||||
|
linesSeen: 400,
|
||||||
|
tokensSeen: 1_684,
|
||||||
|
cardsMined: 0,
|
||||||
|
lookupCount: 0,
|
||||||
|
lookupHits: 0,
|
||||||
|
yomitanLookupCount: 18,
|
||||||
|
knownWordsSeen: 673,
|
||||||
|
knownWordRate: 40,
|
||||||
|
};
|
||||||
|
|
||||||
|
test('buildSessionStatTiles derives pace, known rate, lookup rate, and paused time', () => {
|
||||||
|
const tiles = Object.fromEntries(buildSessionStatTiles(session).map((t) => [t.key, t]));
|
||||||
|
assert.equal(tiles.pace!.value, '67 words/min');
|
||||||
|
assert.equal(tiles.pace!.detail, '16.0 lines/min');
|
||||||
|
assert.equal(tiles.known!.value, '40%');
|
||||||
|
assert.equal(tiles.lookups!.detail, '1.1 lookups per 100 words');
|
||||||
|
assert.equal(tiles.paused!.value, '6m');
|
||||||
|
assert.equal(tiles.paused!.detail, '19% of 31m wall time');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('buildSessionStatTiles shows placeholders when there is no active time or known data', () => {
|
||||||
|
const tiles = Object.fromEntries(
|
||||||
|
buildSessionStatTiles({
|
||||||
|
...session,
|
||||||
|
activeWatchedMs: 0,
|
||||||
|
totalWatchedMs: 0,
|
||||||
|
knownWordsSeen: 0,
|
||||||
|
knownWordRate: 0,
|
||||||
|
}).map((t) => [t.key, t]),
|
||||||
|
);
|
||||||
|
assert.equal(tiles.pace!.value, '—');
|
||||||
|
assert.equal(tiles.known!.value, '—');
|
||||||
|
assert.equal(tiles.paused!.value, 'none');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('pickDensityTickIndexes spaces about five labels across the buckets', () => {
|
||||||
|
assert.deepEqual(pickDensityTickIndexes(3), [0, 1, 2]);
|
||||||
|
assert.deepEqual(pickDensityTickIndexes(50), [0, 10, 20, 30, 40]);
|
||||||
|
assert.deepEqual(pickDensityTickIndexes(0), []);
|
||||||
|
});
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -29,23 +29,12 @@ function toPercent(tsMs: number, tsMin: number, tsMax: number): number {
|
|||||||
return Math.max(0, Math.min(100, ratio));
|
return Math.max(0, Math.min(100, ratio));
|
||||||
}
|
}
|
||||||
|
|
||||||
function markerLabel(marker: SessionChartMarker): string {
|
const CARD_MARKER_LABEL = '\u26CF';
|
||||||
switch (marker.kind) {
|
const CARD_MARKER_COLORS = {
|
||||||
case 'pause':
|
border: '#f5bde6',
|
||||||
return '||';
|
bg: 'rgba(245,189,230,0.18)',
|
||||||
case 'card':
|
text: '#f5bde6',
|
||||||
return '\u26CF';
|
};
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function markerColors(marker: SessionChartMarker): { border: string; bg: string; text: string } {
|
|
||||||
switch (marker.kind) {
|
|
||||||
case 'pause':
|
|
||||||
return { border: '#f5a97f', bg: 'rgba(245,169,127,0.16)', text: '#f5a97f' };
|
|
||||||
case 'card':
|
|
||||||
return { border: '#a6da95', bg: 'rgba(166,218,149,0.16)', text: '#a6da95' };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function popupAlignment(percent: number): string {
|
function popupAlignment(percent: number): string {
|
||||||
if (percent <= 15) return 'left-0 translate-x-0';
|
if (percent <= 15) return 'left-0 translate-x-0';
|
||||||
@@ -137,16 +126,14 @@ export function SessionEventOverlay({
|
|||||||
plotWidthPx: plotArea.width,
|
plotWidthPx: plotArea.width,
|
||||||
})}px`
|
})}px`
|
||||||
: `${percent}%`;
|
: `${percent}%`;
|
||||||
const colors = markerColors(marker);
|
|
||||||
const isActive = marker.key === activeMarkerKey;
|
const isActive = marker.key === activeMarkerKey;
|
||||||
const isPinned = marker.key === pinnedMarkerKey;
|
const isPinned = marker.key === pinnedMarkerKey;
|
||||||
const loading =
|
const loading = marker.noteIds.some((noteId) => loadingNoteIds.has(noteId));
|
||||||
marker.kind === 'card' && marker.noteIds.some((noteId) => loadingNoteIds.has(noteId));
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={marker.key}
|
key={marker.key}
|
||||||
className="pointer-events-auto absolute top-0 -translate-x-1/2 pt-1"
|
className="pointer-events-auto absolute top-1/2 -translate-x-1/2 -translate-y-1/2"
|
||||||
style={{ left }}
|
style={{ left }}
|
||||||
onMouseEnter={() => onHoveredMarkerChange(marker.key)}
|
onMouseEnter={() => onHoveredMarkerChange(marker.key)}
|
||||||
onMouseLeave={(event) =>
|
onMouseLeave={(event) =>
|
||||||
@@ -160,13 +147,14 @@ export function SessionEventOverlay({
|
|||||||
<div className="relative flex flex-col items-center">
|
<div className="relative flex flex-col items-center">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
aria-label={`Show ${marker.kind} event details`}
|
aria-label="Show card mined event details"
|
||||||
aria-pressed={isPinned}
|
aria-pressed={isPinned}
|
||||||
className="flex h-5 min-w-5 items-center justify-center rounded-full border px-1 text-[10px] font-semibold shadow-sm backdrop-blur-sm"
|
title={`${marker.cardsDelta} ${marker.cardsDelta === 1 ? 'card' : 'cards'} mined`}
|
||||||
|
className="flex h-5 min-w-5 items-center justify-center rounded-full border px-1 text-[10px] font-semibold shadow-sm backdrop-blur-sm transition-transform hover:scale-110"
|
||||||
style={{
|
style={{
|
||||||
borderColor: colors.border,
|
borderColor: CARD_MARKER_COLORS.border,
|
||||||
background: colors.bg,
|
background: CARD_MARKER_COLORS.bg,
|
||||||
color: colors.text,
|
color: CARD_MARKER_COLORS.text,
|
||||||
}}
|
}}
|
||||||
onClick={(event) => {
|
onClick={(event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
@@ -175,7 +163,7 @@ export function SessionEventOverlay({
|
|||||||
onPinnedMarkerChange(togglePinnedSessionMarkerKey(pinnedMarkerKey, marker.key));
|
onPinnedMarkerChange(togglePinnedSessionMarkerKey(pinnedMarkerKey, marker.key));
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{markerLabel(marker)}
|
{CARD_MARKER_LABEL}
|
||||||
</button>
|
</button>
|
||||||
{isActive ? (
|
{isActive ? (
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -1,8 +1,4 @@
|
|||||||
import {
|
import type { SessionChartMarker, SessionEventNoteInfo } from '../../lib/session-events';
|
||||||
formatEventSeconds,
|
|
||||||
type SessionChartMarker,
|
|
||||||
type SessionEventNoteInfo,
|
|
||||||
} from '../../lib/session-events';
|
|
||||||
|
|
||||||
interface SessionEventPopoverProps {
|
interface SessionEventPopoverProps {
|
||||||
marker: SessionChartMarker;
|
marker: SessionChartMarker;
|
||||||
@@ -35,10 +31,7 @@ export function SessionEventPopover({
|
|||||||
<div className="relative z-50 w-64 rounded-xl border border-ctp-surface2 bg-ctp-surface0/95 p-3 shadow-2xl shadow-black/30 backdrop-blur-sm">
|
<div className="relative z-50 w-64 rounded-xl border border-ctp-surface2 bg-ctp-surface0/95 p-3 shadow-2xl shadow-black/30 backdrop-blur-sm">
|
||||||
<div className="mb-2 flex items-start justify-between gap-3">
|
<div className="mb-2 flex items-start justify-between gap-3">
|
||||||
<div>
|
<div>
|
||||||
<div className="text-xs font-semibold text-ctp-text">
|
<div className="text-xs font-semibold text-ctp-text">Card mined</div>
|
||||||
{marker.kind === 'pause' && 'Paused'}
|
|
||||||
{marker.kind === 'card' && 'Card mined'}
|
|
||||||
</div>
|
|
||||||
<div className="text-[10px] text-ctp-overlay1">{formatEventTime(marker.eventTsMs)}</div>
|
<div className="text-[10px] text-ctp-overlay1">{formatEventTime(marker.eventTsMs)}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
@@ -64,78 +57,65 @@ export function SessionEventPopover({
|
|||||||
×
|
×
|
||||||
</button>
|
</button>
|
||||||
) : null}
|
) : null}
|
||||||
<div className="text-sm">
|
<div className="text-sm text-ctp-cards-mined">{'\u26CF'}</div>
|
||||||
{marker.kind === 'pause' && '||'}
|
|
||||||
{marker.kind === 'card' && '\u26CF'}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{marker.kind === 'pause' && (
|
<div className="space-y-2">
|
||||||
<div className="text-xs text-ctp-subtext0">
|
<div className="text-xs text-ctp-cards-mined">
|
||||||
Duration: <span className="text-ctp-peach">{formatEventSeconds(marker.durationMs)}</span>
|
+{marker.cardsDelta} {marker.cardsDelta === 1 ? 'card' : 'cards'}
|
||||||
</div>
|
</div>
|
||||||
)}
|
{loading ? (
|
||||||
|
<div className="text-xs text-ctp-overlay1">Loading Anki note info...</div>
|
||||||
{marker.kind === 'card' && (
|
) : null}
|
||||||
<div className="space-y-2">
|
<div className="space-y-1.5">
|
||||||
<div className="text-xs text-ctp-cards-mined">
|
{marker.noteIds.length > 0 ? (
|
||||||
+{marker.cardsDelta} {marker.cardsDelta === 1 ? 'card' : 'cards'}
|
marker.noteIds.map((noteId) => {
|
||||||
</div>
|
const info = noteInfos.get(noteId);
|
||||||
{loading ? (
|
const hasPreview = Boolean(info?.expression || info?.context || info?.meaning);
|
||||||
<div className="text-xs text-ctp-overlay1">Loading Anki note info...</div>
|
const showUnavailableFallback = !loading && !hasPreview;
|
||||||
) : null}
|
return (
|
||||||
<div className="space-y-1.5">
|
<div
|
||||||
{marker.noteIds.length > 0 ? (
|
key={noteId}
|
||||||
marker.noteIds.map((noteId) => {
|
className="rounded-lg border border-ctp-surface1 bg-ctp-mantle/80 px-2.5 py-2"
|
||||||
const info = noteInfos.get(noteId);
|
>
|
||||||
const hasPreview = Boolean(info?.expression || info?.context || info?.meaning);
|
<div className="mb-1 flex items-center justify-between gap-2">
|
||||||
const showUnavailableFallback = !loading && !hasPreview;
|
<div className="rounded-full bg-ctp-surface1 px-1.5 py-0.5 text-[10px] uppercase tracking-wide text-ctp-overlay1">
|
||||||
return (
|
Note {noteId}
|
||||||
<div
|
|
||||||
key={noteId}
|
|
||||||
className="rounded-lg border border-ctp-surface1 bg-ctp-mantle/80 px-2.5 py-2"
|
|
||||||
>
|
|
||||||
<div className="mb-1 flex items-center justify-between gap-2">
|
|
||||||
<div className="rounded-full bg-ctp-surface1 px-1.5 py-0.5 text-[10px] uppercase tracking-wide text-ctp-overlay1">
|
|
||||||
Note {noteId}
|
|
||||||
</div>
|
|
||||||
{showUnavailableFallback ? (
|
|
||||||
<div className="text-[10px] text-ctp-overlay1">Preview unavailable</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
</div>
|
||||||
{info?.expression ? (
|
|
||||||
<div className="mb-1 text-sm font-medium text-ctp-text">
|
|
||||||
{info.expression}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
{info?.context ? (
|
|
||||||
<div className="mb-1 text-xs text-ctp-subtext0">{info.context}</div>
|
|
||||||
) : null}
|
|
||||||
{info?.meaning ? (
|
|
||||||
<div className="mb-2 text-xs text-ctp-teal">{info.meaning}</div>
|
|
||||||
) : null}
|
|
||||||
{showUnavailableFallback ? (
|
{showUnavailableFallback ? (
|
||||||
<div className="mb-2 text-xs text-ctp-overlay1">
|
<div className="text-[10px] text-ctp-overlay1">Preview unavailable</div>
|
||||||
Preview unavailable from AnkiConnect.
|
|
||||||
</div>
|
|
||||||
) : null}
|
) : null}
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => onOpenNote(noteId)}
|
|
||||||
className="rounded-md bg-ctp-surface1 px-2 py-1 text-[10px] text-ctp-blue transition-colors hover:bg-ctp-surface2"
|
|
||||||
>
|
|
||||||
Open in Anki
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
{info?.expression ? (
|
||||||
})
|
<div className="mb-1 text-sm font-medium text-ctp-text">{info.expression}</div>
|
||||||
) : (
|
) : null}
|
||||||
<div className="text-xs text-ctp-overlay1">No linked note ids recorded.</div>
|
{info?.context ? (
|
||||||
)}
|
<div className="mb-1 text-xs text-ctp-subtext0">{info.context}</div>
|
||||||
</div>
|
) : null}
|
||||||
|
{info?.meaning ? (
|
||||||
|
<div className="mb-2 text-xs text-ctp-teal">{info.meaning}</div>
|
||||||
|
) : null}
|
||||||
|
{showUnavailableFallback ? (
|
||||||
|
<div className="mb-2 text-xs text-ctp-overlay1">
|
||||||
|
Preview unavailable from AnkiConnect.
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onOpenNote(noteId)}
|
||||||
|
className="rounded-md bg-ctp-surface1 px-2 py-1 text-[10px] text-ctp-blue transition-colors hover:bg-ctp-surface2"
|
||||||
|
>
|
||||||
|
Open in Anki
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
) : (
|
||||||
|
<div className="text-xs text-ctp-overlay1">No linked note ids recorded.</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -4,6 +4,7 @@ import test from 'node:test';
|
|||||||
import {
|
import {
|
||||||
epochDayToDate,
|
epochDayToDate,
|
||||||
epochMsFromDbTimestamp,
|
epochMsFromDbTimestamp,
|
||||||
|
formatClockDuration,
|
||||||
formatRelativeDate,
|
formatRelativeDate,
|
||||||
formatSessionDayLabel,
|
formatSessionDayLabel,
|
||||||
} from './formatters';
|
} from './formatters';
|
||||||
@@ -153,3 +154,9 @@ test('formatSessionDayLabel includes year for past-year dates', () => {
|
|||||||
assert.notEqual(label, withoutYear);
|
assert.notEqual(label, withoutYear);
|
||||||
}, fixedNow);
|
}, fixedNow);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('formatClockDuration keeps seconds for sub-hour spans', () => {
|
||||||
|
assert.equal(formatClockDuration(42_000), '42s');
|
||||||
|
assert.equal(formatClockDuration(372_000), '6m 12s');
|
||||||
|
assert.equal(formatClockDuration(3_780_000), '1h 3m');
|
||||||
|
});
|
||||||
@@ -74,3 +74,17 @@ export function formatSessionDayLabel(sessionStartedAtMs: number): string {
|
|||||||
...(includeYear ? { year: 'numeric' } : {}),
|
...(includeYear ? { year: 'numeric' } : {}),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Second-precise duration for short spans ("42s", "6m 12s", "1h 3m"). */
|
||||||
|
export function formatClockDuration(ms: number): string {
|
||||||
|
const totalSec = Math.max(0, Math.round(ms / 1000));
|
||||||
|
if (totalSec < 60) return `${totalSec}s`;
|
||||||
|
const totalMin = Math.floor(totalSec / 60);
|
||||||
|
if (totalMin < 60) {
|
||||||
|
const sec = totalSec % 60;
|
||||||
|
return sec > 0 ? `${totalMin}m ${sec}s` : `${totalMin}m`;
|
||||||
|
}
|
||||||
|
const hours = Math.floor(totalMin / 60);
|
||||||
|
const mins = totalMin % 60;
|
||||||
|
return mins > 0 ? `${hours}h ${mins}m` : `${hours}h`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import { buildSessionDensity, pickDensityBucketMs } from './session-density';
|
||||||
|
import { EventType } from '../types/stats';
|
||||||
|
|
||||||
|
function sample(sampleMs: number, linesSeen: number, tokensSeen: number) {
|
||||||
|
return { sampleMs, totalWatchedMs: 0, activeWatchedMs: 0, linesSeen, tokensSeen, cardsMined: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
test('pickDensityBucketMs grows the bucket until the session fits in the bucket budget', () => {
|
||||||
|
assert.equal(pickDensityBucketMs(5 * 60_000), 10_000);
|
||||||
|
assert.equal(pickDensityBucketMs(25 * 60_000), 30_000);
|
||||||
|
assert.equal(pickDensityBucketMs(3 * 3_600_000), 300_000);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('pickDensityBucketMs scales past the largest candidate so very long spans stay in budget', () => {
|
||||||
|
const spanMs = 48 * 3_600_000;
|
||||||
|
const bucketMs = pickDensityBucketMs(spanMs);
|
||||||
|
assert.ok(Math.ceil(spanMs / bucketMs) <= 60);
|
||||||
|
assert.equal(bucketMs % 60_000, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('buildSessionDensity converts cumulative samples into per-bucket known/unknown deltas', () => {
|
||||||
|
const density = buildSessionDensity(
|
||||||
|
[sample(0, 0, 0), sample(5_000, 1, 10), sample(12_000, 2, 25), sample(25_000, 3, 40)],
|
||||||
|
[
|
||||||
|
{ linesSeen: 1, knownWordsSeen: 4, totalWordsSeen: 8 },
|
||||||
|
{ linesSeen: 2, knownWordsSeen: 10, totalWordsSeen: 20 },
|
||||||
|
{ linesSeen: 3, knownWordsSeen: 20, totalWordsSeen: 32 },
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{ eventType: EventType.YOMITAN_LOOKUP, tsMs: 3_000, payload: null },
|
||||||
|
{ eventType: EventType.YOMITAN_LOOKUP, tsMs: 14_000, payload: null },
|
||||||
|
{ eventType: EventType.CARD_MINED, tsMs: 24_000, payload: '{"cardsMined":2}' },
|
||||||
|
{ eventType: EventType.PAUSE_START, tsMs: 24_500, payload: null },
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(density.bucketMs, 10_000);
|
||||||
|
assert.equal(density.hasKnownWords, true);
|
||||||
|
assert.deepEqual(
|
||||||
|
density.buckets.map((b) => [b.knownWords, b.unknownWords, b.lookups, b.cards]),
|
||||||
|
[
|
||||||
|
[4, 4, 1, 0],
|
||||||
|
[6, 6, 1, 0],
|
||||||
|
[10, 2, 0, 2],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('buildSessionDensity falls back to raw token counts without known-word data', () => {
|
||||||
|
const density = buildSessionDensity(
|
||||||
|
[sample(0, 0, 0), sample(9_000, 1, 7), sample(15_000, 2, 12)],
|
||||||
|
[],
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(density.hasKnownWords, false);
|
||||||
|
assert.deepEqual(
|
||||||
|
density.buckets.map((b) => [b.words, b.knownWords]),
|
||||||
|
[
|
||||||
|
[7, 0],
|
||||||
|
[5, 0],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('buildSessionDensity returns no buckets for an empty timeline', () => {
|
||||||
|
assert.deepEqual(buildSessionDensity([], [], []).buckets, []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('buildSessionDensity keeps the final sample when the span ends exactly on a bucket edge', () => {
|
||||||
|
const density = buildSessionDensity([sample(0, 0, 0), sample(300_000, 1, 42)], [], []);
|
||||||
|
|
||||||
|
assert.equal(density.bucketMs, 10_000);
|
||||||
|
assert.equal(density.buckets.length, 30);
|
||||||
|
assert.equal(density.buckets.at(-1)!.words, 42);
|
||||||
|
assert.equal(
|
||||||
|
density.buckets.reduce((sum, b) => sum + b.words, 0),
|
||||||
|
42,
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
import type { KnownWordsTimelinePoint } from '../hooks/useSessions';
|
||||||
|
import { readCardsMinedDelta } from './session-events';
|
||||||
|
import { EventType } from '../types/stats';
|
||||||
|
import type { SessionEvent, SessionTimelinePoint } from '../types/stats';
|
||||||
|
|
||||||
|
/** One time slice of a session: words read, lookups made, and cards mined inside it. */
|
||||||
|
export interface SessionDensityBucket {
|
||||||
|
index: number;
|
||||||
|
startMs: number;
|
||||||
|
endMs: number;
|
||||||
|
words: number;
|
||||||
|
knownWords: number;
|
||||||
|
unknownWords: number;
|
||||||
|
lookups: number;
|
||||||
|
cards: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SessionDensity {
|
||||||
|
bucketMs: number;
|
||||||
|
startMs: number;
|
||||||
|
endMs: number;
|
||||||
|
/** True when the known-words timeline had data, so known/unknown splits are meaningful. */
|
||||||
|
hasKnownWords: boolean;
|
||||||
|
buckets: SessionDensityBucket[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const BUCKET_SIZE_CANDIDATES_MS = [
|
||||||
|
10_000, 15_000, 30_000, 60_000, 120_000, 300_000, 600_000, 1_800_000,
|
||||||
|
];
|
||||||
|
|
||||||
|
export const MAX_DENSITY_BUCKETS = 60;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Smallest candidate bucket size that keeps the whole span within maxBuckets.
|
||||||
|
* Spans too long for every candidate get whole-minute buckets sized to fit.
|
||||||
|
*/
|
||||||
|
export function pickDensityBucketMs(spanMs: number, maxBuckets = MAX_DENSITY_BUCKETS): number {
|
||||||
|
for (const candidate of BUCKET_SIZE_CANDIDATES_MS) {
|
||||||
|
if (Math.ceil(spanMs / candidate) <= maxBuckets) return candidate;
|
||||||
|
}
|
||||||
|
return Math.ceil(spanMs / maxBuckets / 60_000) * 60_000;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatBucketSize(bucketMs: number): string {
|
||||||
|
if (bucketMs < 60_000) return `${Math.round(bucketMs / 1000)}s`;
|
||||||
|
const minutes = Math.round(bucketMs / 60_000);
|
||||||
|
return `${minutes}m`;
|
||||||
|
}
|
||||||
|
|
||||||
|
type KnownWordCounts = { knownWordsSeen: number; totalWordsSeen: number };
|
||||||
|
|
||||||
|
/** Cumulative known/total counts at a lines-seen checkpoint, using the nearest earlier point. */
|
||||||
|
function lookupKnownWordCounts(
|
||||||
|
sortedPoints: KnownWordsTimelinePoint[],
|
||||||
|
linesSeen: number,
|
||||||
|
): KnownWordCounts {
|
||||||
|
let best: KnownWordCounts = { knownWordsSeen: 0, totalWordsSeen: 0 };
|
||||||
|
for (const point of sortedPoints) {
|
||||||
|
if (point.linesSeen > linesSeen) break;
|
||||||
|
best = point;
|
||||||
|
}
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Turn cumulative telemetry samples into per-bucket deltas so the chart shows how
|
||||||
|
* dense the dialogue was over the session instead of a monotonically rising total.
|
||||||
|
*/
|
||||||
|
export function buildSessionDensity(
|
||||||
|
timeline: SessionTimelinePoint[],
|
||||||
|
knownWordsTimeline: KnownWordsTimelinePoint[],
|
||||||
|
events: SessionEvent[],
|
||||||
|
): SessionDensity {
|
||||||
|
const samples = [...timeline].sort((a, b) => a.sampleMs - b.sampleMs);
|
||||||
|
if (samples.length === 0) {
|
||||||
|
return { bucketMs: 0, startMs: 0, endMs: 0, hasKnownWords: false, buckets: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
const knownPoints = [...knownWordsTimeline].sort((a, b) => a.linesSeen - b.linesSeen);
|
||||||
|
const hasKnownWords = knownPoints.length > 0;
|
||||||
|
const startMs = samples[0]!.sampleMs;
|
||||||
|
const lastSampleMs = samples[samples.length - 1]!.sampleMs;
|
||||||
|
const spanMs = Math.max(1, lastSampleMs - startMs);
|
||||||
|
const bucketMs = pickDensityBucketMs(spanMs);
|
||||||
|
const bucketCount = Math.max(1, Math.ceil(spanMs / bucketMs));
|
||||||
|
const endMs = startMs + bucketCount * bucketMs;
|
||||||
|
|
||||||
|
const buckets: SessionDensityBucket[] = [];
|
||||||
|
let sampleIndex = -1;
|
||||||
|
let prevWords = 0;
|
||||||
|
let prevKnown = 0;
|
||||||
|
|
||||||
|
for (let index = 0; index < bucketCount; index += 1) {
|
||||||
|
const bucketStart = startMs + index * bucketMs;
|
||||||
|
const bucketEnd = bucketStart + bucketMs;
|
||||||
|
// The last bucket also owns a sample sitting exactly on the session's end edge.
|
||||||
|
const isLastBucket = index === bucketCount - 1;
|
||||||
|
while (
|
||||||
|
sampleIndex + 1 < samples.length &&
|
||||||
|
(isLastBucket || samples[sampleIndex + 1]!.sampleMs < bucketEnd)
|
||||||
|
) {
|
||||||
|
sampleIndex += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
let cumulativeWords = prevWords;
|
||||||
|
let cumulativeKnown = prevKnown;
|
||||||
|
if (sampleIndex >= 0) {
|
||||||
|
const sample = samples[sampleIndex]!;
|
||||||
|
if (hasKnownWords) {
|
||||||
|
const counts = lookupKnownWordCounts(knownPoints, sample.linesSeen);
|
||||||
|
cumulativeWords = counts.totalWordsSeen;
|
||||||
|
cumulativeKnown = Math.min(counts.knownWordsSeen, counts.totalWordsSeen);
|
||||||
|
} else {
|
||||||
|
cumulativeWords = sample.tokensSeen;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const words = Math.max(0, cumulativeWords - prevWords);
|
||||||
|
const knownWords = Math.min(words, Math.max(0, cumulativeKnown - prevKnown));
|
||||||
|
prevWords = Math.max(prevWords, cumulativeWords);
|
||||||
|
prevKnown = Math.max(prevKnown, cumulativeKnown);
|
||||||
|
|
||||||
|
buckets.push({
|
||||||
|
index,
|
||||||
|
startMs: bucketStart,
|
||||||
|
endMs: bucketEnd,
|
||||||
|
words,
|
||||||
|
knownWords,
|
||||||
|
unknownWords: words - knownWords,
|
||||||
|
lookups: 0,
|
||||||
|
cards: 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const event of events) {
|
||||||
|
if (event.eventType !== EventType.YOMITAN_LOOKUP && event.eventType !== EventType.CARD_MINED) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const rawIndex = Math.floor((event.tsMs - startMs) / bucketMs);
|
||||||
|
const bucket = buckets[Math.max(0, Math.min(bucketCount - 1, rawIndex))]!;
|
||||||
|
if (event.eventType === EventType.YOMITAN_LOOKUP) {
|
||||||
|
bucket.lookups += 1;
|
||||||
|
} else {
|
||||||
|
bucket.cards += readCardsMinedDelta(event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { bucketMs, startMs, endMs, hasKnownWords, buckets };
|
||||||
|
}
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
import assert from 'node:assert/strict';
|
|
||||||
import test from 'node:test';
|
|
||||||
import {
|
|
||||||
buildKnownWordsRatioChartData,
|
|
||||||
getKnownPctAxisMax,
|
|
||||||
} from '../components/sessions/SessionDetail';
|
|
||||||
import { buildSessionChartEvents } from './session-events';
|
|
||||||
import { EventType } from '../types/stats';
|
|
||||||
|
|
||||||
test('buildSessionChartEvents keeps only chart-relevant events and pairs pause ranges', () => {
|
|
||||||
const chartEvents = buildSessionChartEvents([
|
|
||||||
{ eventType: EventType.SUBTITLE_LINE, tsMs: 1_000, payload: '{"line":"ignored"}' },
|
|
||||||
{ eventType: EventType.PAUSE_START, tsMs: 2_000, payload: null },
|
|
||||||
{ eventType: EventType.SEEK_FORWARD, tsMs: 3_000, payload: null },
|
|
||||||
{ eventType: EventType.PAUSE_END, tsMs: 4_000, payload: null },
|
|
||||||
{ eventType: EventType.CARD_MINED, tsMs: 5_000, payload: '{"cardsMined":1}' },
|
|
||||||
{ eventType: EventType.YOMITAN_LOOKUP, tsMs: 6_000, payload: null },
|
|
||||||
{ eventType: EventType.SEEK_BACKWARD, tsMs: 7_000, payload: null },
|
|
||||||
{ eventType: EventType.LOOKUP, tsMs: 8_000, payload: '{"hit":true}' },
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Seek events are intentionally dropped from the chart — they were too noisy.
|
|
||||||
assert.deepEqual(
|
|
||||||
chartEvents.markers.filter((marker) => marker.kind !== 'pause' && marker.kind !== 'card'),
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
assert.deepEqual(
|
|
||||||
chartEvents.cardEvents.map((event) => event.tsMs),
|
|
||||||
[5_000],
|
|
||||||
);
|
|
||||||
assert.deepEqual(
|
|
||||||
chartEvents.yomitanLookupEvents.map((event) => event.tsMs),
|
|
||||||
[6_000],
|
|
||||||
);
|
|
||||||
assert.deepEqual(chartEvents.pauseRegions, [{ startMs: 2_000, endMs: 4_000 }]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('getKnownPctAxisMax adds headroom above the highest known percentage', () => {
|
|
||||||
assert.equal(getKnownPctAxisMax([22.4, 31.2, 29.8]), 40);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('getKnownPctAxisMax caps the chart top at 100%', () => {
|
|
||||||
assert.equal(getKnownPctAxisMax([97.1, 98.6]), 100);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('buildKnownWordsRatioChartData uses filtered known-word timeline totals', () => {
|
|
||||||
const chartData = buildKnownWordsRatioChartData(
|
|
||||||
[
|
|
||||||
{ sampleMs: 1_000, linesSeen: 1, tokensSeen: 10 },
|
|
||||||
{ sampleMs: 2_000, linesSeen: 2, tokensSeen: 20 },
|
|
||||||
],
|
|
||||||
new Map([
|
|
||||||
[1, { knownWordsSeen: 2, totalWordsSeen: 3 }],
|
|
||||||
[2, { knownWordsSeen: 3, totalWordsSeen: 4 }],
|
|
||||||
]),
|
|
||||||
);
|
|
||||||
|
|
||||||
assert.deepEqual(chartData, [
|
|
||||||
{ tsMs: 1_000, knownWords: 2, unknownWords: 1, totalWords: 3 },
|
|
||||||
{ tsMs: 2_000, knownWords: 3, unknownWords: 1, totalWords: 4 },
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
@@ -29,20 +29,13 @@ test('buildSessionChartEvents produces typed hover markers with parsed payload m
|
|||||||
{ eventType: EventType.YOMITAN_LOOKUP, tsMs: 7_000, payload: null },
|
{ eventType: EventType.YOMITAN_LOOKUP, tsMs: 7_000, payload: null },
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Seek events are intentionally dropped — too noisy on the session chart.
|
// Pause and seek events are intentionally dropped — too noisy on the session chart.
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
chartEvents.markers.map((marker) => marker.kind),
|
chartEvents.markers.map((marker) => marker.kind),
|
||||||
['pause', 'card'],
|
['card'],
|
||||||
);
|
);
|
||||||
|
|
||||||
const pauseMarker = chartEvents.markers[0]!;
|
const cardMarker = chartEvents.markers[0]!;
|
||||||
assert.equal(pauseMarker.kind, 'pause');
|
|
||||||
assert.equal(pauseMarker.startMs, 2_000);
|
|
||||||
assert.equal(pauseMarker.endMs, 5_000);
|
|
||||||
assert.equal(pauseMarker.durationMs, 3_000);
|
|
||||||
assert.equal(pauseMarker.anchorTsMs, 3_500);
|
|
||||||
|
|
||||||
const cardMarker = chartEvents.markers[1]!;
|
|
||||||
assert.equal(cardMarker.kind, 'card');
|
assert.equal(cardMarker.kind, 'card');
|
||||||
assert.deepEqual(cardMarker.noteIds, [11, 22]);
|
assert.deepEqual(cardMarker.noteIds, [11, 22]);
|
||||||
assert.equal(cardMarker.cardsDelta, 2);
|
assert.equal(cardMarker.cardsDelta, 2);
|
||||||
|
|||||||
@@ -1,21 +1,10 @@
|
|||||||
import { EventType, type SessionEvent } from '../types/stats';
|
import { EventType, type SessionEvent } from '../types/stats';
|
||||||
|
|
||||||
export const SESSION_CHART_EVENT_TYPES = [
|
export const SESSION_CHART_EVENT_TYPES = [EventType.CARD_MINED, EventType.YOMITAN_LOOKUP] as const;
|
||||||
EventType.CARD_MINED,
|
|
||||||
EventType.PAUSE_START,
|
|
||||||
EventType.PAUSE_END,
|
|
||||||
EventType.YOMITAN_LOOKUP,
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
export interface PauseRegion {
|
|
||||||
startMs: number;
|
|
||||||
endMs: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SessionChartEvents {
|
export interface SessionChartEvents {
|
||||||
cardEvents: SessionEvent[];
|
cardEvents: SessionEvent[];
|
||||||
yomitanLookupEvents: SessionEvent[];
|
yomitanLookupEvents: SessionEvent[];
|
||||||
pauseRegions: PauseRegion[];
|
|
||||||
markers: SessionChartMarker[];
|
markers: SessionChartMarker[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -45,24 +34,15 @@ interface SessionEventNoteRecord {
|
|||||||
fields?: Record<string, SessionEventNoteField> | null;
|
fields?: Record<string, SessionEventNoteField> | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type SessionChartMarker =
|
/** Interactive card-mined marker shown on the session event lane. */
|
||||||
| {
|
export interface SessionChartMarker {
|
||||||
key: string;
|
key: string;
|
||||||
kind: 'pause';
|
kind: 'card';
|
||||||
anchorTsMs: number;
|
anchorTsMs: number;
|
||||||
eventTsMs: number;
|
eventTsMs: number;
|
||||||
startMs: number;
|
noteIds: number[];
|
||||||
endMs: number;
|
cardsDelta: number;
|
||||||
durationMs: number;
|
}
|
||||||
}
|
|
||||||
| {
|
|
||||||
key: string;
|
|
||||||
kind: 'card';
|
|
||||||
anchorTsMs: number;
|
|
||||||
eventTsMs: number;
|
|
||||||
noteIds: number[];
|
|
||||||
cardsDelta: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
function parsePayload(payload: string | null): Record<string, unknown> | null {
|
function parsePayload(payload: string | null): Record<string, unknown> | null {
|
||||||
if (!payload) return null;
|
if (!payload) return null;
|
||||||
@@ -85,6 +65,11 @@ function readNoteIds(value: unknown): number[] {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Cards added by one CARD_MINED event; older events without a count mean one card. */
|
||||||
|
export function readCardsMinedDelta(event: SessionEvent): number {
|
||||||
|
return readNumberField(parsePayload(event.payload)?.cardsMined) ?? 1;
|
||||||
|
}
|
||||||
|
|
||||||
function stripHtml(value: string): string {
|
function stripHtml(value: string): string {
|
||||||
return value
|
return value
|
||||||
.replace(/\[sound:[^\]]+\]/gi, ' ')
|
.replace(/\[sound:[^\]]+\]/gi, ' ')
|
||||||
@@ -257,11 +242,6 @@ export function togglePinnedSessionMarkerKey(
|
|||||||
return currentPinnedMarkerKey === nextMarkerKey ? null : nextMarkerKey;
|
return currentPinnedMarkerKey === nextMarkerKey ? null : nextMarkerKey;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatEventSeconds(ms: number | null): string | null {
|
|
||||||
if (ms == null || !Number.isFinite(ms)) return null;
|
|
||||||
return `${(ms / 1000).toFixed(1)}s`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function projectSessionMarkerLeftPx({
|
export function projectSessionMarkerLeftPx({
|
||||||
anchorTsMs,
|
anchorTsMs,
|
||||||
tsMin,
|
tsMin,
|
||||||
@@ -284,71 +264,32 @@ export function projectSessionMarkerLeftPx({
|
|||||||
export function buildSessionChartEvents(events: SessionEvent[]): SessionChartEvents {
|
export function buildSessionChartEvents(events: SessionEvent[]): SessionChartEvents {
|
||||||
const cardEvents: SessionEvent[] = [];
|
const cardEvents: SessionEvent[] = [];
|
||||||
const yomitanLookupEvents: SessionEvent[] = [];
|
const yomitanLookupEvents: SessionEvent[] = [];
|
||||||
const pauseRegions: PauseRegion[] = [];
|
|
||||||
const markers: SessionChartMarker[] = [];
|
const markers: SessionChartMarker[] = [];
|
||||||
let pendingPauseStartMs: number | null = null;
|
|
||||||
|
|
||||||
for (const event of events) {
|
for (const event of events) {
|
||||||
switch (event.eventType) {
|
switch (event.eventType) {
|
||||||
case EventType.CARD_MINED:
|
case EventType.CARD_MINED: {
|
||||||
cardEvents.push(event);
|
cardEvents.push(event);
|
||||||
{
|
const payload = parsePayload(event.payload);
|
||||||
const payload = parsePayload(event.payload);
|
markers.push({
|
||||||
markers.push({
|
key: `card-${event.tsMs}`,
|
||||||
key: `card-${event.tsMs}`,
|
kind: 'card',
|
||||||
kind: 'card',
|
anchorTsMs: event.tsMs,
|
||||||
anchorTsMs: event.tsMs,
|
eventTsMs: event.tsMs,
|
||||||
eventTsMs: event.tsMs,
|
noteIds: readNoteIds(payload?.noteIds),
|
||||||
noteIds: readNoteIds(payload?.noteIds),
|
cardsDelta: readCardsMinedDelta(event),
|
||||||
cardsDelta: readNumberField(payload?.cardsMined) ?? 1,
|
});
|
||||||
});
|
|
||||||
}
|
|
||||||
break;
|
break;
|
||||||
|
}
|
||||||
case EventType.YOMITAN_LOOKUP:
|
case EventType.YOMITAN_LOOKUP:
|
||||||
yomitanLookupEvents.push(event);
|
yomitanLookupEvents.push(event);
|
||||||
break;
|
break;
|
||||||
case EventType.PAUSE_START:
|
|
||||||
pendingPauseStartMs = event.tsMs;
|
|
||||||
break;
|
|
||||||
case EventType.PAUSE_END:
|
|
||||||
if (pendingPauseStartMs !== null) {
|
|
||||||
pauseRegions.push({ startMs: pendingPauseStartMs, endMs: event.tsMs });
|
|
||||||
markers.push({
|
|
||||||
key: `pause-${pendingPauseStartMs}-${event.tsMs}`,
|
|
||||||
kind: 'pause',
|
|
||||||
anchorTsMs: pendingPauseStartMs + Math.round((event.tsMs - pendingPauseStartMs) / 2),
|
|
||||||
eventTsMs: pendingPauseStartMs,
|
|
||||||
startMs: pendingPauseStartMs,
|
|
||||||
endMs: event.tsMs,
|
|
||||||
durationMs: Math.max(0, event.tsMs - pendingPauseStartMs),
|
|
||||||
});
|
|
||||||
pendingPauseStartMs = null;
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
default:
|
default:
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (pendingPauseStartMs !== null) {
|
|
||||||
pauseRegions.push({ startMs: pendingPauseStartMs, endMs: pendingPauseStartMs + 2_000 });
|
|
||||||
markers.push({
|
|
||||||
key: `pause-${pendingPauseStartMs}-${pendingPauseStartMs + 2_000}`,
|
|
||||||
kind: 'pause',
|
|
||||||
anchorTsMs: pendingPauseStartMs + 1_000,
|
|
||||||
eventTsMs: pendingPauseStartMs,
|
|
||||||
startMs: pendingPauseStartMs,
|
|
||||||
endMs: pendingPauseStartMs + 2_000,
|
|
||||||
durationMs: 2_000,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
markers.sort((left, right) => left.anchorTsMs - right.anchorTsMs);
|
markers.sort((left, right) => left.anchorTsMs - right.anchorTsMs);
|
||||||
|
|
||||||
return {
|
return { cardEvents, yomitanLookupEvents, markers };
|
||||||
cardEvents,
|
|
||||||
yomitanLookupEvents,
|
|
||||||
pauseRegions,
|
|
||||||
markers,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user