This commit is contained in:
Zhou Hua 2025-02-11 18:39:01 +08:00
parent 3b7e927bf0
commit 0d64c0d64f
13 changed files with 6888 additions and 0 deletions

15
.github/FUNDING.yml vendored Normal file
View file

@ -0,0 +1,15 @@
# These are supported funding model platforms
github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
patreon: # Replace with a single Patreon username
open_collective: # Replace with a single Open Collective username
ko_fi: zhouhua
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
liberapay: # Replace with a single Liberapay username
issuehunt: # Replace with a single IssueHunt username
lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
polar: # Replace with a single Polar username
buy_me_a_coffee: # Replace with a single Buy Me a Coffee username
thanks_dev: # Replace with a single thanks.dev username
custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']

40
.github/workflows/release.yml vendored Normal file
View file

@ -0,0 +1,40 @@
name: Release Obsidian plugin
on:
push:
tags:
- "*"
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v3
with:
submodules: recursive
- name: Use Node.js
uses: actions/setup-node@v3
with:
node-version: "20.x"
- name: setup pnpm
uses: pnpm/action-setup@v3
with:
version: latest
run_install: true
- name: Build plugin
run: pnpm build
- name: Create release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
tag="${GITHUB_REF#refs/tags/}"
gh release create "$tag" \
--title="$tag" \
main.js manifest.json styles.css

44
.gitignore vendored Normal file
View file

@ -0,0 +1,44 @@
# Dependencies
node_modules
.pnp
.pnp.js
# Testing
coverage
# Production
build
dist
storybook-static
# Misc
.DS_Store
.env.local
.env.development.local
.env.test.local
.env.production.local
.env
# Logs
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Editor directories and files
.idea
.vscode
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
# TypeScript
*.tsbuildinfo
# Cache
.cache
.parcel-cache
main.js
styles.css

40
README.md Normal file
View file

@ -0,0 +1,40 @@
# Obsidian Audio Beautifier
A plugin that enhances the display of audio files in Obsidian, supporting both audio attachments and recorded audio files.
## Features
- Beautiful audio player interface
- Support for audio attachments (MP3, WAV, etc.)
- Support for Obsidian recorded audio
## Preview
### Before
![Before](./screenshots/before.png)
### After
![After](./screenshots/after.png)
## Installation
### From Obsidian Community Plugins
1. Open Obsidian Settings
2. Go to `Community Plugins`
3. Disable `Safe Mode`
4. Search for "Audio Beautifier"
5. Click Install
6. Enable the plugin
## Usage
Once installed, the plugin automatically enhances the appearance of all audio files in your vault. No additional configuration needed.
## My Other Obsidian Plugins
* [Export Image](https://github.com/zhouhua/obsidian-export-image)
* [Markdown Media Card](https://github.com/zhouhua/obsidian-markdown-media-card)
* [vConsole](https://github.com/zhouhua/obsidian-vconsole)
* [POWER MODE](https://github.com/zhouhua/obsidian-power-mode)
* [PopKit](https://github.com/zhouhua/obsidian-popkit)
* [Another Sticky Headings](https://github.com/zhouhua/obsidian-sticky-headings)

61
esbuild.config.mjs Normal file
View file

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

3
eslint.config.js Normal file
View file

@ -0,0 +1,3 @@
import baseConfig from '@waveform/inf/eslint';
export default baseConfig;

70
main.css Normal file
View file

@ -0,0 +1,70 @@
@import '@waveform-audio/player/index.css';
/* 基础按钮样式重置 */
.wa-player button {
box-shadow: none;
width: auto;
height: auto;
background-color: transparent;
padding: 0;
}
/* 隐藏默认音频元素 */
.internal-embed audio {
display: none;
}
/* 播放器容器样式 */
.waveform-player-widget {
border-radius: 8px;
overflow: hidden;
display: block;
width: 100%;
background: var(--background-secondary);
margin: 0.5em 0;
}
.waveform-player-widget-container {
position: relative;
width: 100%;
display: block;
transition: all 0.3s ease;
margin: 0.5em 0;
}
/* 实时预览模式样式 */
.markdown-source-view.mod-cm6.is-live-preview .cm-content .waveform-player-widget-container {
display: block !important;
visibility: visible !important;
position: relative !important;
pointer-events: auto !important;
user-select: auto !important;
z-index: 1;
}
/* 实时预览模式下的嵌套样式 */
.markdown-source-view.mod-cm6.is-live-preview .cm-line .waveform-player-widget-container {
margin: 0.5em 0 !important;
padding-left: 0.5em !important;
border-left: 2px solid var(--interactive-accent);
}
/* 确保播放器在实时预览模式下可以交互 */
.markdown-source-view.mod-cm6.is-live-preview .waveform-player-widget {
pointer-events: auto !important;
user-select: auto !important;
position: relative !important;
z-index: 2;
}
/* 优化间距 */
.waveform-player-widget-container + .waveform-player-widget-container {
margin-top: 1em !important;
}
/* 统一播放器样式 */
.wa-obsidian-player {
background: var(--background-secondary);
border-radius: 8px;
padding: 0.5em;
}

6225
pnpm-lock.yaml Normal file

File diff suppressed because it is too large Load diff

BIN
screenshots/after.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

BIN
screenshots/before.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.2 KiB

337
src/main.ts Normal file
View file

@ -0,0 +1,337 @@
import type { EditorState, Extension } from '@codemirror/state';
import type { DecorationSet, ViewUpdate } from '@codemirror/view';
import type { ComponentType } from 'react';
import { StateEffect, StateField } from '@codemirror/state';
import { Decoration, EditorView, ViewPlugin, WidgetType } from '@codemirror/view';
import WaveformPlayer from '@waveform-audio/player';
import { Plugin, TFile } from 'obsidian';
import { createElement } from 'react';
import { createRoot } from 'react-dom/client';
const PLAYER_CLASS = 'waveform-player-widget';
const AUDIO_LINK_PATTERN = '!\\[([^\\]]*)\\]\\(([^)]+\\.(?:mp3|wav|ogg|m4a|webm))\\)';
class AudioPlayerWidget extends WidgetType {
private static counter = 0;
private container: HTMLElement | null = null;
private readonly id: string;
private mounted = false;
private playerDiv: HTMLElement | null = null;
private root: null | ReturnType<typeof createRoot> = null;
constructor(
private readonly src: string,
private readonly title: string,
private readonly plugin: WaveformPlayerPlugin,
) {
super();
this.id = `audio-player-${AudioPlayerWidget.counter++}`;
}
destroy() {
this.unmount();
this.container = null;
this.playerDiv = null;
}
eq(other: AudioPlayerWidget): boolean {
return (
other instanceof AudioPlayerWidget &&
this.src === other.src &&
this.title === other.title
);
}
toDOM() {
if (this.container) {
this.unmount();
}
const container = document.createElement('div');
container.className = `${PLAYER_CLASS}-container`;
container.dataset.playerId = this.id;
const playerDiv = document.createElement('div');
playerDiv.className = PLAYER_CLASS;
container.appendChild(playerDiv);
this.container = container;
this.playerDiv = playerDiv;
// 使用 requestIdleCallback 延迟挂载,优化性能
if (window.requestIdleCallback) {
window.requestIdleCallback(() => this.mount());
}
else {
setTimeout(() => this.mount(), 0);
}
return container;
}
private mount() {
if (!this.playerDiv || !this.container || this.mounted) {
return;
}
const audioFile = this.plugin.getAudioFile(this.src);
if (!audioFile) {
console.warn('[AudioPlayerWidget] Audio file not found:', this.src);
return;
}
const audioUrl = this.plugin.app.vault.getResourcePath(audioFile);
console.log("audioUrl", audioUrl);
const decodedUrl = decodeURIComponent(audioUrl);
try {
this.root = createRoot(this.playerDiv);
this.root.render(
createElement(WaveformPlayer as ComponentType<any>, {
className: 'wa-obsidian-player',
key: this.id,
samplePoints: 200,
src: decodedUrl,
styles: {
controls: {
paddingBottom: 0,
width: '156px',
},
root: {
padding: '0.5em',
},
title: {
fontSize: '14px',
margin: 0,
},
waveform: {
height: '100px',
},
},
title: this.title || audioFile.basename,
type: 'mirror',
}),
);
this.mounted = true;
}
catch (error) {
console.error('[AudioPlayerWidget] Failed to mount player:', error);
}
}
private unmount() {
if (this.root) {
try {
this.root.unmount();
}
catch (error) {
console.error('[AudioPlayerWidget] Failed to unmount player:', error);
}
this.root = null;
}
if (this.playerDiv) {
this.playerDiv.innerHTML = '';
}
this.mounted = false;
}
}
export default class WaveformPlayerPlugin extends Plugin {
createEditorExtension(): Extension {
const updateAudioPlayers = StateEffect.define<void>();
const audioPlayerField = StateField.define<DecorationSet>({
create: (state) => {
return this.buildDecorations(state);
},
provide: field => EditorView.decorations.from(field),
update: (decorations, tr) => {
if (!tr.docChanged && !tr.effects.some(e => e.is(updateAudioPlayers))) {
return decorations;
}
const changes = tr.changes;
let needsUpdate = false;
if (tr.docChanged) {
const regex = new RegExp(AUDIO_LINK_PATTERN);
/* @ts-ignore */
changes.iterChanges((fromA, toA, fromB, toB, inserted) => {
const insertedText = inserted.toString();
const hasAudioLink = regex.test(insertedText);
if (hasAudioLink) {
needsUpdate = true;
}
});
}
if (!needsUpdate && !tr.effects.some(e => e.is(updateAudioPlayers))) {
return decorations;
}
return this.buildDecorations(tr.state);
},
});
const viewportPlugin = ViewPlugin.fromClass(class {
private updateScheduled = false;
private timeout: NodeJS.Timeout | null = null;
constructor(private readonly view: EditorView) {
this.scheduleUpdate();
}
destroy() {
if (this.timeout) {
clearTimeout(this.timeout);
}
}
update(update: ViewUpdate) {
if (update.viewportChanged) {
this.scheduleUpdate();
}
}
private scheduleUpdate() {
if (this.updateScheduled) {
return;
}
this.updateScheduled = true;
if (this.timeout) {
clearTimeout(this.timeout);
}
this.timeout = setTimeout(() => {
this.updateScheduled = false;
this.view.dispatch({
effects: updateAudioPlayers.of(undefined),
});
}, 200);
}
});
return [audioPlayerField, viewportPlugin];
}
getAudioFile(src: string): null | TFile {
const decodedSrc = decodeURIComponent(src);
let audioFile = this.app.vault.getAbstractFileByPath(decodedSrc);
if (!audioFile || !(audioFile instanceof TFile)) {
const attachPath = this.app.vault.config.attachmentFolderPath;
if (!attachPath) {
return null;
}
audioFile = this.app.vault.getAbstractFileByPath(`${attachPath}/${decodedSrc}`);
if (!audioFile || !(audioFile instanceof TFile)) {
audioFile = this.app.vault.getAbstractFileByPath(`${attachPath}/${src}`);
if (!audioFile || !(audioFile instanceof TFile)) {
return null;
}
}
}
return audioFile instanceof TFile ? audioFile : null;
}
async onload() {
// 注册编辑器扩展
this.registerEditorExtension(this.createEditorExtension());
// 注册 Markdown 后处理器(用于阅读视图)
this.registerMarkdownPostProcessor((element) => {
const audioElements = element.querySelectorAll('.internal-embed');
audioElements.forEach((div) => {
const src = div.getAttribute('src');
if (!src || !/\.(?:mp3|wav|ogg|m4a|webm)$/i.test(src)) {
return;
}
const audioFile = this.getAudioFile(src);
if (!audioFile) {
return;
}
const audioUrl = this.app.vault.getResourcePath(audioFile);
const decodedUrl = decodeURIComponent(audioUrl);
const container = document.createElement('div');
container.className = `${PLAYER_CLASS}-container`;
const playerDiv = document.createElement('div');
playerDiv.className = PLAYER_CLASS;
container.appendChild(playerDiv);
// 在原有元素后面插入播放器
div.parentNode?.insertBefore(container, div.nextSibling);
const root = createRoot(playerDiv);
root.render(
createElement(WaveformPlayer as ComponentType<any>, {
className: 'wa-obsidian-player',
samplePoints: 200,
src: decodedUrl,
styles: {
controls: {
paddingBottom: 0,
width: '156px',
},
root: {
padding: '0.5em',
},
title: {
fontSize: '14px',
margin: 0,
},
waveform: {
height: '100px',
},
},
type: 'mirror',
}),
);
});
});
}
onunload() {
}
private buildDecorations(state: EditorState): DecorationSet {
const widgets: any[] = [];
const doc = state.doc;
// 遍历所有行,注意 CodeMirror 6 中行号从 1 开始
for (let lineNo = 1; lineNo <= doc.lines; lineNo++) {
const line = doc.line(lineNo);
const lineText = line.text;
// 每次创建新的正则表达式实例
const regex = new RegExp(AUDIO_LINK_PATTERN, 'gi');
// 使用 matchAll 替代 while 循环
Array.from(lineText.matchAll(regex)).forEach((match) => {
const [, title, src] = match;
if (!src) {
return;
}
const matchStart = line.from + match.index!;
const matchEnd = matchStart + match[0].length;
// 添加播放器装饰器,直接在链接后面插入
widgets.push(
Decoration.widget({
block: true, // 添加 block 属性
persistent: true,
side: 1, // 在匹配文本后面插入
widget: new AudioPlayerWidget(src, title || '', this),
}).range(matchEnd),
);
});
}
return Decoration.set(widgets, true);
}
}

35
tsconfig.json Normal file
View file

@ -0,0 +1,35 @@
{
"extends": "@waveform-audio/inf/tsconfig",
"compilerOptions": {
"baseUrl": ".",
"inlineSourceMap": true,
"inlineSources": true,
"module": "ESNext",
"target": "ES6",
"allowJs": true,
"noImplicitAny": true,
"moduleResolution": "node",
"importHelpers": true,
"isolatedModules": true,
"strictNullChecks": true,
"allowSyntheticDefaultImports": true,
"esModuleInterop": true,
"skipLibCheck": true,
"lib": [
"DOM",
"ES5",
"ES6",
"ES7",
"ES2021"
],
"jsx": "react-jsx",
"jsxImportSource": "react",
"types": [
"obsidian-typings"
]
},
"include": [
"src/**/*.ts",
"src/**/*.tsx"
]
}

18
version-bump.mjs Normal file
View file

@ -0,0 +1,18 @@
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
import { readFileSync, writeFileSync } from 'fs';
import process from 'process';
const targetVersion = process.env.npm_package_version;
// read minAppVersion from manifest.json and bump version to target version
let manifest = JSON.parse(readFileSync('manifest.json', 'utf8'));
const { minAppVersion } = manifest;
manifest.version = targetVersion;
writeFileSync('manifest.json', JSON.stringify(manifest, null, '\t'));
// update versions.json with target version and minAppVersion from manifest.json
let versions = JSON.parse(readFileSync('versions.json', 'utf8'));
versions[targetVersion] = minAppVersion;
writeFileSync('versions.json', JSON.stringify(versions, null, '\t'));