feat(plugin-md-power): add audioReader support (#398)

* feat(plugin-md-power): add `audioReader` support

* chore: tweak
This commit is contained in:
pengzhanbo 2024-12-25 00:17:18 +08:00 committed by GitHub
parent 0b7a955343
commit c276a77d4e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 558 additions and 2 deletions

View File

@ -96,6 +96,7 @@ export const themeGuide = defineNoteConfig({
'bilibili',
'youtube',
'artplayer',
'audioReader',
],
},
],

View File

@ -31,6 +31,7 @@ export const theme: Theme = plumeTheme({
bilibili: true,
youtube: true,
artPlayer: true,
audioReader: true,
codepen: true,
replit: true,
codeSandbox: true,

View File

@ -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`
## 全局组件
主题提供了全局组件 `<AudioReader />` 以支持更灵活丰富的使用方式。
### 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 美 <AudioReader src="/audio/audio.mp3">[ˈɔːdioʊ]</AudioReader>
```
**输出:**
audio 美 <AudioReader src="https://sensearch.baidu.com/gettts?lan=en&spd=3&source=alading&text=audio">[ˈɔːdioʊ]</AudioReader>

View File

@ -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",

View File

@ -0,0 +1,100 @@
<script setup lang="ts">
import { useInterval } from '@vueuse/core'
import { onMounted, toRef, watch } from 'vue'
import { useAudioPlayer } from '../composables/audio.js'
const props = defineProps<{
src: string
autoplay?: boolean
type?: string
volume?: number
startTime?: number
endTime?: number
}>()
const { paused, play, pause, seek, setVolume } = useAudioPlayer(
toRef(() => props.src),
{
type: toRef(() => props.type || ''),
autoplay: props.autoplay,
oncanplay: () => {
if (props.startTime) {
seek(props.startTime)
}
},
ontimeupdate: (time) => {
if (props.endTime && time >= props.endTime) {
pause()
if (props.startTime) {
seek(props.startTime)
}
}
},
},
)
const interval = useInterval(300, { controls: true, immediate: false })
watch(paused, () => {
if (paused.value) {
interval.pause()
}
})
function opacity(mo: number) {
return paused.value ? 1 : (interval.counter.value % 3 >= mo ? 1 : 0)
}
function toggle() {
if (paused.value) {
play()
interval.reset()
interval.resume()
}
else {
pause()
}
}
onMounted(() => {
watch(() => props.volume, (volume) => {
if (typeof volume !== 'undefined') {
setVolume(volume)
}
}, { immediate: true })
})
</script>
<template>
<span class="vp-audio-reader" @click="toggle">
<slot />
<span class="icon-audio">
<svg fill="currentcolor" width="16" height="16" viewBox="0 0 54 54" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><g stroke="none" stroke-width="1" fill-rule="evenodd"><path d="M24.1538 5.86289C24.8505 5.23954 25.738 4.95724 26.6005 5.00519C27.463 5.05313 28.3137 5.43204 28.9371 6.12878C29.4928 6.74989 29.8 7.55405 29.8 8.38746V46.28C29.8 47.2149 29.4186 48.0645 28.8078 48.6754C28.197 49.2862 27.3474 49.6675 26.4125 49.6675C25.5843 49.6675 24.7848 49.3641 24.1651 48.8147L13.0526 38.9618C12.5285 38.4971 11.8523 38.2405 11.1518 38.2405H5.3875C4.45261 38.2405 3.603 37.8591 2.99218 37.2483C2.38135 36.6375 2 35.7879 2 34.853V19.7719C2 18.837 2.38135 17.9874 2.99218 17.3766C3.603 16.7658 4.45262 16.3844 5.3875 16.3844H11.2991C12.004 16.3844 12.6841 16.1246 13.2095 15.6546L24.1538 5.86289ZM25.8 9.75731L15.8766 18.6356C14.6178 19.7618 12.9881 20.3844 11.2991 20.3844H6V34.2405H11.1518C12.8302 34.2405 14.4505 34.8553 15.7064 35.9688L25.8 44.9184V9.75731Z" /><path :style="{ opacity: opacity(1) }" d="M38.1519 17.8402L36.992 16.2108L33.7333 18.5304L34.8931 20.1598C36.2942 22.1281 37.1487 24.6457 37.1487 27.4131C37.1487 30.1933 36.2862 32.7214 34.8736 34.6937L33.709 36.3197L36.9609 38.6488L38.1255 37.0229C40.0285 34.366 41.1487 31.0221 41.1487 27.4131C41.1487 23.8207 40.0388 20.4911 38.1519 17.8402Z" /><path :style="{ opacity: opacity(2) }" d="M43.617 8.17398L44.9714 9.64556C49.0913 14.1219 51.6179 20.3637 51.6179 27.2257C51.6179 34.0838 49.0943 40.3223 44.9787 44.798L43.6249 46.2702L40.6805 43.5627L42.0343 42.0905C45.4542 38.3714 47.6179 33.1061 47.6179 27.2257C47.6179 21.3419 45.4516 16.0739 42.0282 12.3544L40.6738 10.8828L43.617 8.17398Z" /></g></svg>
</span>
</span>
</template>
<style>
.vp-audio-reader {
display: inline-block;
color: currentcolor;
cursor: pointer;
}
.vp-audio-reader .icon-audio {
display: inline-block;
width: 1.2em;
height: 1.2em;
margin-left: 0.2em;
vertical-align: middle;
}
.vp-audio-reader,
.vp-audio-reader .icon-audio {
transition: color var(--vp-t-color);
}
.vp-audio-reader:hover,
.vp-audio-reader:hover .icon-audio {
color: var(--vp-c-brand-1);
}
</style>

View File

@ -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<string>
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<string>, 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))
},
}
}

View File

@ -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<never> = md =>
md.inline.ruler.before('link', 'audio-reader', audioReader)

View File

@ -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)

View File

@ -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',
`\

View File

@ -70,6 +70,13 @@ export interface MarkdownPowerPluginOptions {
*/
artPlayer?: boolean
/**
* audioReader
*
* `@[audioReader](url)`
*/
audioReader?: boolean
// code embed
/**
* codepen

View File

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