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'] }, ]