refactor(chapter): PKG-CHAPTER-03 split runtime into typed lifecycle and view modules

This commit is contained in:
Harvey Zhao committed 2026-09-10 23:12:56 +08:00
1 parent a0286c4e95
commit 48660c4e12
23 files changed
+1207 -497

No files matched your search

@@ -0,0 +1,36 @@
import type { Chapter } from './types'
export function normalizeChapters(chapters: Chapter[] = [], duration: number): Chapter[] {
if (!Array.isArray(chapters) || !chapters.length || !Number.isFinite(duration) || duration <= 0)
return []
// Sorting, Infinity replacement and gap insertion intentionally mutate caller data.
chapters.sort((a, b) => a.start - b.start)
for (const [index, chapter] of chapters.entries()) {
if (!chapter || typeof chapter.start !== 'number' || typeof chapter.end !== 'number' || typeof chapter.title !== 'string')
throw new TypeError('Illegal chapter data type')
if (chapter.end === Infinity)
chapter.end = duration
const next = chapters[index + 1]
if (!Number.isFinite(chapter.start) || !Number.isFinite(chapter.end)
|| chapter.start < 0 || chapter.end > duration || chapter.start >= chapter.end
|| (next && chapter.end > next.start)) { throw new Error('Illegal chapter time point') }
}
const first = chapters[0]
const last = chapters[chapters.length - 1]
if (first && first.start > 0)
chapters.unshift({ start: 0, end: first.start, title: '' })
if (last && last.end < duration)
chapters.push({ start: last.end, end: duration, title: '' })
for (let index = 0; index < chapters.length - 1; index++) {
const current = chapters[index]
const next = chapters[index + 1]
if (current && next && current.end !== next.start)
chapters.splice(index + 1, 0, { start: current.end, end: next.start, title: '' })
}
return chapters
}
@@ -1,188 +0,0 @@
import style from './style.less?inline'
export default function artplayerPluginChapter(option = {}) {
return (art) => {
const { $player } = art.template
const { setStyle, append, clamp, query, addClass, removeClass } = art.constructor.utils
const html = `
<div class="art-chapter">
<div class="art-chapter-inner">
<div class="art-progress-hover"></div>
<div class="art-progress-loaded"></div>
<div class="art-progress-played"></div>
</div>
</div>
`
let $chapters = []
const $inner = art.query('.art-control-progress-inner')
const $control = append($inner, '<div class="art-chapters"></div>')
const $title = append($inner, '<div class="art-chapter-title"></div>')
function showTitle({ $chapter, width }) {
const title = $chapter.dataset.title.trim()
if (title) {
$title.textContent = title
const titleWidth = $title.clientWidth
if (width <= titleWidth / 2) {
setStyle($title, 'left', 0)
}
else if (width > $inner.clientWidth - titleWidth / 2) {
setStyle($title, 'left', `${$inner.clientWidth - titleWidth}px`)
}
else {
setStyle($title, 'left', `${width - titleWidth / 2}px`)
}
}
}
function update(chapters = []) {
$chapters = []
$control.textContent = ''
removeClass($player, 'artplayer-plugin-chapter')
if (!Array.isArray(chapters))
return
if (!chapters.length)
return
if (!art.duration)
return
chapters = chapters.sort((a, b) => a.start - b.start)
for (let i = 0; i < chapters.length; i++) {
const chapter = chapters[i]
const nextChapter = chapters[i + 1]
if (chapter.end === Infinity) {
chapter.end = art.duration
}
if (
typeof chapter.start !== 'number'
|| typeof chapter.end !== 'number'
|| typeof chapter.title !== 'string'
) {
throw new TypeError('Illegal chapter data type')
}
if (chapter.start < 0 || chapter.end > art.duration || chapter.start >= chapter.end) {
throw new Error('Illegal chapter time point')
}
if (nextChapter && chapter.end > nextChapter.start) {
throw new Error('Illegal chapter time point')
}
}
if (chapters[0].start > 0) {
chapters.unshift({ start: 0, end: chapters[0].start, title: '' })
}
if (chapters[chapters.length - 1].end < art.duration) {
chapters.push({ start: chapters[chapters.length - 1].end, end: art.duration, title: '' })
}
for (let i = 0; i < chapters.length - 1; i++) {
if (chapters[i].end !== chapters[i + 1].start) {
chapters.splice(i + 1, 0, {
start: chapters[i].end,
end: chapters[i + 1].start,
title: '',
})
}
}
$chapters = chapters.map((chapter) => {
const $chapter = append($control, html)
const start = clamp(chapter.start, 0, art.duration)
const end = clamp(chapter.end, 0, art.duration)
const duration = end - start
const percentage = duration / art.duration
$chapter.dataset.start = start
$chapter.dataset.end = end
$chapter.dataset.duration = duration
$chapter.dataset.title = chapter.title.trim()
$chapter.style.width = `${percentage * 100}%`
return {
$chapter,
$hover: query('.art-progress-hover', $chapter),
$loaded: query('.art-progress-loaded', $chapter),
$played: query('.art-progress-played', $chapter),
}
})
addClass($player, 'artplayer-plugin-chapter')
art.emit('setBar', 'loaded', art.loaded || 0)
}
art.on('setBar', (type, percentage) => {
if (!$chapters.length)
return
for (let i = 0; i < $chapters.length; i++) {
const { $chapter, $loaded, $played, $hover } = $chapters[i]
const $target = {
hover: $hover,
loaded: $loaded,
played: $played,
}[type]
if (!$target)
return
const width = $control.clientWidth * percentage
const currentTime = art.duration * percentage
const duration = Number.parseFloat($chapter.dataset.duration)
const start = Number.parseFloat($chapter.dataset.start)
const end = Number.parseFloat($chapter.dataset.end)
if (currentTime < start) {
setStyle($target, 'width', 0)
}
if (currentTime > end) {
setStyle($target, 'width', '100%')
}
if (currentTime >= start && currentTime <= end) {
const percentage = (currentTime - start) / duration
setStyle($target, 'width', `${percentage * 100}%`)
if (type === 'hover' && width > 0) {
showTitle({ $chapter, width })
}
}
}
})
art.once('video:loadedmetadata', () => update(option.chapters))
return {
name: 'artplayerPluginChapter',
update: ({ chapters }) => update(chapters),
}
}
}
if (typeof document !== 'undefined') {
const id = 'artplayer-plugin-chapter'
let $style = document.getElementById(id)
if (!$style) {
$style = document.createElement('style')
$style.id = id
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => {
document.head.appendChild($style)
})
}
else {
(document.head || document.documentElement).appendChild($style)
}
}
$style.textContent = style
}
@@ -0,0 +1,54 @@
import type Artplayer from 'artplayer'
import type { Bar, Option, Result } from './types'
import { normalizeChapters } from './chapters'
import { createProgress } from './progress'
import { installStyle } from './stylesheet'
export default function artplayerPluginChapter(option: Option = {}) {
return (art: Artplayer): Result => {
const player = art.template.$player
const inner = art.query<HTMLElement>('.art-control-progress-inner')
if (!inner)
throw new Error('Missing ArtPlayer progress container')
const className = 'artplayer-plugin-chapter'
let progress: ReturnType<typeof createProgress> | undefined = createProgress(inner)
function update(chapters?: Option['chapters']) {
if (!progress || art.isDestroy)
return
progress.clear()
player.classList.remove(className)
const normalized = normalizeChapters(chapters, art.duration)
if (!normalized.length)
return
progress.render(normalized, art.duration)
player.classList.add(className)
art.emit('setBar', 'loaded', art.loaded || 0)
}
function setBar(type: Bar, percentage: number) {
if (!art.isDestroy)
progress?.setBar(type, percentage, art.duration)
}
function initialize() {
update(option.chapters)
}
function destroy() {
art.off('setBar', setBar)
art.off('video:loadedmetadata', initialize)
art.off('destroy', destroy)
progress?.destroy()
progress = undefined
player.classList.remove(className)
}
art.on('setBar', setBar)
art.once('video:loadedmetadata', initialize)
art.on('destroy', destroy)
return { name: 'artplayerPluginChapter', update: ({ chapters }) => update(chapters) }
}
}
installStyle()
@@ -0,0 +1,93 @@
import type { Bar, Chapter } from './types'
interface Segment {
start: number
end: number
title: string
bars: Record<Bar, HTMLDivElement>
}
export function createProgress(inner: HTMLElement) {
const create = (parent: HTMLElement, className: string) => {
const element = inner.ownerDocument.createElement('div')
element.className = className
parent.appendChild(element)
return element
}
const control = create(inner, 'art-chapters')
const title = create(inner, 'art-chapter-title')
let segments: Segment[] = []
function clearTitle() {
title.textContent = ''
title.style.left = '0px'
title.style.visibility = 'hidden'
}
function clear() {
segments = []
control.textContent = ''
clearTitle()
}
function render(chapters: Chapter[], duration: number) {
segments = chapters.map((chapter) => {
const element = create(control, 'art-chapter')
const body = create(element, 'art-chapter-inner')
const length = chapter.end - chapter.start
element.dataset.start = String(chapter.start)
element.dataset.end = String(chapter.end)
element.dataset.duration = String(length)
element.dataset.title = chapter.title.trim()
element.style.width = `${length / duration * 100}%`
return {
start: chapter.start,
end: chapter.end,
title: chapter.title.trim(),
bars: {
hover: create(body, 'art-progress-hover'),
loaded: create(body, 'art-progress-loaded'),
played: create(body, 'art-progress-played'),
},
}
})
}
function setBar(type: Bar, percentage: number, duration: number) {
if (type !== 'hover' && type !== 'loaded' && type !== 'played')
return
if (!Number.isFinite(percentage))
return
const currentTime = duration * percentage
let hovered: Segment | undefined
for (const segment of segments) {
const target = segment.bars[type]
target.style.width = currentTime < segment.start
? '0px'
: currentTime > segment.end
? '100%'
: `${(currentTime - segment.start) / (segment.end - segment.start) * 100}%`
if (currentTime >= segment.start && currentTime <= segment.end)
hovered = segment
}
if (type !== 'hover')
return
clearTitle()
const width = control.clientWidth * percentage
if (hovered?.title && width > 0) {
title.textContent = hovered.title
title.style.visibility = 'visible'
title.style.left = `${Math.max(0, Math.min(width - title.clientWidth / 2, inner.clientWidth - title.clientWidth))}px`
}
}
function destroy() {
clear()
control.remove()
title.remove()
}
clearTitle()
return { clear, render, setBar, destroy }
}
@@ -0,0 +1,21 @@
import style from './style.less?inline'
export function installStyle() {
if (typeof document === 'undefined')
return
const id = 'artplayer-plugin-chapter'
const install = () => {
let element = document.getElementById(id)
if (!element) {
element = document.createElement('style')
element.id = id
;(document.head || document.documentElement).appendChild(element)
}
element.textContent = style
}
if (document.getElementById(id) || document.readyState !== 'loading')
install()
else
document.addEventListener('DOMContentLoaded', install, { once: true })
}
@@ -0,0 +1,6 @@
import type artplayerPluginChapter from '../types/artplayer-plugin-chapter'
export type Option = NonNullable<Parameters<typeof artplayerPluginChapter>[0]>
export type Chapter = NonNullable<Option['chapters']>[number]
export type Result = ReturnType<ReturnType<typeof artplayerPluginChapter>>
export type Bar = 'hover' | 'loaded' | 'played'