diff --git a/docs/.vuepress/notes/zh/theme-guide.ts b/docs/.vuepress/notes/zh/theme-guide.ts
index 602550b37..62b7bf3be 100644
--- a/docs/.vuepress/notes/zh/theme-guide.ts
+++ b/docs/.vuepress/notes/zh/theme-guide.ts
@@ -96,6 +96,7 @@ export const themeGuide = defineNoteConfig({
'bilibili',
'youtube',
'artplayer',
+ 'audioReader',
],
},
],
diff --git a/docs/.vuepress/theme.ts b/docs/.vuepress/theme.ts
index bb2fd4999..b4e1d8913 100644
--- a/docs/.vuepress/theme.ts
+++ b/docs/.vuepress/theme.ts
@@ -31,6 +31,7 @@ export const theme: Theme = plumeTheme({
bilibili: true,
youtube: true,
artPlayer: true,
+ audioReader: true,
codepen: true,
replit: true,
codeSandbox: true,
diff --git "a/docs/notes/theme/guide/\345\265\214\345\205\245/audioReader.md" "b/docs/notes/theme/guide/\345\265\214\345\205\245/audioReader.md"
new file mode 100644
index 000000000..3d9cf8aeb
--- /dev/null
+++ "b/docs/notes/theme/guide/\345\265\214\345\205\245/audioReader.md"
@@ -0,0 +1,108 @@
+---
+title: Audio Reader 音频
+icon: rivet-icons:audio
+createTime: 2024/12/24 22:31:01
+permalink: /guide/embed/audio/reader/
+---
+
+## 概述
+
+主题支持在文档中嵌入 音频阅读 。
+
+该功能由 [vuepress-plugin-md-power](../../config/plugins/markdownPower.md) 提供支持。
+
+**音频阅读** 并不是一个音乐播放器,它仅是在内容中嵌入一个( @[audioReader](https://sensearch.baidu.com/gettts?lan=en&spd=3&source=alading&text=audio) )按钮,点击后播放一段音频。
+
+它适合用于播放一些短时间的音频,比如 **单词标音** 。
+
+## 配置
+
+该功能默认不启用。你需要在主题配置中开启。
+
+::: code-tabs
+@tab .vuepress/config.ts
+
+```ts
+export default defineUserConfig({
+ theme: plumeTheme({
+ plugins: {
+ markdownPower: {
+ audioReader: true,
+ },
+ }
+ })
+})
+```
+
+:::
+
+## markdown 语法
+
+音频嵌入 markdown 语法是一个 行内语法,因此你可以在 markdown 的任何地方中使用。
+
+```md
+@[audioReader](src)
+```
+
+添加配置项:
+
+```md
+@[audioReader type="audio/mpeg" title="title" autoplay start-time="0" end-time="10" volume="0.7"](src)
+```
+
+**配置说明:**
+
+- `type`:音频类型,格式如:`audio/mpeg` ,
+ 默认根据音频链接地址的文件扩展名推断,如果链接地址中不包含扩展名,请手动声明。
+- `title`: 音频标题,显示在音频图标之前。
+- `autoplay`:是否自动播放,不建议启用。
+- `start-time`:音频起始播放时间点,单位为 秒。
+- `end-time`:音频结束播放时间点,单位为 秒。
+- `volume`:音频播放音量,范围为 `0 ~ 1` 。
+
+## 全局组件
+
+主题提供了全局组件 `` 以支持更灵活丰富的使用方式。
+
+### Props
+
+| 字段 | 类型 | 描述 |
+| --------- | --------- | ----------------------------------- |
+| src | `string` | 必填,音频播放地址 |
+| type | `string` | 选填,音频格式,默认从 `src` 中截取 |
+| autoplay | `boolean` | 选填,是否自动播放,不建议启用 |
+| startTime | `number` | 选填,音频起始播放时间点,单位为 秒 |
+| endTime | `number` | 选填,音频结束播放时间点,单位为 秒 |
+| volume | `number | 选填,音频播放音量,范围为 `0 ~ 1` |
+
+## 示例
+
+**输入:**
+
+```md
+audio 美 [ˈɔːdioʊ] @[audioReader](/audio/audio.mp3)
+```
+
+**输出:**
+
+audio 美 [ˈɔːdioʊ] @[audioReader](https://sensearch.baidu.com/gettts?lan=en&spd=3&source=alading&text=audio)
+
+**输入:**
+
+```md
+audio 美 @[audioReader title="[ˈɔːdioʊ]"](/audio/audio.mp3)
+```
+
+**输出:**
+
+audio 美 @[audioReader title="[ˈɔːdioʊ]"](https://sensearch.baidu.com/gettts?lan=en&spd=3&source=alading&text=audio)
+
+**输入:**
+
+```md
+audio 美 [ˈɔːdioʊ]
+```
+
+**输出:**
+
+audio 美 [ˈɔːdioʊ]
diff --git a/plugins/plugin-md-power/package.json b/plugins/plugin-md-power/package.json
index a012d5b0d..51f854367 100644
--- a/plugins/plugin-md-power/package.json
+++ b/plugins/plugin-md-power/package.json
@@ -31,10 +31,13 @@
"lib"
],
"scripts": {
+ "dev": "pnpm '/(copy|tsup):watch/'",
"build": "pnpm copy && pnpm tsup",
"clean": "rimraf --glob ./lib",
"copy": "cpx \"src/**/*.{d.ts,vue,css,scss,jpg,png}\" lib",
- "tsup": "tsup --config tsup.config.ts"
+ "copy:watch": "cpx \"src/**/*.{d.ts,vue,css,scss,jpg,png}\" lib -w",
+ "tsup": "tsup --config tsup.config.ts",
+ "tsup:watch": "tsup --config tsup.config.ts --watch -- -c"
},
"peerDependencies": {
"artplayer": "^5.2.0",
diff --git a/plugins/plugin-md-power/src/client/components/AudioReader.vue b/plugins/plugin-md-power/src/client/components/AudioReader.vue
new file mode 100644
index 000000000..ff704bc03
--- /dev/null
+++ b/plugins/plugin-md-power/src/client/components/AudioReader.vue
@@ -0,0 +1,100 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/plugins/plugin-md-power/src/client/composables/audio.ts b/plugins/plugin-md-power/src/client/composables/audio.ts
new file mode 100644
index 000000000..8de5c7fae
--- /dev/null
+++ b/plugins/plugin-md-power/src/client/composables/audio.ts
@@ -0,0 +1,244 @@
+import { type MaybeRef, onMounted, onUnmounted, ref, toValue, watch } from 'vue'
+
+const mimeTypes = {
+ 'audio/flac': ['flac', 'fla'],
+ 'audio/mpeg': ['mp3', 'mpga'],
+ 'audio/mp4': ['mp4', 'm4a'],
+ 'audio/ogg': ['ogg', 'oga'],
+ 'audio/aac': ['aac', 'adts'],
+ 'audio/x-ms-wma': ['wma'],
+ 'audio/x-aiff': ['aiff', 'aif', 'aifc'],
+ 'audio/webm': ['webm'],
+}
+
+export interface BufferedRange {
+ start: number
+ end: number
+}
+
+export interface AudioPlayerOptions {
+ type?: MaybeRef
+ autoplay?: boolean
+ mutex?: boolean
+ onload?: HTMLAudioElement['onload']
+ onerror?: HTMLAudioElement['onerror']
+ onpause?: HTMLAudioElement['onpause']
+ onplay?: HTMLAudioElement['onplay']
+ onplaying?: HTMLAudioElement['onplaying']
+ onseeked?: HTMLAudioElement['onseeked']
+ onvolume?: (volume: number) => void
+ onend?: HTMLAudioElement['onended']
+ onprogress?: (current: number, total: number) => void
+ oncanplay?: HTMLAudioElement['oncanplay']
+ oncanplaythrough?: HTMLAudioElement['oncanplaythrough']
+ ontimeupdate?: (currentTime: number) => void
+ onwaiting?: HTMLAudioElement['onwaiting']
+}
+
+const playerList: HTMLAudioElement[] = []
+
+export function useAudioPlayer(source: MaybeRef, options: AudioPlayerOptions = {}) {
+ let player: HTMLAudioElement | null = null
+
+ let unknownSupport = false
+
+ const isSupported = ref(false)
+ const loaded = ref(false)
+ const paused = ref(true)
+ const currentTime = ref(0)
+ const duration = ref(0)
+ const volume = ref(1)
+
+ function initialize() {
+ player = document.createElement('audio')
+ player.className = 'audio-player'
+ player.style.display = 'none'
+ player.preload = options.autoplay ? 'auto' : 'none'
+ player.autoplay = options.autoplay ?? false
+ document.body.appendChild(player)
+ playerList.push(player)
+
+ player.onloadedmetadata = () => {
+ duration.value = player!.duration
+ currentTime.value = player!.currentTime
+ volume.value = player!.volume
+ loaded.value = true
+ }
+
+ player.oncanplay = (...args) => {
+ loaded.value = true
+ if (unknownSupport)
+ isSupported.value = true
+
+ options.oncanplay?.bind(player!)(...args)
+ }
+
+ player.onplay = (...args) => {
+ paused.value = false
+ options.onplay?.bind(player!)(...args)
+ }
+
+ player.onpause = (...args) => {
+ paused.value = true
+ options.onpause?.bind(player!)(...args)
+ }
+
+ player.ontimeupdate = () => {
+ if (isValidDuration(player!.duration)) {
+ const lastBufferTime = getLastBufferedTime()
+ if (lastBufferTime <= player!.duration) {
+ options.ontimeupdate?.bind(player!)(lastBufferTime)
+ currentTime.value = lastBufferTime
+ options.onprogress?.bind(player!)(lastBufferTime, player!.duration)
+ }
+ }
+ }
+
+ player.onvolumechange = () => {
+ volume.value = player!.volume
+ options.onvolume?.bind(player!)(player!.volume)
+ }
+ player.onended = (...args) => {
+ paused.value = true
+ options.onend?.bind(player!)(...args)
+ }
+
+ player.onplaying = options.onplaying!
+ player.onload = options.onload!
+ player.onerror = options.onerror!
+ player.onseeked = options.onseeked!
+ player.oncanplaythrough = options.oncanplaythrough!
+ player.onwaiting = options.onwaiting!
+
+ isSupported.value = isSupportType()
+
+ player.src = toValue(source)
+ player.load()
+ }
+
+ function isSupportType() {
+ if (!player)
+ return false
+ let type = toValue(options.type)
+ if (!type) {
+ const ext = toValue(source).split('.').pop() || ''
+ type = Object.keys(mimeTypes).filter(type => mimeTypes[type].includes(ext))[0]
+ }
+ if (!type) {
+ unknownSupport = true
+ return false
+ }
+
+ const isSupported = player.canPlayType(type) !== ''
+ if (!isSupported) {
+ console.warn(`The specified type "${type}" is not supported by the browser.`)
+ }
+ return isSupported
+ }
+
+ function getBufferedRanges(): BufferedRange[] {
+ if (!player)
+ return []
+ const ranges: BufferedRange[] = []
+ const seekable = player.buffered || []
+
+ const offset = 0
+
+ for (let i = 0, length = seekable.length; i < length; i++) {
+ let start = seekable.start(i)
+ let end = seekable.end(i)
+ if (!isValidDuration(start))
+ start = 0
+
+ if (!isValidDuration(end)) {
+ end = 0
+ continue
+ }
+
+ ranges.push({
+ start: start + offset,
+ end: end + offset,
+ })
+ }
+ return ranges
+ }
+
+ function getLastBufferedTime(): number {
+ const bufferedRanges = getBufferedRanges()
+ if (!bufferedRanges.length)
+ return 0
+
+ const buff = bufferedRanges.find(
+ buff =>
+ buff.start < player!.currentTime
+ && buff.end > player!.currentTime,
+ )
+ if (buff)
+ return buff.end
+
+ const last = bufferedRanges[bufferedRanges.length - 1]
+ return last.end
+ }
+
+ function isValidDuration(duration: number) {
+ if (
+ duration
+ && !Number.isNaN(duration)
+ && duration !== Number.POSITIVE_INFINITY
+ && duration !== Number.NEGATIVE_INFINITY
+ ) {
+ return true
+ }
+
+ return false
+ }
+
+ function destroy() {
+ player?.pause()
+ player?.remove()
+ playerList.splice(playerList.indexOf(player!), 1)
+ player = null
+ }
+
+ onMounted(() => {
+ initialize()
+ watch([source, options.type], () => {
+ destroy()
+ loaded.value = false
+ paused.value = true
+ currentTime.value = 0
+ duration.value = 0
+ initialize()
+ })
+ })
+
+ onUnmounted(() => destroy())
+
+ return {
+ isSupported,
+ paused,
+ loaded,
+ currentTime,
+ duration,
+ player,
+ destroy,
+ play: () => {
+ if (options.mutex ?? true) {
+ for (const p of playerList) {
+ if (p !== player)
+ p.pause()
+ }
+ }
+ player?.play()
+ },
+ pause: () => player?.pause(),
+ seek(time: number) {
+ if (player)
+ player.currentTime = time
+ },
+ setVolume(volume: number) {
+ if (player)
+ player.volume = Math.min(1, Math.max(0, volume))
+ },
+ }
+}
diff --git a/plugins/plugin-md-power/src/node/embed/audio/reader.ts b/plugins/plugin-md-power/src/node/embed/audio/reader.ts
new file mode 100644
index 000000000..d8f816596
--- /dev/null
+++ b/plugins/plugin-md-power/src/node/embed/audio/reader.ts
@@ -0,0 +1,81 @@
+import type { PluginWithOptions } from 'markdown-it'
+import type { RuleInline } from 'markdown-it/lib/parser_inline.mjs'
+import { resolveAttrs } from '../../utils/resolveAttrs.js'
+
+const audioReader: RuleInline = (state, silent) => {
+ const max = state.posMax
+ let start = state.pos
+ let href = ''
+
+ if (state.src.slice(start, start + 13) !== '@[audioReader')
+ return false
+
+ // @[audioReader]()
+ if (max - start < 17)
+ return false
+
+ const labelStart = state.pos + 13
+ const labelEnd = state.md.helpers.parseLinkLabel(state, state.pos + 1, true)
+
+ // not found ']'
+ if (labelEnd < 0)
+ return false
+
+ let pos = labelEnd + 1
+ if (pos < max && state.src.charCodeAt(pos) === 0x28 /* ( */) {
+ pos++
+ const code = state.src.charCodeAt(pos)
+ if (code === 0x0A /* \n */ || code === 0x20 /* space */)
+ return false
+
+ start = pos
+ const res = state.md.helpers.parseLinkDestination(state.src, pos, state.posMax)
+ if (res.ok) {
+ href = state.md.normalizeLink(res.str)
+ if (state.md.validateLink(href)) {
+ pos = res.pos
+ }
+ else {
+ href = ''
+ }
+ }
+ if (pos >= max || state.src.charCodeAt(pos) !== 0x29 /* ) */) {
+ return false
+ }
+ }
+
+ if (!silent) {
+ state.pos = labelStart
+ state.posMax = labelEnd
+ const info = state.src.slice(labelStart, labelEnd).trim()
+ const { attrs } = resolveAttrs(info)
+
+ const tokenOpen = state.push('audio_reader_open', 'audioReader', 1)
+ tokenOpen.info = info
+ tokenOpen.attrs = [['src', href]]
+
+ if (attrs.startTime)
+ tokenOpen.attrs.push([':start-time', attrs.startTime])
+
+ if (attrs.endTime)
+ tokenOpen.attrs.push([':end-time', attrs.endTime])
+
+ if (attrs.type)
+ tokenOpen.attrs.push(['type', attrs.type])
+
+ if (attrs.volume)
+ tokenOpen.attrs.push([':volume', attrs.volume])
+
+ if (attrs.title)
+ state.push('text', '', 0).content = attrs.title
+
+ state.push('audio_reader_close', 'audioReader', -1)
+ }
+
+ state.pos = pos + 1
+ state.posMax = max
+ return true
+}
+
+export const audioReaderPlugin: PluginWithOptions = md =>
+ md.inline.ruler.before('link', 'audio-reader', audioReader)
diff --git a/plugins/plugin-md-power/src/node/embed/index.ts b/plugins/plugin-md-power/src/node/embed/index.ts
index d7dceb260..59b269ce2 100644
--- a/plugins/plugin-md-power/src/node/embed/index.ts
+++ b/plugins/plugin-md-power/src/node/embed/index.ts
@@ -1,5 +1,6 @@
import type { Markdown } from 'vuepress/markdown'
import type { MarkdownPowerPluginOptions } from '../../shared/index.js'
+import { audioReaderPlugin } from './audio/reader.js'
import { caniusePlugin, legacyCaniuse } from './caniuse.js'
import { codepenPlugin } from './code/codepen.js'
import { codeSandboxPlugin } from './code/codeSandbox.js'
@@ -39,6 +40,11 @@ export function embedSyntaxPlugin(md: Markdown, options: MarkdownPowerPluginOpti
md.use(artPlayerPlugin)
}
+ if (options.audioReader) {
+ // @[audioReader](url)
+ md.use(audioReaderPlugin)
+ }
+
if (options.codepen) {
// @[codepen](user/slash)
md.use(codepenPlugin)
diff --git a/plugins/plugin-md-power/src/node/prepareConfigFile.ts b/plugins/plugin-md-power/src/node/prepareConfigFile.ts
index c47c8c200..d5a33e1b4 100644
--- a/plugins/plugin-md-power/src/node/prepareConfigFile.ts
+++ b/plugins/plugin-md-power/src/node/prepareConfigFile.ts
@@ -70,6 +70,11 @@ export async function prepareConfigFile(app: App, options: MarkdownPowerPluginOp
enhances.add(`app.component('ArtPlayer', ArtPlayer)`)
}
+ if (options.audioReader) {
+ imports.add(`import AudioReader from '${CLIENT_FOLDER}components/AudioReader.vue'`)
+ enhances.add(`app.component('AudioReader', AudioReader)`)
+ }
+
return app.writeTemp(
'md-power/config.js',
`\
diff --git a/plugins/plugin-md-power/src/shared/plugin.ts b/plugins/plugin-md-power/src/shared/plugin.ts
index 60fb9c0a7..0d13107cf 100644
--- a/plugins/plugin-md-power/src/shared/plugin.ts
+++ b/plugins/plugin-md-power/src/shared/plugin.ts
@@ -70,6 +70,13 @@ export interface MarkdownPowerPluginOptions {
*/
artPlayer?: boolean
+ /**
+ * 是否启用 audioReader 音频嵌入
+ *
+ * `@[audioReader](url)`
+ */
+ audioReader?: boolean
+
// code embed
/**
* 是否启用 codepen 嵌入
diff --git a/plugins/plugin-md-power/tsup.config.ts b/plugins/plugin-md-power/tsup.config.ts
index 1cdc24d66..a4c509576 100644
--- a/plugins/plugin-md-power/tsup.config.ts
+++ b/plugins/plugin-md-power/tsup.config.ts
@@ -2,7 +2,7 @@ import { defineConfig, type Options } from 'tsup'
import { argv } from '../../scripts/tsup-args.js'
const config = [
- { dir: 'composables', files: ['codeRepl.ts', 'pdf.ts', 'rustRepl.ts', 'size.ts'] },
+ { dir: 'composables', files: ['codeRepl.ts', 'pdf.ts', 'rustRepl.ts', 'size.ts', 'audio.ts'] },
{ dir: 'utils', files: ['http.ts', 'is.ts', 'link.ts', 'sleep.ts'] },
{ dir: '', files: ['index.ts', 'options.ts'] },
]