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/storyboard-plugin'
|
||||||
import './shared/control-bar/theater-button'
|
import './shared/control-bar/theater-button'
|
||||||
import './shared/control-bar/time-tooltip'
|
import './shared/control-bar/time-tooltip'
|
||||||
import './shared/control-bar/chapters-seek-bar'
|
|
||||||
import './shared/dock/peertube-dock-component'
|
import './shared/dock/peertube-dock-component'
|
||||||
import './shared/dock/peertube-dock-plugin'
|
import './shared/dock/peertube-dock-plugin'
|
||||||
import './shared/hotkeys/peertube-hotkeys-plugin'
|
import './shared/hotkeys/peertube-hotkeys-plugin'
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
|
|
||||||
$slider-height: 3px;
|
$slider-height: 3px;
|
||||||
$slider-hover-height: 5px;
|
$slider-hover-height: 5px;
|
||||||
|
$chapter-marker-size: 9px;
|
||||||
|
|
||||||
.vjs-peertube-skin.has-chapter {
|
.vjs-peertube-skin.has-chapter {
|
||||||
.vjs-time-tooltip {
|
.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 {
|
.video-js.vjs-peertube-skin .vjs-control-bar {
|
||||||
--first-last-element-margin: 10px;
|
--first-last-element-margin: 10px;
|
||||||
--icon-size: 26px;
|
--icon-size: 26px;
|
||||||
@@ -248,7 +272,7 @@ $slider-hover-height: 5px;
|
|||||||
}
|
}
|
||||||
|
|
||||||
.vjs-slider {
|
.vjs-slider {
|
||||||
background-color: transparent;
|
background-color: rgba(255, 255, 255, 0.2);
|
||||||
|
|
||||||
.vjs-play-progress {
|
.vjs-play-progress {
|
||||||
background: pvar(--pt-player-fg);
|
background: pvar(--pt-player-fg);
|
||||||
@@ -278,27 +302,6 @@ $slider-hover-height: 5px;
|
|||||||
.vjs-caption-toggle-control {
|
.vjs-caption-toggle-control {
|
||||||
width: var(--button-width);
|
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 {
|
.vjs-peertube-skin.vjs-has-captions .vjs-caption-toggle-control {
|
||||||
|
|||||||
@@ -1,45 +1,47 @@
|
|||||||
import { VideoChapter } from '@peertube/peertube-models'
|
import { VideoChapter } from '@peertube/peertube-models'
|
||||||
import videojs from 'video.js'
|
import videojs from 'video.js'
|
||||||
import { ChaptersOptions, VideojsPlayer, VideojsPlugin } from '../../types'
|
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
|
const Plugin = videojs.getPlugin('plugin') as typeof VideojsPlugin
|
||||||
|
|
||||||
class ChaptersPlugin extends Plugin {
|
class ChaptersPlugin extends Plugin {
|
||||||
declare private chapters: VideoChapter[]
|
declare private chapters: VideoChapter[]
|
||||||
declare private progressBar: ChaptersProgressBar
|
declare private markers: ProgressBarMarkerComponent[]
|
||||||
|
|
||||||
private activeChapter: VideoChapter
|
private activeChapter: VideoChapter
|
||||||
|
|
||||||
constructor (player: VideojsPlayer, options: ChaptersOptions) {
|
constructor (player: VideojsPlayer, options: ChaptersOptions) {
|
||||||
super(player)
|
super(player)
|
||||||
|
|
||||||
|
this.markers = []
|
||||||
this.chapters = options.chapters
|
this.chapters = options.chapters
|
||||||
|
|
||||||
this.player.ready(() => {
|
this.player.ready(() => {
|
||||||
player.addClass('vjs-chapters')
|
player.addClass('vjs-chapters')
|
||||||
|
|
||||||
this.progressBar = new ChaptersProgressBar(player, { chapters: this.chapters })
|
for (const chapter of this.chapters) {
|
||||||
const seekBar = this.getSeekBar()
|
if (chapter.timecode === 0) continue
|
||||||
const playProgressBar = seekBar.getChild('playProgressBar')
|
|
||||||
|
|
||||||
if (playProgressBar) {
|
const marker = new ProgressBarMarkerComponent(player, { timecode: chapter.timecode })
|
||||||
const playIndex = seekBar.children().indexOf(playProgressBar)
|
|
||||||
if (playIndex >= 0) {
|
marker.on('mouseenter', () => {
|
||||||
seekBar.addChild(this.progressBar, {}, playIndex)
|
this.activeChapter = chapter
|
||||||
} else {
|
})
|
||||||
seekBar.addChild(this.progressBar)
|
|
||||||
}
|
marker.on('mouseleave', () => {
|
||||||
} else {
|
this.activeChapter = undefined
|
||||||
seekBar.addChild(this.progressBar)
|
})
|
||||||
|
|
||||||
|
this.markers.push(marker)
|
||||||
|
this.getSeekBar().addChild(marker)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
dispose () {
|
dispose () {
|
||||||
if (this.progressBar) {
|
for (const marker of this.markers) {
|
||||||
this.getSeekBar().removeChild(this.progressBar)
|
this.getSeekBar().removeChild(marker)
|
||||||
this.progressBar = undefined
|
|
||||||
}
|
}
|
||||||
|
|
||||||
super.dispose()
|
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