mirror of
https://github.com/zhouhua/obsidian-waveform-player.git
synced 2026-07-22 05:41:57 +00:00
Compare commits
7 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ff1f62c274 | ||
|
|
df6dca652f | ||
|
|
e8a1b4e0af | ||
|
|
36a90e695f | ||
|
|
ee5b62d009 | ||
|
|
3e7c5bb415 | ||
|
|
958b0e3e44 |
16 changed files with 1266 additions and 716 deletions
5
.typesafe-i18n.json
Normal file
5
.typesafe-i18n.json
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
{
|
||||
"outputPath": "./i18n/",
|
||||
"adapters": [],
|
||||
"$schema": "https://unpkg.com/typesafe-i18n@5.26.2/schema/typesafe-i18n.json"
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
# Obsidian Waveform Audio Player
|
||||
# Waveform Audio Player Plugin
|
||||
|
||||
A plugin that enhances the display of audio files in Obsidian, supporting both audio attachments and recorded audio files.
|
||||
|
||||
|
|
|
|||
35
i18n/en/index.ts
Normal file
35
i18n/en/index.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
import type { BaseTranslation } from '../i18n-types';
|
||||
|
||||
const en: BaseTranslation = {
|
||||
settings: {
|
||||
stopOtherPlayers: {
|
||||
title: 'Stop other players when playing',
|
||||
description: 'Stop other players when playing a new audio file',
|
||||
},
|
||||
waveformType: {
|
||||
title: 'Waveform Type',
|
||||
description: 'Choose the display style of the audio waveform',
|
||||
options: {
|
||||
bars: 'Bars',
|
||||
envelope: 'Envelope',
|
||||
line: 'Line',
|
||||
mirror: 'Mirror',
|
||||
wave: 'Wave'
|
||||
}
|
||||
},
|
||||
samplePoints: {
|
||||
title: 'Sample points',
|
||||
description: 'Set the number of sample points for the waveform. The higher the number, the more detailed the waveform, but the performance consumption is also higher.',
|
||||
options: {
|
||||
'50': '50 (Lowest)',
|
||||
'100': '100 (Low)',
|
||||
'200': '200 (Default)',
|
||||
'500': '500 (Medium)',
|
||||
'1000': '1000 (High)',
|
||||
'2000': '2000 (Very High)',
|
||||
'5000': '5000 (Extreme)'
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
export default en;
|
||||
10
i18n/formatters.ts
Normal file
10
i18n/formatters.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import type { FormattersInitializer } from 'typesafe-i18n';
|
||||
import type { Locales, Formatters } from './i18n-types';
|
||||
|
||||
export const initFormatters: FormattersInitializer<Locales, Formatters> = (_locale: Locales) => {
|
||||
const formatters: Formatters = {
|
||||
// add your formatter functions here
|
||||
};
|
||||
|
||||
return formatters;
|
||||
};
|
||||
190
i18n/i18n-types.ts
Normal file
190
i18n/i18n-types.ts
Normal file
|
|
@ -0,0 +1,190 @@
|
|||
// This file was auto-generated by 'typesafe-i18n'. Any manual changes will be overwritten.
|
||||
/* eslint-disable */
|
||||
import type { BaseTranslation as BaseTranslationType, LocalizedString } from 'typesafe-i18n'
|
||||
|
||||
export type BaseTranslation = BaseTranslationType
|
||||
export type BaseLocale = 'en'
|
||||
|
||||
export type Locales =
|
||||
| 'en'
|
||||
| 'zh'
|
||||
|
||||
export type Translation = RootTranslation
|
||||
|
||||
export type Translations = RootTranslation
|
||||
|
||||
type RootTranslation = {
|
||||
settings: {
|
||||
stopOtherPlayers: {
|
||||
/**
|
||||
* Stop other players when playing
|
||||
*/
|
||||
title: string
|
||||
/**
|
||||
* Stop other players when playing a new audio file
|
||||
*/
|
||||
description: string
|
||||
}
|
||||
waveformType: {
|
||||
/**
|
||||
* Waveform Type
|
||||
*/
|
||||
title: string
|
||||
/**
|
||||
* Choose the display style of the audio waveform
|
||||
*/
|
||||
description: string
|
||||
options: {
|
||||
/**
|
||||
* Bars
|
||||
*/
|
||||
bars: string
|
||||
/**
|
||||
* Envelope
|
||||
*/
|
||||
envelope: string
|
||||
/**
|
||||
* Line
|
||||
*/
|
||||
line: string
|
||||
/**
|
||||
* Mirror
|
||||
*/
|
||||
mirror: string
|
||||
/**
|
||||
* Wave
|
||||
*/
|
||||
wave: string
|
||||
}
|
||||
}
|
||||
samplePoints: {
|
||||
/**
|
||||
* Sample points
|
||||
*/
|
||||
title: string
|
||||
/**
|
||||
* Set the number of sample points for the waveform. The higher the number, the more detailed the waveform, but the performance consumption is also higher.
|
||||
*/
|
||||
description: string
|
||||
options: {
|
||||
/**
|
||||
* 50 (Lowest)
|
||||
*/
|
||||
'50': string
|
||||
/**
|
||||
* 100 (Low)
|
||||
*/
|
||||
'100': string
|
||||
/**
|
||||
* 200 (Default)
|
||||
*/
|
||||
'200': string
|
||||
/**
|
||||
* 500 (Medium)
|
||||
*/
|
||||
'500': string
|
||||
/**
|
||||
* 1000 (High)
|
||||
*/
|
||||
'1000': string
|
||||
/**
|
||||
* 2000 (Very High)
|
||||
*/
|
||||
'2000': string
|
||||
/**
|
||||
* 5000 (Extreme)
|
||||
*/
|
||||
'5000': string
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export type TranslationFunctions = {
|
||||
settings: {
|
||||
stopOtherPlayers: {
|
||||
/**
|
||||
* Stop other players when playing
|
||||
*/
|
||||
title: () => LocalizedString
|
||||
/**
|
||||
* Stop other players when playing a new audio file
|
||||
*/
|
||||
description: () => LocalizedString
|
||||
}
|
||||
waveformType: {
|
||||
/**
|
||||
* Waveform Type
|
||||
*/
|
||||
title: () => LocalizedString
|
||||
/**
|
||||
* Choose the display style of the audio waveform
|
||||
*/
|
||||
description: () => LocalizedString
|
||||
options: {
|
||||
/**
|
||||
* Bars
|
||||
*/
|
||||
bars: () => LocalizedString
|
||||
/**
|
||||
* Envelope
|
||||
*/
|
||||
envelope: () => LocalizedString
|
||||
/**
|
||||
* Line
|
||||
*/
|
||||
line: () => LocalizedString
|
||||
/**
|
||||
* Mirror
|
||||
*/
|
||||
mirror: () => LocalizedString
|
||||
/**
|
||||
* Wave
|
||||
*/
|
||||
wave: () => LocalizedString
|
||||
}
|
||||
}
|
||||
samplePoints: {
|
||||
/**
|
||||
* Sample points
|
||||
*/
|
||||
title: () => LocalizedString
|
||||
/**
|
||||
* Set the number of sample points for the waveform. The higher the number, the more detailed the waveform, but the performance consumption is also higher.
|
||||
*/
|
||||
description: () => LocalizedString
|
||||
options: {
|
||||
/**
|
||||
* 50 (Lowest)
|
||||
*/
|
||||
'50': () => LocalizedString
|
||||
/**
|
||||
* 100 (Low)
|
||||
*/
|
||||
'100': () => LocalizedString
|
||||
/**
|
||||
* 200 (Default)
|
||||
*/
|
||||
'200': () => LocalizedString
|
||||
/**
|
||||
* 500 (Medium)
|
||||
*/
|
||||
'500': () => LocalizedString
|
||||
/**
|
||||
* 1000 (High)
|
||||
*/
|
||||
'1000': () => LocalizedString
|
||||
/**
|
||||
* 2000 (Very High)
|
||||
*/
|
||||
'2000': () => LocalizedString
|
||||
/**
|
||||
* 5000 (Extreme)
|
||||
*/
|
||||
'5000': () => LocalizedString
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export type Formatters = {}
|
||||
27
i18n/i18n-util.async.ts
Normal file
27
i18n/i18n-util.async.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
// This file was auto-generated by 'typesafe-i18n'. Any manual changes will be overwritten.
|
||||
/* eslint-disable */
|
||||
|
||||
import { initFormatters } from './formatters'
|
||||
import type { Locales, Translations } from './i18n-types'
|
||||
import { loadedFormatters, loadedLocales, locales } from './i18n-util'
|
||||
|
||||
const localeTranslationLoaders = {
|
||||
en: () => import('./en'),
|
||||
zh: () => import('./zh'),
|
||||
}
|
||||
|
||||
const updateDictionary = (locale: Locales, dictionary: Partial<Translations>): Translations =>
|
||||
loadedLocales[locale] = { ...loadedLocales[locale], ...dictionary }
|
||||
|
||||
export const importLocaleAsync = async (locale: Locales): Promise<Translations> =>
|
||||
(await localeTranslationLoaders[locale]()).default as unknown as Translations
|
||||
|
||||
export const loadLocaleAsync = async (locale: Locales): Promise<void> => {
|
||||
updateDictionary(locale, await importLocaleAsync(locale))
|
||||
loadFormatters(locale)
|
||||
}
|
||||
|
||||
export const loadAllLocalesAsync = (): Promise<void[]> => Promise.all(locales.map(loadLocaleAsync))
|
||||
|
||||
export const loadFormatters = (locale: Locales): void =>
|
||||
void (loadedFormatters[locale] = initFormatters(locale))
|
||||
26
i18n/i18n-util.sync.ts
Normal file
26
i18n/i18n-util.sync.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
// This file was auto-generated by 'typesafe-i18n'. Any manual changes will be overwritten.
|
||||
/* eslint-disable */
|
||||
|
||||
import { initFormatters } from './formatters'
|
||||
import type { Locales, Translations } from './i18n-types'
|
||||
import { loadedFormatters, loadedLocales, locales } from './i18n-util'
|
||||
|
||||
import en from './en'
|
||||
import zh from './zh'
|
||||
|
||||
const localeTranslations = {
|
||||
en,
|
||||
zh,
|
||||
}
|
||||
|
||||
export const loadLocale = (locale: Locales): void => {
|
||||
if (loadedLocales[locale]) return
|
||||
|
||||
loadedLocales[locale] = localeTranslations[locale] as unknown as Translations
|
||||
loadFormatters(locale)
|
||||
}
|
||||
|
||||
export const loadAllLocales = (): void => locales.forEach(loadLocale)
|
||||
|
||||
export const loadFormatters = (locale: Locales): void =>
|
||||
void (loadedFormatters[locale] = initFormatters(locale))
|
||||
38
i18n/i18n-util.ts
Normal file
38
i18n/i18n-util.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
// This file was auto-generated by 'typesafe-i18n'. Any manual changes will be overwritten.
|
||||
/* eslint-disable */
|
||||
|
||||
import { i18n as initI18n, i18nObject as initI18nObject, i18nString as initI18nString } from 'typesafe-i18n'
|
||||
import type { LocaleDetector } from 'typesafe-i18n/detectors'
|
||||
import type { LocaleTranslationFunctions, TranslateByString } from 'typesafe-i18n'
|
||||
import { detectLocale as detectLocaleFn } from 'typesafe-i18n/detectors'
|
||||
import { initExtendDictionary } from 'typesafe-i18n/utils'
|
||||
import type { Formatters, Locales, Translations, TranslationFunctions } from './i18n-types'
|
||||
|
||||
export const baseLocale: Locales = 'en'
|
||||
|
||||
export const locales: Locales[] = [
|
||||
'en',
|
||||
'zh'
|
||||
]
|
||||
|
||||
export const isLocale = (locale: string): locale is Locales => locales.includes(locale as Locales)
|
||||
|
||||
export const loadedLocales: Record<Locales, Translations> = {} as Record<Locales, Translations>
|
||||
|
||||
export const loadedFormatters: Record<Locales, Formatters> = {} as Record<Locales, Formatters>
|
||||
|
||||
export const extendDictionary = initExtendDictionary<Translations>()
|
||||
|
||||
export const i18nString = (locale: Locales): TranslateByString => initI18nString<Locales, Formatters>(locale, loadedFormatters[locale])
|
||||
|
||||
export const i18nObject = (locale: Locales): TranslationFunctions =>
|
||||
initI18nObject<Locales, Translations, TranslationFunctions, Formatters>(
|
||||
locale,
|
||||
loadedLocales[locale],
|
||||
loadedFormatters[locale]
|
||||
)
|
||||
|
||||
export const i18n = (): LocaleTranslationFunctions<Locales, Translations, TranslationFunctions> =>
|
||||
initI18n<Locales, Translations, TranslationFunctions, Formatters>(loadedLocales, loadedFormatters)
|
||||
|
||||
export const detectLocale = (...detectors: LocaleDetector[]): Locales => detectLocaleFn<Locales>(baseLocale, locales, ...detectors)
|
||||
36
i18n/zh/index.ts
Normal file
36
i18n/zh/index.ts
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
import type { Translation } from '../i18n-types';
|
||||
|
||||
const zh: Translation = {
|
||||
settings: {
|
||||
stopOtherPlayers: {
|
||||
title: '播放时停止其他播放器',
|
||||
description: '播放新音频文件时停止其他播放器',
|
||||
},
|
||||
waveformType: {
|
||||
title: '波形类型',
|
||||
description: '选择音频波形的显示样式',
|
||||
options: {
|
||||
bars: '柱形',
|
||||
envelope: '包络',
|
||||
line: '单线条',
|
||||
mirror: '镜像',
|
||||
wave: '波浪'
|
||||
}
|
||||
},
|
||||
samplePoints: {
|
||||
title: '波形采样点数量',
|
||||
description: '设置波形图的采样点数量。数值越大,波形细节越丰富,但性能消耗也越大。',
|
||||
options: {
|
||||
'50': '50 (最低)',
|
||||
'100': '100 (低)',
|
||||
'200': '200 (默认)',
|
||||
'500': '500 (中)',
|
||||
'1000': '1000 (高)',
|
||||
'2000': '2000 (很高)',
|
||||
'5000': '5000 (极高)'
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default zh;
|
||||
10
main.css
10
main.css
|
|
@ -10,8 +10,8 @@
|
|||
}
|
||||
|
||||
/* 隐藏默认音频元素 */
|
||||
.internal-embed audio {
|
||||
display: none;
|
||||
.internal-embed.audio-embed {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* 播放器容器样式 */
|
||||
|
|
@ -20,7 +20,6 @@
|
|||
overflow: hidden;
|
||||
display: block;
|
||||
width: 100%;
|
||||
background: var(--background-secondary);
|
||||
margin: 0.5em 0;
|
||||
}
|
||||
|
||||
|
|
@ -64,7 +63,8 @@
|
|||
|
||||
/* 统一播放器样式 */
|
||||
.wa-obsidian-player {
|
||||
background: var(--background-secondary);
|
||||
border-radius: 8px;
|
||||
padding: 0.5em;
|
||||
padding: 16px;
|
||||
background: transparent;
|
||||
--wa-text-color: var(--text-normal);
|
||||
}
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"id": "waveform-player",
|
||||
"name": "Waveform Player",
|
||||
"version": "0.0.4",
|
||||
"version": "0.0.7",
|
||||
"minAppVersion": "1.7.7",
|
||||
"description": "Render audio files as waveforms",
|
||||
"author": "Zhou Hua",
|
||||
|
|
|
|||
10
package.json
10
package.json
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"name": "obsidian-waveform-player",
|
||||
"type": "module",
|
||||
"version": "0.0.4",
|
||||
"version": "0.0.7",
|
||||
"description": "Obsidian plugin for rendering audio files as waveforms",
|
||||
"author": "zhouhua",
|
||||
"license": "MIT",
|
||||
|
|
@ -15,7 +15,8 @@
|
|||
"scripts": {
|
||||
"dev": "node esbuild.config.mjs",
|
||||
"build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production",
|
||||
"version": "node version-bump.mjs && git add manifest.json versions.json"
|
||||
"version": "node version-bump.mjs && git add manifest.json versions.json",
|
||||
"typesafe-i18n": "typesafe-i18n"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@codemirror/language": "^6.0.0",
|
||||
|
|
@ -36,8 +37,9 @@
|
|||
"typescript": "5.7.3"
|
||||
},
|
||||
"dependencies": {
|
||||
"@waveform-audio/player": "^0.0.3",
|
||||
"@waveform-audio/player": "^0.0.4",
|
||||
"react": "^18.3.0",
|
||||
"react-dom": "^18.3.0"
|
||||
"react-dom": "^18.3.0",
|
||||
"typesafe-i18n": "^5.26.2"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
1155
pnpm-lock.yaml
1155
pnpm-lock.yaml
File diff suppressed because it is too large
Load diff
26
src/L.ts
Normal file
26
src/L.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import { loadAllLocales } from '../i18n/i18n-util.sync';
|
||||
import { i18n } from '../i18n/i18n-util';
|
||||
import type { Locales } from '../i18n/i18n-types';
|
||||
|
||||
loadAllLocales();
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
i18next: {
|
||||
language: string;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
let locale: Locales = 'en';
|
||||
try {
|
||||
locale = (window.i18next.language || '').startsWith('zh') ? 'zh' : 'en';
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
catch (e) {
|
||||
/* empty */
|
||||
}
|
||||
|
||||
const L = i18n()[locale];
|
||||
|
||||
export default L;
|
||||
405
src/main.ts
405
src/main.ts
|
|
@ -1,15 +1,100 @@
|
|||
import type { EditorState, Extension } from '@codemirror/state';
|
||||
import type { DecorationSet, ViewUpdate } from '@codemirror/view';
|
||||
import type { ComponentType } from 'react';
|
||||
import type { ComponentType, ReactNode } 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 WaveformPlayer, { AudioPlayerContextValue } from '@waveform-audio/player';
|
||||
import { Plugin, TFile, PluginSettingTab, Setting, App } from 'obsidian';
|
||||
import { createElement } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import L from './L';
|
||||
|
||||
const PLAYER_CLASS = 'waveform-player-widget';
|
||||
const AUDIO_LINK_PATTERN = '!\\[([^\\]]*)\\]\\(([^)]+\\.(?:mp3|wav|ogg|m4a|webm))\\)';
|
||||
const AUDIO_LINK_PATTERN = '!\\[([^\\]]*)\\]\\(([^)]+\\.(?:mp3|wav|ogg|m4a|webm))\\)|!\\[\\[([^\\]]+\\.(?:mp3|wav|ogg|m4a|webm))\\]\\]';
|
||||
|
||||
// 波形类型枚举
|
||||
type WaveformType = 'bars' | 'envelope' | 'line' | 'mirror' | 'wave';
|
||||
|
||||
// 插件设置接口
|
||||
interface WaveformPlayerSettings {
|
||||
stopOthersOnPlay: boolean;
|
||||
waveformType: WaveformType;
|
||||
samplePoints: number; // 采样点数量
|
||||
}
|
||||
|
||||
// 默认设置
|
||||
const DEFAULT_SETTINGS: WaveformPlayerSettings = {
|
||||
stopOthersOnPlay: true,
|
||||
waveformType: 'mirror',
|
||||
samplePoints: 200, // 默认采样点数量
|
||||
};
|
||||
|
||||
// 全局音频播放器注册表
|
||||
let audioPlayerContexts: AudioPlayerContextValue[] = [];
|
||||
|
||||
// 阅读视图播放器信息存储
|
||||
interface ReadingViewPlayerInfo {
|
||||
root: ReturnType<typeof createRoot>;
|
||||
container: HTMLElement;
|
||||
playerDiv: HTMLElement;
|
||||
audioUrl: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
// 播放器属性接口,用于统一创建播放器组件
|
||||
interface PlayerProps {
|
||||
src: string;
|
||||
title: string;
|
||||
plugin: WaveformPlayerPlugin;
|
||||
id?: string;
|
||||
}
|
||||
|
||||
// 创建播放器React元素的统一函数
|
||||
function createPlayerElement({ src, title, plugin, id }: PlayerProps): ReactNode {
|
||||
return createElement(WaveformPlayer as ComponentType<any>, {
|
||||
className: 'wa-obsidian-player',
|
||||
key: id,
|
||||
samplePoints: plugin.settings.samplePoints, // 使用设置中的采样点数量
|
||||
src,
|
||||
showDownloadButton: false,
|
||||
onPlay: (ctx: AudioPlayerContextValue) => {
|
||||
if (plugin.settings.stopOthersOnPlay) {
|
||||
audioPlayerContexts.forEach(player => {
|
||||
if (player.instanceId !== ctx.instanceId) {
|
||||
player.pause();
|
||||
}
|
||||
});
|
||||
}
|
||||
audioPlayerContexts.push(ctx);
|
||||
},
|
||||
onPause: (ctx: AudioPlayerContextValue) => {
|
||||
audioPlayerContexts = audioPlayerContexts.filter(player => player.instanceId !== ctx.instanceId);
|
||||
},
|
||||
onEnded: (ctx: AudioPlayerContextValue) => {
|
||||
// 音频结束时从注册表中移除
|
||||
audioPlayerContexts = audioPlayerContexts.filter(player => player.instanceId !== ctx.instanceId);
|
||||
},
|
||||
styles: {
|
||||
controls: {
|
||||
width: '156px',
|
||||
padding: 0,
|
||||
},
|
||||
header: {
|
||||
padding: 0,
|
||||
paddingBottom: 8,
|
||||
},
|
||||
title: {
|
||||
fontSize: '14px',
|
||||
margin: 0,
|
||||
},
|
||||
waveform: {
|
||||
height: '100px',
|
||||
},
|
||||
},
|
||||
title,
|
||||
type: plugin.settings.waveformType,
|
||||
});
|
||||
}
|
||||
|
||||
class AudioPlayerWidget extends WidgetType {
|
||||
private static counter = 0;
|
||||
|
|
@ -18,6 +103,14 @@ class AudioPlayerWidget extends WidgetType {
|
|||
private mounted = false;
|
||||
private playerDiv: HTMLElement | null = null;
|
||||
private root: null | ReturnType<typeof createRoot> = null;
|
||||
// 记录创建时的设置版本,用于检测设置是否变化
|
||||
private settingsVersion: number;
|
||||
|
||||
// 存储所有实例的静态集合
|
||||
private static instances: Set<AudioPlayerWidget> = new Set();
|
||||
|
||||
// 静态触发器,用于手动触发重新渲染所有播放器
|
||||
public static updateTrigger = StateEffect.define<void>();
|
||||
|
||||
constructor(
|
||||
private readonly src: string,
|
||||
|
|
@ -26,19 +119,41 @@ class AudioPlayerWidget extends WidgetType {
|
|||
) {
|
||||
super();
|
||||
this.id = `audio-player-${AudioPlayerWidget.counter++}`;
|
||||
// 记录当前的设置版本
|
||||
this.settingsVersion = this.plugin.settingsVersion;
|
||||
// 将实例添加到集合中
|
||||
AudioPlayerWidget.instances.add(this);
|
||||
}
|
||||
|
||||
destroy() {
|
||||
// 从集合中移除实例
|
||||
AudioPlayerWidget.instances.delete(this);
|
||||
this.unmount();
|
||||
this.container = null;
|
||||
this.playerDiv = null;
|
||||
}
|
||||
|
||||
// 静态方法:重新渲染所有播放器实例
|
||||
static refreshAllPlayers(plugin: WaveformPlayerPlugin): void {
|
||||
// 触发编辑器强制更新
|
||||
plugin.triggerEditorRefresh();
|
||||
}
|
||||
|
||||
// 重新挂载播放器
|
||||
remount(): void {
|
||||
if (this.playerDiv && this.container) {
|
||||
this.unmount();
|
||||
this.mount();
|
||||
}
|
||||
}
|
||||
|
||||
// 当比较两个播放器小部件是否相同时,同时考虑设置版本
|
||||
eq(other: AudioPlayerWidget): boolean {
|
||||
return (
|
||||
other instanceof AudioPlayerWidget &&
|
||||
this.src === other.src &&
|
||||
this.title === other.title
|
||||
this.title === other.title &&
|
||||
this.settingsVersion === other.settingsVersion // 比较设置版本
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -80,36 +195,17 @@ class AudioPlayerWidget extends WidgetType {
|
|||
}
|
||||
|
||||
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,
|
||||
createPlayerElement({
|
||||
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',
|
||||
}),
|
||||
plugin: this.plugin,
|
||||
id: this.id
|
||||
})
|
||||
);
|
||||
this.mounted = true;
|
||||
}
|
||||
|
|
@ -136,8 +232,65 @@ class AudioPlayerWidget extends WidgetType {
|
|||
}
|
||||
|
||||
export default class WaveformPlayerPlugin extends Plugin {
|
||||
settings: WaveformPlayerSettings = DEFAULT_SETTINGS;
|
||||
// 设置版本号,每次设置变更时递增
|
||||
settingsVersion: number = 0;
|
||||
// 存储阅读视图中渲染的播放器
|
||||
private readingViewPlayers: ReadingViewPlayerInfo[] = [];
|
||||
// 编辑器视图
|
||||
private editorViews: Set<EditorView> = new Set();
|
||||
|
||||
// 触发编辑器视图刷新
|
||||
triggerEditorRefresh(): void {
|
||||
this.editorViews.forEach(view => {
|
||||
view.dispatch({
|
||||
effects: AudioPlayerWidget.updateTrigger.of(undefined)
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// 更新所有播放器
|
||||
refreshAllPlayers(): void {
|
||||
// 增加设置版本号
|
||||
this.settingsVersion++;
|
||||
|
||||
// 更新编辑器视图中的播放器
|
||||
AudioPlayerWidget.refreshAllPlayers(this);
|
||||
|
||||
// 更新阅读视图中的播放器
|
||||
this.refreshReadingViewPlayers();
|
||||
|
||||
// 通知用户刷新完成
|
||||
this.app.workspace.trigger('waveform-player:refresh-complete');
|
||||
}
|
||||
|
||||
// 刷新阅读视图播放器
|
||||
private refreshReadingViewPlayers(): void {
|
||||
this.readingViewPlayers.forEach(playerInfo => {
|
||||
try {
|
||||
// 卸载并重新渲染播放器
|
||||
playerInfo.root.unmount();
|
||||
const root = createRoot(playerInfo.playerDiv);
|
||||
|
||||
root.render(
|
||||
createPlayerElement({
|
||||
src: playerInfo.audioUrl,
|
||||
title: playerInfo.title,
|
||||
plugin: this
|
||||
})
|
||||
);
|
||||
|
||||
// 更新播放器根节点引用
|
||||
playerInfo.root = root;
|
||||
} catch (error) {
|
||||
console.error('[WaveformPlayerPlugin] Failed to refresh reading view player:', error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
createEditorExtension(): Extension {
|
||||
const updateAudioPlayers = StateEffect.define<void>();
|
||||
const plugin = this;
|
||||
|
||||
const audioPlayerField = StateField.define<DecorationSet>({
|
||||
create: (state) => {
|
||||
|
|
@ -145,7 +298,9 @@ export default class WaveformPlayerPlugin extends Plugin {
|
|||
},
|
||||
provide: field => EditorView.decorations.from(field),
|
||||
update: (decorations, tr) => {
|
||||
if (!tr.docChanged && !tr.effects.some(e => e.is(updateAudioPlayers))) {
|
||||
if (!tr.docChanged &&
|
||||
!tr.effects.some(e => e.is(updateAudioPlayers)) &&
|
||||
!tr.effects.some(e => e.is(AudioPlayerWidget.updateTrigger))) {
|
||||
return decorations;
|
||||
}
|
||||
|
||||
|
|
@ -164,6 +319,11 @@ export default class WaveformPlayerPlugin extends Plugin {
|
|||
});
|
||||
}
|
||||
|
||||
// 如果是设置更新触发器,强制更新
|
||||
if (tr.effects.some(e => e.is(AudioPlayerWidget.updateTrigger))) {
|
||||
needsUpdate = true;
|
||||
}
|
||||
|
||||
if (!needsUpdate && !tr.effects.some(e => e.is(updateAudioPlayers))) {
|
||||
return decorations;
|
||||
}
|
||||
|
|
@ -174,16 +334,20 @@ export default class WaveformPlayerPlugin extends Plugin {
|
|||
|
||||
const viewportPlugin = ViewPlugin.fromClass(class {
|
||||
private updateScheduled = false;
|
||||
private timeout: NodeJS.Timeout | null = null;
|
||||
private timeout: number | null = null;
|
||||
|
||||
constructor(private readonly view: EditorView) {
|
||||
this.scheduleUpdate();
|
||||
// 注册编辑器视图
|
||||
plugin.editorViews.add(view);
|
||||
}
|
||||
|
||||
destroy() {
|
||||
if (this.timeout) {
|
||||
clearTimeout(this.timeout);
|
||||
}
|
||||
// 移除编辑器视图
|
||||
plugin.editorViews.delete(this.view);
|
||||
}
|
||||
|
||||
update(update: ViewUpdate) {
|
||||
|
|
@ -202,7 +366,7 @@ export default class WaveformPlayerPlugin extends Plugin {
|
|||
clearTimeout(this.timeout);
|
||||
}
|
||||
|
||||
this.timeout = setTimeout(() => {
|
||||
this.timeout = window.setTimeout(() => {
|
||||
this.updateScheduled = false;
|
||||
this.view.dispatch({
|
||||
effects: updateAudioPlayers.of(undefined),
|
||||
|
|
@ -215,26 +379,17 @@ export default class WaveformPlayerPlugin extends Plugin {
|
|||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
const audioFile = this.app.metadataCache.getFirstLinkpathDest(src, '');
|
||||
return audioFile instanceof TFile ? audioFile : null;
|
||||
}
|
||||
|
||||
async onload() {
|
||||
// 加载设置
|
||||
await this.loadSettings();
|
||||
|
||||
// 添加设置选项卡
|
||||
this.addSettingTab(new WaveformPlayerSettingTab(this.app, this));
|
||||
|
||||
// 注册编辑器扩展
|
||||
this.registerEditorExtension(this.createEditorExtension());
|
||||
|
||||
|
|
@ -267,35 +422,72 @@ export default class WaveformPlayerPlugin extends Plugin {
|
|||
div.parentNode?.insertBefore(container, div.nextSibling);
|
||||
|
||||
const root = createRoot(playerDiv);
|
||||
|
||||
// 创建播放器信息并存储
|
||||
const playerInfo: ReadingViewPlayerInfo = {
|
||||
root,
|
||||
container,
|
||||
playerDiv,
|
||||
audioUrl: decodedUrl,
|
||||
title: audioFile.basename,
|
||||
};
|
||||
|
||||
// 将播放器信息添加到列表中
|
||||
this.readingViewPlayers.push(playerInfo);
|
||||
|
||||
// 当组件卸载时,从列表中移除
|
||||
const observer = new MutationObserver((mutations) => {
|
||||
for (const mutation of mutations) {
|
||||
for (const node of Array.from(mutation.removedNodes)) {
|
||||
if (node.contains(container)) {
|
||||
this.readingViewPlayers = this.readingViewPlayers.filter(p => p !== playerInfo);
|
||||
observer.disconnect();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 监视 DOM 变化
|
||||
if (div.parentNode) {
|
||||
observer.observe(div.parentNode, { childList: true, subtree: true });
|
||||
}
|
||||
|
||||
root.render(
|
||||
createElement(WaveformPlayer as ComponentType<any>, {
|
||||
className: 'wa-obsidian-player',
|
||||
samplePoints: 200,
|
||||
createPlayerElement({
|
||||
src: decodedUrl,
|
||||
styles: {
|
||||
controls: {
|
||||
paddingBottom: 0,
|
||||
width: '156px',
|
||||
},
|
||||
root: {
|
||||
padding: '0.5em',
|
||||
},
|
||||
title: {
|
||||
fontSize: '14px',
|
||||
margin: 0,
|
||||
},
|
||||
waveform: {
|
||||
height: '100px',
|
||||
},
|
||||
},
|
||||
type: 'mirror',
|
||||
}),
|
||||
title: audioFile.basename,
|
||||
plugin: this
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
onunload() {
|
||||
// 清理所有播放器
|
||||
this.readingViewPlayers.forEach(playerInfo => {
|
||||
try {
|
||||
playerInfo.root.unmount();
|
||||
} catch (error) {
|
||||
console.error('[WaveformPlayerPlugin] Failed to unmount reading view player:', error);
|
||||
}
|
||||
});
|
||||
this.readingViewPlayers = [];
|
||||
this.editorViews.clear();
|
||||
|
||||
audioPlayerContexts.forEach(ctx => ctx.stop());
|
||||
audioPlayerContexts = [];
|
||||
}
|
||||
|
||||
// 加载设置
|
||||
async loadSettings() {
|
||||
this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData());
|
||||
}
|
||||
|
||||
// 保存设置
|
||||
async saveSettings() {
|
||||
await this.saveData(this.settings);
|
||||
}
|
||||
|
||||
private buildDecorations(state: EditorState): DecorationSet {
|
||||
|
|
@ -310,9 +502,12 @@ export default class WaveformPlayerPlugin extends Plugin {
|
|||
// 每次创建新的正则表达式实例
|
||||
const regex = new RegExp(AUDIO_LINK_PATTERN, 'gi');
|
||||
|
||||
// 使用 matchAll 替代 while 循环
|
||||
Array.from(lineText.matchAll(regex)).forEach((match) => {
|
||||
const [, title, src] = match;
|
||||
const [_, title, mdSrc, obsidianSrc] = match;
|
||||
// 如果是 Obsidian 格式的链接,使用 obsidianSrc 作为源和标题
|
||||
const src = obsidianSrc || mdSrc;
|
||||
const effectiveTitle = obsidianSrc ? obsidianSrc.split('/').pop()?.replace(/\.[^.]+$/, '') || '' : (title || '');
|
||||
|
||||
if (!src) {
|
||||
return;
|
||||
}
|
||||
|
|
@ -326,7 +521,7 @@ export default class WaveformPlayerPlugin extends Plugin {
|
|||
block: true, // 添加 block 属性
|
||||
persistent: true,
|
||||
side: 1, // 在匹配文本后面插入
|
||||
widget: new AudioPlayerWidget(src, title || '', this),
|
||||
widget: new AudioPlayerWidget(src, effectiveTitle, this),
|
||||
}).range(matchEnd),
|
||||
);
|
||||
});
|
||||
|
|
@ -335,3 +530,71 @@ export default class WaveformPlayerPlugin extends Plugin {
|
|||
return Decoration.set(widgets, true);
|
||||
}
|
||||
}
|
||||
|
||||
// 设置选项卡类
|
||||
class WaveformPlayerSettingTab extends PluginSettingTab {
|
||||
plugin: WaveformPlayerPlugin;
|
||||
|
||||
constructor(app: App, plugin: WaveformPlayerPlugin) {
|
||||
super(app, plugin);
|
||||
this.plugin = plugin;
|
||||
}
|
||||
|
||||
display(): void {
|
||||
const { containerEl } = this;
|
||||
|
||||
containerEl.empty();
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName(L.settings.stopOtherPlayers.title())
|
||||
.setDesc(L.settings.stopOtherPlayers.description())
|
||||
.addToggle(toggle => toggle
|
||||
.setValue(this.plugin.settings.stopOthersOnPlay)
|
||||
.onChange(async (value) => {
|
||||
this.plugin.settings.stopOthersOnPlay = value;
|
||||
await this.plugin.saveSettings();
|
||||
// 设置变化时刷新所有播放器
|
||||
this.plugin.refreshAllPlayers();
|
||||
}));
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName(L.settings.waveformType.title())
|
||||
.setDesc(L.settings.waveformType.description())
|
||||
.addDropdown(dropdown => dropdown
|
||||
.addOptions({
|
||||
'bars': L.settings.waveformType.options.bars(),
|
||||
'envelope': L.settings.waveformType.options.envelope(),
|
||||
'line': L.settings.waveformType.options.line(),
|
||||
'mirror': L.settings.waveformType.options.mirror(),
|
||||
'wave': L.settings.waveformType.options.wave()
|
||||
})
|
||||
.setValue(this.plugin.settings.waveformType)
|
||||
.onChange(async (value: string) => {
|
||||
this.plugin.settings.waveformType = value as WaveformType;
|
||||
await this.plugin.saveSettings();
|
||||
// 设置变化时刷新所有播放器
|
||||
this.plugin.refreshAllPlayers();
|
||||
}));
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName(L.settings.samplePoints.title())
|
||||
.setDesc(L.settings.samplePoints.description())
|
||||
.addDropdown(dropdown => dropdown
|
||||
.addOptions({
|
||||
'50': L.settings.samplePoints.options[50](),
|
||||
'100': L.settings.samplePoints.options[100](),
|
||||
'200': L.settings.samplePoints.options[200](),
|
||||
'500': L.settings.samplePoints.options[500](),
|
||||
'1000': L.settings.samplePoints.options[1000](),
|
||||
'2000': L.settings.samplePoints.options[2000](),
|
||||
'5000': L.settings.samplePoints.options[5000]()
|
||||
})
|
||||
.setValue(this.plugin.settings.samplePoints.toString())
|
||||
.onChange(async (value: string) => {
|
||||
this.plugin.settings.samplePoints = parseInt(value, 10);
|
||||
await this.plugin.saveSettings();
|
||||
// 设置变化时刷新所有播放器
|
||||
this.plugin.refreshAllPlayers();
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,5 +2,8 @@
|
|||
"0.0.1": "1.7.7",
|
||||
"0.0.2": "1.7.7",
|
||||
"0.0.3": "1.7.7",
|
||||
"0.0.4": "1.7.7"
|
||||
"0.0.4": "1.7.7",
|
||||
"0.0.5": "1.7.7",
|
||||
"0.0.6": "1.7.7",
|
||||
"0.0.7": "1.7.7"
|
||||
}
|
||||
Loading…
Reference in a new issue