mirror of
https://github.com/Chocobozzz/PeerTube.git
synced 2026-09-03 20:53:09 -05:00
@@ -19,7 +19,6 @@ import './shared/control-bar/peertube-live-display'
|
||||
import './shared/control-bar/storyboard-plugin'
|
||||
import './shared/control-bar/theater-button'
|
||||
import './shared/control-bar/time-tooltip'
|
||||
import './shared/control-bar/chapters-seek-bar'
|
||||
import './shared/dock/peertube-dock-component'
|
||||
import './shared/dock/peertube-dock-plugin'
|
||||
import './shared/hotkeys/peertube-hotkeys-plugin'
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
|
||||
$slider-height: 3px;
|
||||
$slider-hover-height: 5px;
|
||||
$chapter-marker-size: 9px;
|
||||
|
||||
.vjs-peertube-skin.has-chapter {
|
||||
.vjs-time-tooltip {
|
||||
@@ -17,6 +18,29 @@ $slider-hover-height: 5px;
|
||||
}
|
||||
}
|
||||
|
||||
.vjs-chapter-marker {
|
||||
position: absolute;
|
||||
|
||||
top: math.floor(math.div($chapter-marker-size - $slider-height, 2)) * -1;
|
||||
|
||||
height: $chapter-marker-size;
|
||||
width: $chapter-marker-size;
|
||||
border-radius: $chapter-marker-size;
|
||||
margin-left: math.div($chapter-marker-size, 2) * -1;
|
||||
|
||||
background-color: #fff;
|
||||
cursor: pointer;
|
||||
transition: transform 50ms ease-in-out, border-color 50ms ease-in-out;
|
||||
border: 2px solid transparent;
|
||||
z-index: 101;
|
||||
|
||||
&:hover {
|
||||
transform: scale(1.5);
|
||||
|
||||
border-color: pvar(--primary);
|
||||
}
|
||||
}
|
||||
|
||||
.video-js.vjs-peertube-skin .vjs-control-bar {
|
||||
--first-last-element-margin: 10px;
|
||||
--icon-size: 26px;
|
||||
@@ -248,7 +272,7 @@ $slider-hover-height: 5px;
|
||||
}
|
||||
|
||||
.vjs-slider {
|
||||
background-color: transparent;
|
||||
background-color: rgba(255, 255, 255, 0.2);
|
||||
|
||||
.vjs-play-progress {
|
||||
background: pvar(--pt-player-fg);
|
||||
@@ -278,27 +302,6 @@ $slider-hover-height: 5px;
|
||||
.vjs-caption-toggle-control {
|
||||
width: var(--button-width);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Chapters
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
.vjs-progress .vjs-progress-holder .vjs-chapters-progress {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: 0;
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.vjs-progress-holder .vjs-chapters-progress-part {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
height: 100%;
|
||||
opacity: 0.35;
|
||||
background-color: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
.vjs-peertube-skin.vjs-has-captions .vjs-caption-toggle-control {
|
||||
|
||||
@@ -1,45 +1,47 @@
|
||||
import { VideoChapter } from '@peertube/peertube-models'
|
||||
import videojs from 'video.js'
|
||||
import { ChaptersOptions, VideojsPlayer, VideojsPlugin } from '../../types'
|
||||
import ChaptersProgressBar from './chapters-progress-bar'
|
||||
import { ProgressBarMarkerComponent } from './progress-bar-marker-component'
|
||||
|
||||
const Plugin = videojs.getPlugin('plugin') as typeof VideojsPlugin
|
||||
|
||||
class ChaptersPlugin extends Plugin {
|
||||
declare private chapters: VideoChapter[]
|
||||
declare private progressBar: ChaptersProgressBar
|
||||
declare private markers: ProgressBarMarkerComponent[]
|
||||
|
||||
private activeChapter: VideoChapter
|
||||
|
||||
constructor (player: VideojsPlayer, options: ChaptersOptions) {
|
||||
super(player)
|
||||
|
||||
this.markers = []
|
||||
this.chapters = options.chapters
|
||||
|
||||
this.player.ready(() => {
|
||||
player.addClass('vjs-chapters')
|
||||
|
||||
this.progressBar = new ChaptersProgressBar(player, { chapters: this.chapters })
|
||||
const seekBar = this.getSeekBar()
|
||||
const playProgressBar = seekBar.getChild('playProgressBar')
|
||||
for (const chapter of this.chapters) {
|
||||
if (chapter.timecode === 0) continue
|
||||
|
||||
if (playProgressBar) {
|
||||
const playIndex = seekBar.children().indexOf(playProgressBar)
|
||||
if (playIndex >= 0) {
|
||||
seekBar.addChild(this.progressBar, {}, playIndex)
|
||||
} else {
|
||||
seekBar.addChild(this.progressBar)
|
||||
}
|
||||
} else {
|
||||
seekBar.addChild(this.progressBar)
|
||||
const marker = new ProgressBarMarkerComponent(player, { timecode: chapter.timecode })
|
||||
|
||||
marker.on('mouseenter', () => {
|
||||
this.activeChapter = chapter
|
||||
})
|
||||
|
||||
marker.on('mouseleave', () => {
|
||||
this.activeChapter = undefined
|
||||
})
|
||||
|
||||
this.markers.push(marker)
|
||||
this.getSeekBar().addChild(marker)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
dispose () {
|
||||
if (this.progressBar) {
|
||||
this.getSeekBar().removeChild(this.progressBar)
|
||||
this.progressBar = undefined
|
||||
for (const marker of this.markers) {
|
||||
this.getSeekBar().removeChild(marker)
|
||||
}
|
||||
|
||||
super.dispose()
|
||||
|
||||
@@ -1,143 +0,0 @@
|
||||
/**
|
||||
* @file chapters-progress-bar.ts
|
||||
*/
|
||||
import { VideoChapter } from '@peertube/peertube-models'
|
||||
import videojs from 'video.js'
|
||||
import type { VideojsComponent, VideojsComponentOptions, VideojsPlayer } from '../../types'
|
||||
import { sortBy } from '@peertube/peertube-core-utils'
|
||||
|
||||
const Component = videojs.getComponent('Component') as typeof VideojsComponent
|
||||
|
||||
type ChaptersProgressBarOptions = VideojsComponentOptions & {
|
||||
chapters?: VideoChapter[]
|
||||
segmentGap?: number
|
||||
}
|
||||
|
||||
const clamp = (value: number, min: number, max: number) => Math.min(Math.max(value, min), max)
|
||||
|
||||
class ChaptersProgressBar extends Component {
|
||||
declare private partEls_: HTMLElement[]
|
||||
declare private chapters_: VideoChapter[]
|
||||
declare private updateHandler_: () => void
|
||||
declare options_: ChaptersProgressBarOptions
|
||||
|
||||
constructor (player: VideojsPlayer, options?: ChaptersProgressBarOptions) {
|
||||
super(player, options)
|
||||
|
||||
this.partEls_ = []
|
||||
this.chapters_ = options?.chapters || []
|
||||
this.updateHandler_ = () => this.update()
|
||||
|
||||
this.player().on([ 'durationchange', 'loadedmetadata', 'resize', 'fullscreenchange' ], this.updateHandler_)
|
||||
|
||||
this.update()
|
||||
}
|
||||
|
||||
createEl () {
|
||||
return super.createEl('div', {
|
||||
className: 'vjs-chapters-progress'
|
||||
}, {
|
||||
'aria-hidden': 'true'
|
||||
})
|
||||
}
|
||||
|
||||
setChapters (chapters: VideoChapter[]) {
|
||||
this.chapters_ = chapters || []
|
||||
this.update()
|
||||
}
|
||||
|
||||
private clearSegments_ () {
|
||||
const children = this.partEls_
|
||||
|
||||
for (let i = children.length - 1; i >= 0; i--) {
|
||||
this.el_.removeChild(children[i])
|
||||
}
|
||||
|
||||
this.partEls_.length = 0
|
||||
}
|
||||
|
||||
update () {
|
||||
const chapters = this.chapters_
|
||||
const duration = this.player_.duration()
|
||||
|
||||
if (!chapters || chapters.length === 0 || !isFinite(duration) || duration <= 0) {
|
||||
this.clearSegments_()
|
||||
return
|
||||
}
|
||||
|
||||
const seekBarEl = this.parentComponent_?.el()
|
||||
const seekBarRect = seekBarEl ? videojs.dom.getBoundingClientRect(seekBarEl) : null
|
||||
const gapPx = Math.max(0, this.options_.segmentGap || 0)
|
||||
const gapPxValue = (seekBarRect && seekBarRect.width > 0) ? gapPx : 0
|
||||
const children = this.partEls_
|
||||
let partIndex = 0
|
||||
|
||||
const sortedChapters = sortBy(chapters.slice(), 'timecode')
|
||||
|
||||
if (sortedChapters[0].timecode > 0) {
|
||||
sortedChapters.unshift({ timecode: 0, title: '' })
|
||||
}
|
||||
|
||||
this.requestNamedAnimationFrame('ChaptersProgressBar#update', () => {
|
||||
for (let i = 0; i < sortedChapters.length; i++) {
|
||||
const chapter = sortedChapters[i]
|
||||
const nextChapter = sortedChapters[i + 1]
|
||||
const start = clamp(chapter.timecode, 0, duration)
|
||||
const end = clamp(nextChapter?.timecode ?? duration, 0, duration)
|
||||
|
||||
if (end <= start) {
|
||||
continue
|
||||
}
|
||||
|
||||
const startPercent = (start / duration) * 100
|
||||
const widthPercent = ((end - start) / duration) * 100
|
||||
let part = children[partIndex]
|
||||
|
||||
if (!part) {
|
||||
part = this.el_.appendChild(videojs.dom.createEl('div', {
|
||||
className: 'vjs-chapters-progress-part'
|
||||
})) as HTMLElement
|
||||
children[partIndex] = part
|
||||
}
|
||||
|
||||
const nextStart = start.toFixed(3)
|
||||
const nextEnd = end.toFixed(3)
|
||||
const nextGap = gapPxValue
|
||||
|
||||
if (part.dataset.start === nextStart && part.dataset.end === nextEnd && part.dataset.gap === String(nextGap)) {
|
||||
partIndex++
|
||||
continue
|
||||
}
|
||||
|
||||
part.dataset.start = nextStart
|
||||
part.dataset.end = nextEnd
|
||||
part.dataset.gap = String(nextGap)
|
||||
|
||||
part.style.left = `${startPercent}%`
|
||||
part.style.width = gapPxValue > 0
|
||||
? `calc(${widthPercent}% - ${gapPxValue}px)`
|
||||
: `${widthPercent}%`
|
||||
|
||||
partIndex++
|
||||
}
|
||||
|
||||
for (let i = children.length - 1; i >= partIndex; i--) {
|
||||
this.el_.removeChild(children[i])
|
||||
}
|
||||
|
||||
children.length = partIndex
|
||||
})
|
||||
}
|
||||
|
||||
dispose () {
|
||||
this.partEls_ = null
|
||||
super.dispose()
|
||||
}
|
||||
}
|
||||
|
||||
ChaptersProgressBar.prototype.options_ = {
|
||||
segmentGap: 2
|
||||
}
|
||||
|
||||
Component.registerComponent('ChaptersProgressBar', ChaptersProgressBar as any)
|
||||
export default ChaptersProgressBar
|
||||
@@ -1,42 +0,0 @@
|
||||
/**
|
||||
* @file chapters-seek-bar.ts
|
||||
*/
|
||||
import videojs from 'video.js'
|
||||
import type { VideojsComponent, VideojsComponentOptions, VideojsPlayer } from '../../types'
|
||||
|
||||
type SeekBarConstructor = typeof import('video.js/dist/types/control-bar/progress-control/seek-bar').default
|
||||
|
||||
type ChaptersSeekBarOptions = VideojsComponentOptions & {
|
||||
children?: string[]
|
||||
}
|
||||
|
||||
const Component = videojs.getComponent('Component') as typeof VideojsComponent
|
||||
const SeekBar = videojs.getComponent('SeekBar') as SeekBarConstructor
|
||||
const mergeOptions = videojs.mergeOptions as (...sources: any[]) => any
|
||||
|
||||
class ChaptersSeekBar extends SeekBar {
|
||||
constructor (player: VideojsPlayer, options?: ChaptersSeekBarOptions) {
|
||||
options = mergeOptions(ChaptersSeekBar.prototype.options_, options)
|
||||
|
||||
// Avoid mutating the prototype's `children` array by creating a copy
|
||||
options.children = [ ...options.children ]
|
||||
|
||||
super(player, options as any)
|
||||
}
|
||||
|
||||
createEl () {
|
||||
const el = super.createEl()
|
||||
|
||||
return el
|
||||
}
|
||||
}
|
||||
|
||||
ChaptersSeekBar.prototype.options_ = mergeOptions(SeekBar.prototype.options_, {
|
||||
children: [
|
||||
'loadProgressBar',
|
||||
'playProgressBar'
|
||||
]
|
||||
})
|
||||
|
||||
Component.registerComponent('ChaptersSeekBar', ChaptersSeekBar as any)
|
||||
export default ChaptersSeekBar
|
||||
@@ -0,0 +1,63 @@
|
||||
import videojs from 'video.js'
|
||||
import { ProgressBarMarkerComponentOptions, VideojsClickableComponent, VideojsClickableComponentOptions, VideojsPlayer } from '../../types'
|
||||
|
||||
const ClickableComponent = videojs.getComponent('ClickableComponent') as typeof VideojsClickableComponent
|
||||
|
||||
export class ProgressBarMarkerComponent extends ClickableComponent {
|
||||
declare options_: ProgressBarMarkerComponentOptions & VideojsClickableComponentOptions
|
||||
|
||||
constructor (player: VideojsPlayer, options?: ProgressBarMarkerComponentOptions & VideojsClickableComponentOptions) {
|
||||
super(player, options)
|
||||
|
||||
const updateMarker = () => {
|
||||
if (!this.hasValidDuration()) return
|
||||
|
||||
const el = this.el() as HTMLElement
|
||||
|
||||
el.style.setProperty('left', this.buildLeftStyle())
|
||||
el.style.setProperty('display', 'inline')
|
||||
}
|
||||
this.player().on('durationchange', updateMarker)
|
||||
|
||||
const stopPropagation = (event: Event) => event.stopPropagation()
|
||||
|
||||
this.on([ 'mousedown', 'touchstart' ], stopPropagation)
|
||||
|
||||
this.one('dispose', () => {
|
||||
if (this.player()) this.player().off('durationchange', updateMarker)
|
||||
|
||||
if (this.el()) {
|
||||
this.off([ 'mousedown', 'touchstart' ], stopPropagation)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
createEl () {
|
||||
return videojs.dom.createEl('span', {
|
||||
className: 'vjs-chapter-marker',
|
||||
style: this.hasValidDuration()
|
||||
? `left: ${this.buildLeftStyle()}`
|
||||
: 'display: none;'
|
||||
}) as HTMLButtonElement
|
||||
}
|
||||
|
||||
handleClick (event: Event) {
|
||||
event.stopPropagation()
|
||||
|
||||
if (this.player()) this.player().currentTime(this.options_.timecode)
|
||||
}
|
||||
|
||||
private buildLeftStyle () {
|
||||
return `${(this.options_.timecode / this.player().duration()) * 100}%`
|
||||
}
|
||||
|
||||
private hasValidDuration () {
|
||||
const duration = this.player().duration()
|
||||
|
||||
if (isNaN(duration) || !duration) return false
|
||||
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
videojs.registerComponent('ProgressBarMarkerComponent', ProgressBarMarkerComponent)
|
||||
Reference in New Issue
Block a user