Compare commits
8 Commits
Author | SHA1 | Date |
---|---|---|
Derek | e7152df1ae | |
Derek | 9bf55ea199 | |
Derek | aacbd0bc02 | |
Derek | 59cc14db76 | |
Derek | 95d8110e68 | |
Derek | d574a314ed | |
Derek | 64c248f762 | |
Derek | e44f704012 |
|
@ -1,5 +1,11 @@
|
|||
# Changelog
|
||||
|
||||
## Differences from upstream
|
||||
### vtopia1
|
||||
+ Refactor wording involving "live" to "stream" where appropriate (f10a5b0)
|
||||
+ Utilize VHS and [Owncast latency compensation](https://github.com/owncast/vhs-latency-compensator) (9bf55ea)
|
||||
|
||||
|
||||
## v4.3.0
|
||||
|
||||
### Maintenance
|
||||
|
|
|
@ -1,16 +1,16 @@
|
|||
{
|
||||
"name": "peertube-client",
|
||||
"version": "4.3.0",
|
||||
"version": "4.3.0+vtopia1",
|
||||
"private": true,
|
||||
"license": "AGPL-3.0",
|
||||
"author": {
|
||||
"name": "Chocobozzz",
|
||||
"email": "chocobozzz@framasoft.org",
|
||||
"url": "http://github.com/Chocobozzz"
|
||||
"name": "VtopiaLIVE",
|
||||
"email": "admin@votpia.live",
|
||||
"url": "https://social.vtopia.live/@staff"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/Chocobozzz/PeerTube.git"
|
||||
"url": "git+https://code.vtopia.live/Vtopia/VeeTube.git"
|
||||
},
|
||||
"scripts": {
|
||||
"lint": "npm run lint-ts && npm run lint-scss",
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
<div class="root" *ngIf="!ghostMode">
|
||||
<div class="root">
|
||||
<div class="scroller"
|
||||
#scroller myInfiniteScroller [dataObservable]="onDataSubject.asObservable()"
|
||||
(nearOfTop)="onNearTop()" (nearOfBottom)="onNearBottom()" [onItself]="true"
|
||||
|
|
|
@ -9,8 +9,7 @@ import {
|
|||
} from '@app/core'
|
||||
import { HooksService } from '@app/core/plugins/hooks.service'
|
||||
import {
|
||||
Video,
|
||||
VideoChannelSummary,
|
||||
VideoChannel,
|
||||
RoomMessageCreate,
|
||||
RoomMessage as RoomMessageServerModel
|
||||
} from '@shared/models'
|
||||
|
@ -22,12 +21,10 @@ import { RoomService, RoomMessage, Room } from '@app/shared/shared-chat'
|
|||
styleUrls: ['./video-live-chat.component.scss']
|
||||
})
|
||||
export class LiveChatComponent implements OnInit, OnDestroy {
|
||||
@Input() video: Video
|
||||
@Input() channel: VideoChannel
|
||||
@Input() user: User
|
||||
@Input() ghostMode: Boolean = false
|
||||
|
||||
room: Room
|
||||
channel: VideoChannelSummary
|
||||
messages: RoomMessage[] = []
|
||||
componentPagination: ComponentPagination = {
|
||||
currentPage: 1,
|
||||
|
@ -40,8 +37,6 @@ export class LiveChatComponent implements OnInit, OnDestroy {
|
|||
dockMode: 'docked-bottom' | 'docked-right';
|
||||
sending = false
|
||||
|
||||
startFromDate: Date
|
||||
|
||||
constructor(
|
||||
private hooks: HooksService,
|
||||
private notifier: Notifier,
|
||||
|
@ -50,13 +45,8 @@ export class LiveChatComponent implements OnInit, OnDestroy {
|
|||
) {}
|
||||
|
||||
ngOnInit () {
|
||||
this.channel = this.video.channel
|
||||
// TODO: Start of live session when live / viewing a replay
|
||||
this.startFromDate = new Date(Date.now() - (15 * 60 * 1000))
|
||||
|
||||
this.loadRoom(this.channel.roomId)
|
||||
this.subscribeToRoom(this.channel.roomId)
|
||||
|
||||
this.loadMoreMessages()
|
||||
|
||||
fromEvent(window, 'resize')
|
||||
|
@ -101,8 +91,7 @@ export class LiveChatComponent implements OnInit, OnDestroy {
|
|||
loadMoreMessages () {
|
||||
const params = {
|
||||
roomId: this.channel.roomId,
|
||||
componentPagination: this.componentPagination,
|
||||
afterDate: this.startFromDate
|
||||
componentPagination: this.componentPagination
|
||||
}
|
||||
|
||||
const obs = this.hooks.wrapObsFun(
|
||||
|
|
|
@ -11,17 +11,16 @@
|
|||
<img class="placeholder-image" *ngIf="playerPlaceholderImgSrc" [src]="playerPlaceholderImgSrc" alt="Placeholder image" i18n-alt>
|
||||
</div>
|
||||
|
||||
<my-live-chat *ngIf="video?.isLive && video?.channel?.roomId"
|
||||
[video]="video"
|
||||
[user]="user"
|
||||
[ghostMode]="theaterEnabled"
|
||||
></my-live-chat>
|
||||
|
||||
<my-video-watch-playlist
|
||||
#videoWatchPlaylist [playlist]="playlist"
|
||||
(noVideoFound)="onPlaylistNoVideoFound()" (videoFound)="onPlaylistVideoFound($event)"
|
||||
></my-video-watch-playlist>
|
||||
|
||||
<my-live-chat *ngIf="video?.isLive && video?.channel?.roomId"
|
||||
[channel]="video.channel"
|
||||
[user]="user"
|
||||
></my-live-chat>
|
||||
|
||||
<my-plugin-placeholder pluginId="player-next"></my-plugin-placeholder>
|
||||
</div>
|
||||
|
||||
|
|
|
@ -31,6 +31,7 @@ import {
|
|||
HTMLServerConfig,
|
||||
HttpStatusCode,
|
||||
LiveVideo,
|
||||
LiveVideoLatencyMode,
|
||||
PeerTubeProblemDocument,
|
||||
ServerErrorCode,
|
||||
VideoCaption,
|
||||
|
@ -671,8 +672,16 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
|
|||
if (urlOptions.playerMode === 'p2p-media-loader') mode = 'p2p-media-loader'
|
||||
else mode = 'webtorrent'
|
||||
} else {
|
||||
if (video.hasHlsPlaylist()) mode = 'p2p-media-loader'
|
||||
else mode = 'webtorrent'
|
||||
if (video.hasHlsPlaylist()) {
|
||||
if (liveOptions?.latencyMode === LiveVideoLatencyMode.SMALL_LATENCY) {
|
||||
mode = 'lowlatency'
|
||||
} else {
|
||||
mode = 'p2p-media-loader'
|
||||
}
|
||||
}
|
||||
else {
|
||||
mode = 'webtorrent'
|
||||
}
|
||||
}
|
||||
|
||||
// p2p-media-loader needs TextEncoder, fallback on WebTorrent if not available
|
||||
|
@ -694,6 +703,16 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
|
|||
Object.assign(options, { p2pMediaLoader })
|
||||
}
|
||||
|
||||
if (mode === 'lowlatency') {
|
||||
const hlsPlaylist = video.getHlsPlaylist()
|
||||
|
||||
const vhsMediaLoader = {
|
||||
playlistUrl: hlsPlaylist.playlistUrl,
|
||||
}
|
||||
|
||||
Object.assign(options, { vhsMediaLoader })
|
||||
}
|
||||
|
||||
return { playerMode: mode, playerOptions: options }
|
||||
}
|
||||
|
||||
|
|
|
@ -36,10 +36,9 @@ export class RoomService {
|
|||
|
||||
getMessages (parameters: {
|
||||
roomId: number | string,
|
||||
componentPagination: ComponentPaginationLight,
|
||||
afterDate?: Date,
|
||||
componentPagination: ComponentPaginationLight
|
||||
}): Observable<ResultList<RoomMessage>> {
|
||||
const { roomId, componentPagination, afterDate } = parameters
|
||||
const { roomId, componentPagination } = parameters
|
||||
|
||||
const url = RoomService.BASE_ROOM_URL + roomId + '/messages'
|
||||
|
||||
|
@ -48,8 +47,6 @@ export class RoomService {
|
|||
let params = new HttpParams()
|
||||
params = this.restService.addRestGetParams(params, pagination)
|
||||
|
||||
if (afterDate) params = params.append('afterDate', afterDate.toISOString())
|
||||
|
||||
return this.authHttp.get<ResultList<RoomMessage>>(url, { params })
|
||||
.pipe(
|
||||
map(data => this.extractMessages(data)),
|
||||
|
|
|
@ -70,6 +70,13 @@ export class PeertubePlayerManager {
|
|||
|
||||
this.p2pMediaLoaderModule = p2pMediaLoaderModule
|
||||
}
|
||||
if (mode === 'lowlatency') {
|
||||
await Promise.all([
|
||||
import('./shared/vhs-loader/vhs-loader-plugin'),
|
||||
import('./shared/vhs-loader/latencyCompensator')
|
||||
])
|
||||
Object.assign(options.common, { p2pEnabled: false })
|
||||
}
|
||||
|
||||
await TranslationsManager.loadLocaleInVideoJS(options.common.serverUrl, options.common.language, videojs)
|
||||
|
||||
|
|
|
@ -74,7 +74,7 @@ export class ControlBarOptionsBuilder {
|
|||
private getSettingsButton () {
|
||||
const settingEntries: string[] = []
|
||||
|
||||
settingEntries.push('playbackRateMenuButton')
|
||||
if (!this.options.isLive) settingEntries.push('playbackRateMenuButton')
|
||||
|
||||
if (this.options.captions === true) settingEntries.push('captionsButton')
|
||||
|
||||
|
|
|
@ -9,6 +9,7 @@ import { CommonOptions, PeertubePlayerManagerOptions, PlayerMode } from '../../t
|
|||
import { ControlBarOptionsBuilder } from './control-bar-options-builder'
|
||||
import { HLSOptionsBuilder } from './hls-options-builder'
|
||||
import { WebTorrentOptionsBuilder } from './webtorrent-options-builder'
|
||||
import { VHSOptionsBuilder } from './vhs-options-builder'
|
||||
|
||||
export class ManagerOptionsBuilder {
|
||||
|
||||
|
@ -72,6 +73,12 @@ export class ManagerOptionsBuilder {
|
|||
|
||||
// WebTorrent plugin handles autoplay, because we do some hackish stuff in there
|
||||
autoplay = false
|
||||
} else if (this.mode === 'lowlatency') {
|
||||
const vhsOptionsBuilder = new VHSOptionsBuilder(this.options)
|
||||
const options = vhsOptionsBuilder.getPluginOptions()
|
||||
|
||||
Object.assign(plugins, pick(options, ['vhsLoader', 'latencyCompensator']))
|
||||
Object.assign(html5, options.html5)
|
||||
}
|
||||
|
||||
const controlBarOptionsBuilder = new ControlBarOptionsBuilder(this.options, this.mode)
|
||||
|
@ -92,7 +99,7 @@ export class ManagerOptionsBuilder {
|
|||
|
||||
poster: commonOptions.poster,
|
||||
inactivityTimeout: commonOptions.inactivityTimeout,
|
||||
playbackRates: [ 0.5, 0.75, 1, 1.25, 1.5, 1.75, 2 ],
|
||||
playbackRates: commonOptions.isLive !== true ? [ 0.5, 0.75, 1, 1.25, 1.5, 1.75, 2 ] : null,
|
||||
|
||||
plugins,
|
||||
|
||||
|
|
|
@ -0,0 +1,44 @@
|
|||
import { PeertubePlayerManagerOptions, LatencyCompensatorOptions } from '../../types/manager-options'
|
||||
|
||||
export class VHSOptionsBuilder {
|
||||
|
||||
constructor(
|
||||
private options: PeertubePlayerManagerOptions,
|
||||
) {
|
||||
|
||||
}
|
||||
|
||||
getPluginOptions() {
|
||||
|
||||
const vhsLoader = {
|
||||
type: 'application/x-mpegURL',
|
||||
src: this.options.vhsMediaLoader.playlistUrl,
|
||||
}
|
||||
|
||||
const latencyCompensator: LatencyCompensatorOptions = {
|
||||
rebuffer_event_limit: 3,
|
||||
min_buffer_duration: 100,
|
||||
max_speedup_rate: 1.03,
|
||||
max_speedup_ramp: 0.01,
|
||||
timeout_duration: 30 * 1000,
|
||||
check_timer_interval: 3 * 1000,
|
||||
buffering_amnesty_duration: 3 * 1000 * 60,
|
||||
required_bandwidth_ratio: 2.0,
|
||||
highest_latency_segment_length_multiplier: 2,
|
||||
lowest_latency_segment_length_multiplier: 1,
|
||||
min_latency: 2 * 1000,
|
||||
max_latency: 8 * 1000,
|
||||
max_jump_latency: 10 * 1000,
|
||||
max_jump_frequency: 30 * 1000,
|
||||
startup_wait_time: 10 * 1000,
|
||||
}
|
||||
|
||||
const html5 = {
|
||||
vhs: {
|
||||
allowSeeksWithinUnsafeLiveWindow: true,
|
||||
}
|
||||
}
|
||||
|
||||
return { vhsLoader, html5, latencyCompensator }
|
||||
}
|
||||
}
|
|
@ -59,6 +59,11 @@ class MetricsPlugin extends Plugin {
|
|||
fps = framerate
|
||||
? parseInt(framerate, 10)
|
||||
: undefined
|
||||
} else if (this.mode == 'lowlatency') {
|
||||
const level = this.player.vhsLoader().getCurrentLevel()
|
||||
if (!level) return
|
||||
|
||||
resolution = Math.min(level.height || 0, level.width || 0)
|
||||
} else { // webtorrent
|
||||
const videoFile = this.player.webtorrent().getCurrentVideoFile()
|
||||
if (!videoFile) return
|
||||
|
|
|
@ -31,12 +31,14 @@ const registerSourceHandler = function (vjs: typeof videojs) {
|
|||
|
||||
// FIXME: typings
|
||||
(html5 as any).registerSourceHandler({
|
||||
canHandleSource: function (source: videojs.Tech.SourceObject) {
|
||||
const hlsTypeRE = /^application\/x-mpegURL|application\/vnd\.apple\.mpegurl$/i
|
||||
const hlsExtRE = /\.m3u8/i
|
||||
canHandleSource: function(source: videojs.Tech.SourceObject, options: HlsjsConfigHandlerOptions) {
|
||||
if (options.hlsjsConfig) { // Only consider using hlsjs if it is configured
|
||||
const hlsTypeRE = /^application\/x-mpegURL|application\/vnd\.apple\.mpegurl$/i
|
||||
const hlsExtRE = /\.m3u8/i
|
||||
|
||||
if (hlsTypeRE.test(source.type)) return 'probably'
|
||||
if (hlsExtRE.test(source.src)) return 'maybe'
|
||||
if (hlsTypeRE.test(source.type)) return 'probably'
|
||||
if (hlsExtRE.test(source.src)) return 'maybe'
|
||||
}
|
||||
|
||||
return ''
|
||||
},
|
||||
|
|
|
@ -7,7 +7,7 @@ import { bytes } from '../common'
|
|||
interface StatsCardOptions extends videojs.ComponentOptions {
|
||||
videoUUID: string
|
||||
videoIsLive: boolean
|
||||
mode: 'webtorrent' | 'p2p-media-loader'
|
||||
mode: 'webtorrent' | 'p2p-media-loader' | 'lowlatency'
|
||||
p2pEnabled: boolean
|
||||
}
|
||||
|
||||
|
@ -34,7 +34,7 @@ class StatsCard extends Component {
|
|||
|
||||
updateInterval: any
|
||||
|
||||
mode: 'webtorrent' | 'p2p-media-loader'
|
||||
mode: 'webtorrent' | 'p2p-media-loader' | 'lowlatency'
|
||||
|
||||
metadataStore: any = {}
|
||||
|
||||
|
@ -120,9 +120,10 @@ class StatsCard extends Component {
|
|||
|
||||
this.updateInterval = setInterval(async () => {
|
||||
try {
|
||||
const options = this.mode === 'p2p-media-loader'
|
||||
? this.buildHLSOptions()
|
||||
: await this.buildWebTorrentOptions() // Default
|
||||
let options;
|
||||
if (this.mode === 'p2p-media-loader') options = this.buildHLSOptions()
|
||||
else if (this.mode === 'lowlatency') options = this.buildVHSOptions()
|
||||
else options = await this.buildWebTorrentOptions()
|
||||
|
||||
this.populateInfoValues(options)
|
||||
} catch (err) {
|
||||
|
@ -170,6 +171,33 @@ class StatsCard extends Component {
|
|||
}
|
||||
}
|
||||
|
||||
private buildVHSOptions () {
|
||||
const mediaLoader = this.player_.vhsLoader()
|
||||
const level = mediaLoader.getCurrentLevel()
|
||||
const codecs = level?.playlist.attributes.CODECS
|
||||
|
||||
const resolution = level ? `${level.height}p` : undefined
|
||||
const buffer = this.timeRangesToString(this.player().buffered())
|
||||
|
||||
let progress: number
|
||||
let latency: string
|
||||
|
||||
if (this.options_.videoIsLive) {
|
||||
latency = secondsToTime(mediaLoader.getLiveLatency())
|
||||
} else {
|
||||
progress = this.player().bufferedPercent()
|
||||
}
|
||||
|
||||
return {
|
||||
playerNetworkInfo: this.playerNetworkInfo,
|
||||
resolution,
|
||||
codecs,
|
||||
buffer,
|
||||
latency,
|
||||
progress
|
||||
}
|
||||
}
|
||||
|
||||
private async buildWebTorrentOptions () {
|
||||
const videoFile = this.player_.webtorrent().getCurrentVideoFile()
|
||||
|
||||
|
|
|
@ -0,0 +1,32 @@
|
|||
import videojs from 'video.js'
|
||||
import { VhsMetadataCue, VideoJSTechVHS } from '../../types'
|
||||
|
||||
export function getCurrentlyPlayingSegment (player: videojs.Player) {
|
||||
const currentCue = getCurrentMetadataCue(player)
|
||||
if (!currentCue) return undefined
|
||||
|
||||
const tech = player.tech(false) as VideoJSTechVHS
|
||||
|
||||
const currentRep = tech.vhs.representations().find(
|
||||
(level) => level.playlist.id == currentCue.value.playlist
|
||||
)
|
||||
const currentSegment = currentRep.playlist.segments.find(
|
||||
(segment) => currentCue.value.start == segment.start
|
||||
)
|
||||
|
||||
return currentSegment
|
||||
}
|
||||
|
||||
export function getCurrentMetadataCue (player: videojs.Player) {
|
||||
const tracks = player.textTracks()
|
||||
|
||||
let currentCue: VhsMetadataCue
|
||||
|
||||
for (let i = 0; i < tracks.length; i++) {
|
||||
if (tracks[i].label === 'segment-metadata') {
|
||||
currentCue = tracks[i].activeCues[0] as VhsMetadataCue;
|
||||
}
|
||||
}
|
||||
|
||||
return currentCue
|
||||
}
|
|
@ -0,0 +1,439 @@
|
|||
/*
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2020 Gabe Kangas
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
*/
|
||||
/*
|
||||
The Owncast Latency Compensator.
|
||||
|
||||
It will try to slowly adjust the playback rate to enable the player to get
|
||||
further into the future, with the goal of being as close to the live edge as
|
||||
possible, without causing any buffering events.
|
||||
|
||||
How does latency occur?
|
||||
Two pieces are at play. The first being the server. The larger each segment is
|
||||
that is being generated by Owncast, the larger gap you are going to be from
|
||||
live when you begin playback.
|
||||
|
||||
Second is your media player.
|
||||
The player tries to play every segment as it comes in.
|
||||
However, your computer is not always 100% in playing things in real time, and
|
||||
there are natural stutters in playback. So if one frame is delayed in playback
|
||||
you may not see it visually, but now you're one frame behind. Eventually this
|
||||
can compound and you can be many seconds behind.
|
||||
|
||||
How to help with this? The Owncast Latency Compensator will:
|
||||
- Determine the start (max) and end (min) latency values.
|
||||
- Keep an eye on download speed and stop compensating if it drops too low.
|
||||
- Limit the playback speedup rate so it doesn't sound weird by jumping speeds.
|
||||
- Force a large jump to into the future once compensation begins.
|
||||
- Dynamically calculate the speedup rate based on network speed.
|
||||
- Pause the compensation if buffering events occur.
|
||||
- Completely give up on all compensation if too many buffering events occur.
|
||||
*/
|
||||
/*
|
||||
Modifications from upstream:
|
||||
+ Typescript (wont be upstreamed)
|
||||
+ Constants => paramaters
|
||||
+ Don't assume the live edge is the current time (better handle sub-1x transcodes)
|
||||
*/
|
||||
import videojs from 'video.js'
|
||||
import { LatencyCompensatorOptions, VideoJSTechVHS } from '../../types';
|
||||
import { getCurrentlyPlayingSegment } from './common'
|
||||
|
||||
const Plugin = videojs.getPlugin('plugin')
|
||||
const logger = videojs.log.createLogger('LatencyCompensatorPlugin')
|
||||
|
||||
class LatencyCompensatorPlugin extends Plugin {
|
||||
player: videojs.Player
|
||||
enabled = false
|
||||
running = false
|
||||
inTimeout = false
|
||||
jumpingToLiveIgnoreBuffer = false
|
||||
timeoutTimer: number
|
||||
checkTimer: number
|
||||
bufferingCounter = 0
|
||||
bufferingTimer: number
|
||||
playbackRate = 1.0
|
||||
lastJumpOccurred: number
|
||||
startupTime: Date
|
||||
performedInitialLiveJump = false
|
||||
|
||||
private readonly options: LatencyCompensatorOptions
|
||||
|
||||
constructor(player: videojs.Player, options?: LatencyCompensatorOptions) {
|
||||
super(player)
|
||||
|
||||
this.player = player
|
||||
this.options = options
|
||||
|
||||
this.lastJumpOccurred = null;
|
||||
this.startupTime = new Date();
|
||||
|
||||
this.player.on('playing', this.handlePlaying.bind(this));
|
||||
this.player.on('error', this.handleError.bind(this));
|
||||
this.player.on('waiting', this.handleBuffering.bind(this));
|
||||
this.player.on('stalled', this.handleBuffering.bind(this));
|
||||
this.player.on('ended', this.handleEnded.bind(this));
|
||||
this.player.on('canplaythrough', this.handlePlaying.bind(this));
|
||||
this.player.on('canplay', this.handlePlaying.bind(this));
|
||||
}
|
||||
|
||||
dispose() {
|
||||
this.disable()
|
||||
}
|
||||
|
||||
// This is run on a timer to check if we should be compensating for latency.
|
||||
check() {
|
||||
// We have an arbitrary delay at startup to allow the player to run
|
||||
// normally and hopefully get a bit of a buffer of segments before we
|
||||
// start messing with it.
|
||||
if (new Date().getTime() - this.startupTime.getTime() < this.options.startup_wait_time) {
|
||||
return;
|
||||
}
|
||||
|
||||
// If we're paused then do nothing.
|
||||
if (this.player.paused()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.player.seeking()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.inTimeout) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const tech = this.player.tech({ IWillNotUseThisInPlugins: true }) as VideoJSTechVHS;
|
||||
|
||||
// We need access to the internal tech of VHS to move forward.
|
||||
// If running under an Apple browser that uses CoreMedia (Safari)
|
||||
// we do not have access to this as the tech is internal to the OS.
|
||||
if (!tech || !tech.vhs) {
|
||||
logger.error('Native player!')
|
||||
this.disable()
|
||||
return;
|
||||
}
|
||||
|
||||
// Network state 2 means we're actively using the network.
|
||||
// We only want to attempt latency compensation if we're continuing to
|
||||
// download new segments.
|
||||
const networkState = this.player.networkState();
|
||||
if (networkState !== 2) {
|
||||
return;
|
||||
}
|
||||
|
||||
let totalBuffered = 0;
|
||||
|
||||
try {
|
||||
// Check the player buffers to make sure there's enough playable content
|
||||
// that we can safely play.
|
||||
if (tech.vhs.stats.buffered.length === 0) {
|
||||
this.timeout();
|
||||
return;
|
||||
}
|
||||
|
||||
tech.vhs.stats.buffered.forEach((buffer: any) => {
|
||||
totalBuffered += buffer.end - buffer.start;
|
||||
});
|
||||
} catch (e) { }
|
||||
|
||||
// Determine how much of the current playlist's bandwidth requirements
|
||||
// we're utilizing. If it's too high then we can't afford to push
|
||||
// further into the future because we're downloading too slowly.
|
||||
const currentPlaylist = tech.vhs.playlists.media();
|
||||
const currentPlaylistBandwidth = currentPlaylist.attributes.BANDWIDTH;
|
||||
const playerBandwidth = tech.vhs.systemBandwidth;
|
||||
const bandwidthRatio = playerBandwidth / currentPlaylistBandwidth;
|
||||
|
||||
try {
|
||||
const segment = getCurrentlyPlayingSegment(this.player);
|
||||
if (!segment) {
|
||||
return;
|
||||
}
|
||||
|
||||
// If we're downloading media fast enough or we feel like we have a large
|
||||
// enough buffer then continue. Otherwise timeout for a bit.
|
||||
if (
|
||||
bandwidthRatio < this.options.required_bandwidth_ratio &&
|
||||
totalBuffered < segment.duration * 6
|
||||
) {
|
||||
this.timeout();
|
||||
return;
|
||||
}
|
||||
|
||||
// How far away from live edge do we stop the compensator.
|
||||
const minLatencyThreshold = Math.max(
|
||||
this.options.min_latency,
|
||||
segment.duration * 1000 * this.options.lowest_latency_segment_length_multiplier
|
||||
);
|
||||
|
||||
// How far away from live edge do we start the compensator.
|
||||
const maxLatencyThreshold = Math.max(
|
||||
minLatencyThreshold * 1.4,
|
||||
Math.min(
|
||||
segment.duration * 1000 * this.options.highest_latency_segment_length_multiplier,
|
||||
this.options.max_latency
|
||||
)
|
||||
);
|
||||
|
||||
const liveEdge = currentPlaylist.segments[currentPlaylist.segments.length - 1];
|
||||
|
||||
// Calculate latency as the distance between the playhead and the (idealized) live edge
|
||||
const now = new Date().getTime();
|
||||
const playheadOffset = (tech.currentTime() - segment.start) * 1000;
|
||||
const liveEdgeOffset = now - currentPlaylist.lastRequest;
|
||||
|
||||
const segmentTime = segment.dateTimeObject.getTime();
|
||||
const liveEdgeTime = liveEdge.dateTimeObject.getTime();
|
||||
|
||||
const latency = (liveEdgeTime + liveEdgeOffset) - (segmentTime + playheadOffset);
|
||||
|
||||
if (latency > maxLatencyThreshold) {
|
||||
// If the current latency exceeds the max jump amount then
|
||||
// force jump into the future, skipping all the video in between.
|
||||
if (
|
||||
this.shouldJumpToLive() &&
|
||||
latency > maxLatencyThreshold + this.options.max_jump_latency
|
||||
) {
|
||||
const jumpAmount = latency / 1000 - segment.duration * 3;
|
||||
logger.debug('jump amount', jumpAmount);
|
||||
const seekPosition = this.player.currentTime() + jumpAmount;
|
||||
logger.debug(
|
||||
'latency',
|
||||
latency / 1000,
|
||||
'jumping to live from ',
|
||||
this.player.currentTime(),
|
||||
' to ',
|
||||
seekPosition
|
||||
);
|
||||
|
||||
// Verify we have the seek position buffered before jumping.
|
||||
const availableBufferedTimeEnd = tech.vhs.stats.buffered[0].end;
|
||||
const availableBufferedTimeStart = tech.vhs.stats.buffered[0].start;
|
||||
if (
|
||||
seekPosition > availableBufferedTimeStart
|
||||
&& seekPosition < availableBufferedTimeEnd
|
||||
) {
|
||||
this.jump(seekPosition);
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Using our bandwidth ratio determine a wide guess at how fast we can play.
|
||||
var proposedPlaybackRate = bandwidthRatio * 0.33;
|
||||
|
||||
// But limit the playback rate to a max value.
|
||||
proposedPlaybackRate = Math.max(
|
||||
Math.min(proposedPlaybackRate, this.options.max_speedup_rate),
|
||||
1.0
|
||||
);
|
||||
|
||||
if (proposedPlaybackRate > this.playbackRate + this.options.max_speedup_ramp) {
|
||||
// If this proposed speed is substantially faster than the current rate,
|
||||
// then allow us to ramp up by using a slower value for now.
|
||||
proposedPlaybackRate = this.playbackRate + this.options.max_speedup_ramp;
|
||||
}
|
||||
|
||||
// Limit to 3 decimal places of precision.
|
||||
proposedPlaybackRate =
|
||||
Math.round(proposedPlaybackRate * Math.pow(10, 3)) / Math.pow(10, 3);
|
||||
|
||||
// Otherwise start the playback rate adjustment.
|
||||
this.start(proposedPlaybackRate);
|
||||
} else if (latency <= minLatencyThreshold) {
|
||||
this.stop();
|
||||
}
|
||||
|
||||
logger.debug(
|
||||
'latency',
|
||||
latency / 1000,
|
||||
'min',
|
||||
minLatencyThreshold / 1000,
|
||||
'max',
|
||||
maxLatencyThreshold / 1000,
|
||||
'playback rate',
|
||||
this.playbackRate,
|
||||
'enabled:',
|
||||
this.enabled,
|
||||
'running: ',
|
||||
this.running,
|
||||
'timeout: ',
|
||||
this.inTimeout,
|
||||
'buffers: ',
|
||||
this.bufferingCounter
|
||||
);
|
||||
} catch (err) {
|
||||
logger.error(err);
|
||||
}
|
||||
}
|
||||
|
||||
shouldJumpToLive() {
|
||||
const now = new Date().getTime();
|
||||
const delta = now - this.lastJumpOccurred;
|
||||
return delta > this.options.max_jump_frequency;
|
||||
}
|
||||
|
||||
jump(seekPosition: any) {
|
||||
this.jumpingToLiveIgnoreBuffer = true;
|
||||
this.performedInitialLiveJump = true;
|
||||
|
||||
this.lastJumpOccurred = new Date().getTime();
|
||||
|
||||
logger.debug(
|
||||
'current time',
|
||||
this.player.currentTime(),
|
||||
'seeking to',
|
||||
seekPosition
|
||||
);
|
||||
this.player.currentTime(seekPosition);
|
||||
|
||||
window.setTimeout(() => {
|
||||
this.jumpingToLiveIgnoreBuffer = false;
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
setPlaybackRate(rate: number) {
|
||||
this.playbackRate = rate;
|
||||
this.player.playbackRate(rate);
|
||||
}
|
||||
|
||||
start(rate = 1.0) {
|
||||
if (this.inTimeout || !this.enabled || rate === this.playbackRate) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.running = true;
|
||||
this.setPlaybackRate(rate);
|
||||
}
|
||||
|
||||
stop() {
|
||||
if (this.running) {
|
||||
logger.debug('stopping latency compensator...');
|
||||
}
|
||||
this.running = false;
|
||||
this.setPlaybackRate(1.0);
|
||||
}
|
||||
|
||||
enable() {
|
||||
this.enabled = true;
|
||||
clearInterval(this.checkTimer);
|
||||
clearTimeout(this.bufferingTimer);
|
||||
|
||||
this.checkTimer = window.setInterval(() => {
|
||||
this.check();
|
||||
}, this.options.check_timer_interval);
|
||||
}
|
||||
|
||||
// Disable means we're done for good and should no longer compensate for latency.
|
||||
disable() {
|
||||
clearInterval(this.checkTimer);
|
||||
clearTimeout(this.timeoutTimer);
|
||||
this.stop();
|
||||
this.enabled = false;
|
||||
}
|
||||
|
||||
timeout() {
|
||||
if (this.inTimeout) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.jumpingToLiveIgnoreBuffer) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.inTimeout = true;
|
||||
this.stop();
|
||||
|
||||
clearTimeout(this.timeoutTimer);
|
||||
this.timeoutTimer = window.setTimeout(() => {
|
||||
this.endTimeout();
|
||||
}, this.options.timeout_duration);
|
||||
}
|
||||
|
||||
endTimeout() {
|
||||
clearTimeout(this.timeoutTimer);
|
||||
this.inTimeout = false;
|
||||
}
|
||||
|
||||
handlePlaying() {
|
||||
clearTimeout(this.bufferingTimer);
|
||||
this.enable()
|
||||
}
|
||||
|
||||
handleEnded() {
|
||||
if (!this.enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.disable();
|
||||
}
|
||||
|
||||
handleError(e: any) {
|
||||
if (!this.enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
logger.error('handle error', e);
|
||||
this.timeout();
|
||||
}
|
||||
|
||||
countBufferingEvent() {
|
||||
this.bufferingCounter++;
|
||||
if (this.bufferingCounter > this.options.rebuffer_event_limit) {
|
||||
this.disable();
|
||||
return;
|
||||
}
|
||||
logger.error('timeout due to buffering');
|
||||
this.timeout();
|
||||
|
||||
// Allow us to forget about old buffering events if enough time goes by.
|
||||
window.setTimeout(() => {
|
||||
if (this.bufferingCounter > 0) {
|
||||
this.bufferingCounter--;
|
||||
}
|
||||
}, this.options.buffering_amnesty_duration);
|
||||
}
|
||||
|
||||
handleBuffering() {
|
||||
if (!this.enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.jumpingToLiveIgnoreBuffer) {
|
||||
this.jumpingToLiveIgnoreBuffer = false;
|
||||
return;
|
||||
}
|
||||
|
||||
this.bufferingTimer = window.setTimeout(() => {
|
||||
this.countBufferingEvent();
|
||||
}, this.options.min_buffer_duration);
|
||||
}
|
||||
}
|
||||
|
||||
videojs.registerPlugin('latencyCompensator', LatencyCompensatorPlugin)
|
||||
export { LatencyCompensatorPlugin }
|
|
@ -0,0 +1,132 @@
|
|||
import videojs from 'video.js'
|
||||
import {
|
||||
PeerTubeResolution, PlayerNetworkInfo,
|
||||
VideoJSTechVHS, VhsLevel
|
||||
} from '../../types'
|
||||
import { getCurrentlyPlayingSegment, getCurrentMetadataCue } from './common'
|
||||
|
||||
const Plugin = videojs.getPlugin('plugin')
|
||||
|
||||
class VhsMediaLoaderPlugin extends Plugin {
|
||||
|
||||
private networkInfoInterval: number
|
||||
private readonly options: any
|
||||
private tech: VideoJSTechVHS
|
||||
private lastTotal = 0
|
||||
private readonly CONSTANTS = {
|
||||
INFO_SCHEDULER: 1000 // Don't change this
|
||||
}
|
||||
|
||||
constructor (player: videojs.Player, options?: any) {
|
||||
super(player)
|
||||
|
||||
this.options = options
|
||||
|
||||
player.src({
|
||||
type: options.type,
|
||||
src: options.src
|
||||
})
|
||||
|
||||
player.ready(() => {
|
||||
this.initializeCore()
|
||||
player.play()
|
||||
this.tech = player.tech(false) as VideoJSTechVHS
|
||||
this.runStats()
|
||||
})
|
||||
}
|
||||
|
||||
dispose () {
|
||||
clearInterval(this.networkInfoInterval)
|
||||
}
|
||||
|
||||
getCurrentLevel () {
|
||||
const currentCue = getCurrentMetadataCue(this.player)
|
||||
if (!currentCue) return undefined
|
||||
|
||||
const currentLevel = this.tech.vhs.representations().find(
|
||||
(level: VhsLevel) => level.playlist.id == currentCue.value.playlist
|
||||
)
|
||||
|
||||
return currentLevel
|
||||
}
|
||||
|
||||
|
||||
getLiveLatency () {
|
||||
if (this.player.paused()) return undefined
|
||||
|
||||
const segment = getCurrentlyPlayingSegment(this.player)
|
||||
if (!segment) return undefined
|
||||
|
||||
const segmentTime = segment.dateTimeObject.getTime()
|
||||
const now = new Date().getTime();
|
||||
const playhead = (this.tech as any).currentTime()
|
||||
const latency = ((now - segmentTime) / 1000) - (playhead - segment.start);
|
||||
|
||||
return latency
|
||||
}
|
||||
|
||||
private runStats () {
|
||||
this.networkInfoInterval = window.setInterval(() => {
|
||||
const delta = this.tech.vhs.stats.mediaBytesTransferred - this.lastTotal
|
||||
this.lastTotal = this.tech.vhs.stats.mediaBytesTransferred
|
||||
|
||||
this.player.trigger('p2pInfo', {
|
||||
source: 'lowlatency',
|
||||
http: {
|
||||
downloadSpeed: delta,
|
||||
uploadSpeed: 0,
|
||||
downloaded: this.lastTotal,
|
||||
uploaded: 0
|
||||
},
|
||||
p2p: {
|
||||
downloadSpeed: 0,
|
||||
uploadSpeed: 0,
|
||||
numPeers: 0,
|
||||
downloaded: 0,
|
||||
uploaded: 0
|
||||
},
|
||||
bandwidthEstimate: this.tech.vhs.stats.bandwidth / 8
|
||||
} as PlayerNetworkInfo)
|
||||
}, this.CONSTANTS.INFO_SCHEDULER)
|
||||
}
|
||||
|
||||
private initializeCore () {
|
||||
this.player.one('play', () => {
|
||||
this.player.addClass('vjs-has-big-play-button-clicked')
|
||||
})
|
||||
|
||||
this.player.on('loadedmetadata', () => {
|
||||
this.buildQualities()
|
||||
})
|
||||
this.player.on('usage', (ev) => console.log(ev))
|
||||
}
|
||||
|
||||
private buildQualities () {
|
||||
const resolutions: PeerTubeResolution[] = this.tech.vhs.representations().map((level, index) => ({
|
||||
id: index,
|
||||
label: `${level.height}p`,
|
||||
height: level.height,
|
||||
selected: false,
|
||||
selectCallback: () => this.changeQuality(level.id)
|
||||
}))
|
||||
|
||||
resolutions.push({
|
||||
id: -1,
|
||||
label: this.player.localize('Auto'),
|
||||
selected: true,
|
||||
selectCallback: () => this.changeQuality()
|
||||
})
|
||||
|
||||
this.player.peertubeResolutions().add(resolutions)
|
||||
}
|
||||
|
||||
changeQuality (levelId?: string) {
|
||||
this.tech.vhs.representations().forEach(level => {
|
||||
if (!levelId) level.enabled(true)
|
||||
else level.enabled(level.id == levelId)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
videojs.registerPlugin('vhsLoader', VhsMediaLoaderPlugin)
|
||||
export { VhsMediaLoaderPlugin }
|
|
@ -2,7 +2,7 @@ import { PluginsManager } from '@root-helpers/plugins-manager'
|
|||
import { LiveVideoLatencyMode, VideoFile } from '@shared/models'
|
||||
import { PlaylistPluginOptions, VideoJSCaption } from './peertube-videojs-typings'
|
||||
|
||||
export type PlayerMode = 'webtorrent' | 'p2p-media-loader'
|
||||
export type PlayerMode = 'webtorrent' | 'p2p-media-loader' | 'lowlatency'
|
||||
|
||||
export type WebtorrentOptions = {
|
||||
videoFiles: VideoFile[]
|
||||
|
@ -16,6 +16,44 @@ export type P2PMediaLoaderOptions = {
|
|||
videoFiles: VideoFile[]
|
||||
}
|
||||
|
||||
export type VHSMediaLoaderOptions = {
|
||||
playlistUrl: string
|
||||
}
|
||||
|
||||
|
||||
export type LatencyCompensatorOptions = {
|
||||
/** Max number of buffering events before we stop compensating for latency. */
|
||||
rebuffer_event_limit: number
|
||||
/** Min duration a buffer event must last to be counted. */
|
||||
min_buffer_duration: number
|
||||
/** The playback rate when compensating for latency. */
|
||||
max_speedup_rate: number
|
||||
/** The max amount we will increase the playback rate at once. */
|
||||
max_speedup_ramp: number
|
||||
/** The amount of time we stop handling latency after certain events. */
|
||||
timeout_duration: number
|
||||
/** How often we check if we should be compensating for latency. */
|
||||
check_timer_interval: number
|
||||
/** How often until a buffering event expires. */
|
||||
buffering_amnesty_duration: number
|
||||
/** The player:bitrate ratio required to enable compensating for latency. */
|
||||
required_bandwidth_ratio: number
|
||||
/** Segment length * this value is when we start compensating. */
|
||||
highest_latency_segment_length_multiplier: number
|
||||
/** Segment length * this value is when we stop compensating. */
|
||||
lowest_latency_segment_length_multiplier: number
|
||||
/** The absolute lowest we'll continue compensation to be running at. */
|
||||
min_latency: number
|
||||
/** The absolute highest we'll allow a target latency to be before we start compensating. */
|
||||
max_latency: number
|
||||
/** How much behind the max latency we need to be behind before we allow a jump. */
|
||||
max_jump_latency: number
|
||||
/** How often we'll allow a time jump. */
|
||||
max_jump_frequency: number
|
||||
/** The amount of time after we start up that we'll allow monitoring to occur. */
|
||||
startup_wait_time: number
|
||||
}
|
||||
|
||||
export interface CustomizationOptions {
|
||||
startTime: number | string
|
||||
stopTime: number | string
|
||||
|
@ -85,6 +123,6 @@ export type PeertubePlayerManagerOptions = {
|
|||
common: CommonOptions
|
||||
webtorrent: WebtorrentOptions
|
||||
p2pMediaLoader?: P2PMediaLoaderOptions
|
||||
|
||||
vhsMediaLoader?: VHSMediaLoaderOptions,
|
||||
pluginsManager: PluginsManager
|
||||
}
|
||||
|
|
|
@ -5,6 +5,7 @@ import { VideoFile, VideoPlaylist, VideoPlaylistElement } from '@shared/models'
|
|||
import { PeerTubeDockPluginOptions } from '../shared/dock/peertube-dock-plugin'
|
||||
import { Html5Hlsjs } from '../shared/p2p-media-loader/hls-plugin'
|
||||
import { P2pMediaLoaderPlugin } from '../shared/p2p-media-loader/p2p-media-loader-plugin'
|
||||
import { VhsMediaLoaderPlugin } from '../shared/vhs-loader/vhs-loader-plugin'
|
||||
import { RedundancyUrlManager } from '../shared/p2p-media-loader/redundancy-url-manager'
|
||||
import { PeerTubePlugin } from '../shared/peertube/peertube-plugin'
|
||||
import { PlaylistPlugin } from '../shared/playlist/playlist-plugin'
|
||||
|
@ -35,8 +36,8 @@ declare module 'video.js' {
|
|||
peertube (): PeerTubePlugin
|
||||
|
||||
webtorrent (): WebTorrentPlugin
|
||||
|
||||
p2pMediaLoader (): P2pMediaLoaderPlugin
|
||||
vhsLoader (): VhsMediaLoaderPlugin
|
||||
|
||||
peertubeResolutions (): PeerTubeResolutionsPlugin
|
||||
|
||||
|
@ -64,6 +65,65 @@ export interface VideoJSTechHLS extends videojs.Tech {
|
|||
hlsProvider: Html5Hlsjs
|
||||
}
|
||||
|
||||
export type VhsSegment = {
|
||||
start: number
|
||||
end: number
|
||||
uri: string
|
||||
codecs: string
|
||||
resolution: { width: number, height: number }
|
||||
duration: number
|
||||
dateTimeObject: Date
|
||||
}
|
||||
|
||||
export interface VhsMetadataCue extends TextTrackCue {
|
||||
value: {
|
||||
playlist: string
|
||||
start: number
|
||||
}
|
||||
}
|
||||
|
||||
export type VhsPlaylist = {
|
||||
id: string
|
||||
uri: string
|
||||
resolvedUri: string
|
||||
lastRequest: number
|
||||
attributes: {
|
||||
BANDWIDTH: number
|
||||
CODECS: string
|
||||
RESOLUTION: { width: number, height: number }
|
||||
}
|
||||
segments: VhsSegment[]
|
||||
}
|
||||
|
||||
export type VhsLevel = {
|
||||
id: string
|
||||
|
||||
height: number
|
||||
width: number
|
||||
bandwidth: number
|
||||
|
||||
playlist: VhsPlaylist
|
||||
|
||||
enabled: (enable?: boolean) => boolean
|
||||
}
|
||||
|
||||
export interface VideoJSTechVHS extends videojs.Tech {
|
||||
vhs: {
|
||||
representations: () => VhsLevel[]
|
||||
playlists: {
|
||||
master: () => VhsPlaylist
|
||||
media: () => VhsPlaylist
|
||||
},
|
||||
systemBandwidth: number
|
||||
stats: {
|
||||
mediaBytesTransferred: number
|
||||
bandwidth: number
|
||||
buffered: { start: number, end: number }[]
|
||||
}
|
||||
}
|
||||
currentTime: () => number
|
||||
}
|
||||
|
||||
export interface HlsjsConfigHandlerOptions {
|
||||
hlsjsConfig?: HlsConfig
|
||||
|
||||
|
@ -200,7 +260,7 @@ type AutoResolutionUpdateData = {
|
|||
}
|
||||
|
||||
type PlayerNetworkInfo = {
|
||||
source: 'webtorrent' | 'p2p-media-loader'
|
||||
source: 'webtorrent' | 'p2p-media-loader' | 'lowlatency'
|
||||
|
||||
http: {
|
||||
downloadSpeed: number
|
||||
|
|
|
@ -30,7 +30,6 @@
|
|||
"baseUrl": "./",
|
||||
"paths": {
|
||||
"hls.js": [ "node_modules/hls.js/dist/hls.light" ],
|
||||
"video.js": [ "node_modules/video.js/core" ],
|
||||
"@app/*": [ "src/app/*" ],
|
||||
"@shared/models/*": [ "../shared/models/*" ],
|
||||
"@shared/models": [ "../shared/models" ],
|
||||
|
|
10
package.json
10
package.json
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"name": "peertube",
|
||||
"description": "PeerTube, an ActivityPub-federated video streaming platform using P2P directly in your web browser.",
|
||||
"version": "4.3.0",
|
||||
"version": "4.3.0+vtopia1",
|
||||
"private": true,
|
||||
"licence": "AGPL-3.0",
|
||||
"engines": {
|
||||
|
@ -12,13 +12,13 @@
|
|||
"peertube": "dist/server/tools/peertube.js"
|
||||
},
|
||||
"author": {
|
||||
"name": "Chocobozzz",
|
||||
"email": "chocobozzz@framasoft.org",
|
||||
"url": "http://github.com/Chocobozzz"
|
||||
"name": "VtopiaLIVE",
|
||||
"email": "admin@votpia.live",
|
||||
"url": "https://social.vtopia.live/@staff"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/Chocobozzz/PeerTube.git"
|
||||
"url": "git+https://code.vtopia.live/Vtopia/VeeTube.git"
|
||||
},
|
||||
"typings": "*.d.ts",
|
||||
"scripts": {
|
||||
|
|
|
@ -66,7 +66,6 @@ async function getRoomMessages(req: express.Request, res: express.Response) {
|
|||
roomId: parseInt(req.params.roomId),
|
||||
start: req.query.start,
|
||||
count: req.query.count,
|
||||
afterTimestamp: req.query.afterDate,
|
||||
user,
|
||||
};
|
||||
|
||||
|
|
|
@ -99,16 +99,25 @@ function buildStreamSuffix (base: string, streamNum?: number) {
|
|||
}
|
||||
|
||||
function getScaleFilter (options: EncoderOptions): string {
|
||||
if (options.scaleFilter) return options.scaleFilter.name
|
||||
if (options.scaleFilter?.name) return options.scaleFilter.name
|
||||
|
||||
return 'scale'
|
||||
}
|
||||
|
||||
function getScaleFilterArgs (options: EncoderOptions, resolution: number): string {
|
||||
if (options.scaleFilter?.args) {
|
||||
return options.scaleFilter.args.join(':')
|
||||
}
|
||||
|
||||
return `w=-2:h=${resolution}`
|
||||
}
|
||||
|
||||
export {
|
||||
getFFmpeg,
|
||||
getFFmpegVersion,
|
||||
runCommand,
|
||||
StreamType,
|
||||
buildStreamSuffix,
|
||||
getScaleFilter
|
||||
getScaleFilter,
|
||||
getScaleFilterArgs
|
||||
}
|
||||
|
|
|
@ -3,7 +3,7 @@ import { join } from 'path'
|
|||
import { VIDEO_LIVE } from '@server/initializers/constants'
|
||||
import { AvailableEncoders, LiveVideoLatencyMode } from '@shared/models'
|
||||
import { logger, loggerTagsFactory } from '../logger'
|
||||
import { buildStreamSuffix, getFFmpeg, getScaleFilter, StreamType } from './ffmpeg-commons'
|
||||
import { buildStreamSuffix, getFFmpeg, getScaleFilter, getScaleFilterArgs, StreamType } from './ffmpeg-commons'
|
||||
import { getEncoderBuilderResult } from './ffmpeg-encoders'
|
||||
import { addDefaultEncoderGlobalParams, addDefaultEncoderParams, applyEncoderOptions } from './ffmpeg-presets'
|
||||
import { computeFPS } from './ffprobe-utils'
|
||||
|
@ -46,6 +46,8 @@ async function getLiveTranscodingCommand (options: {
|
|||
|
||||
addDefaultEncoderGlobalParams(command)
|
||||
|
||||
const segmentLength = getLiveSegmentTime(latencyMode)
|
||||
|
||||
for (let i = 0; i < resolutions.length; i++) {
|
||||
const resolution = resolutions[i]
|
||||
const resolutionFPS = computeFPS(fps, resolution)
|
||||
|
@ -64,6 +66,7 @@ async function getLiveTranscodingCommand (options: {
|
|||
|
||||
resolution,
|
||||
fps: resolutionFPS,
|
||||
segmentLength,
|
||||
|
||||
streamNum: i,
|
||||
videoType: 'live' as 'live'
|
||||
|
@ -78,7 +81,9 @@ async function getLiveTranscodingCommand (options: {
|
|||
|
||||
command.outputOption(`-map [vout${resolution}]`)
|
||||
|
||||
addDefaultEncoderParams({ command, encoder: builderResult.encoder, fps: resolutionFPS, streamNum: i })
|
||||
addDefaultEncoderParams({
|
||||
command, segmentLength, encoder: builderResult.encoder, fps: resolutionFPS, streamNum: i
|
||||
})
|
||||
|
||||
logger.debug(
|
||||
'Apply ffmpeg live video params from %s using %s profile.', builderResult.encoder, profile,
|
||||
|
@ -91,7 +96,7 @@ async function getLiveTranscodingCommand (options: {
|
|||
complexFilter.push({
|
||||
inputs: `vtemp${resolution}`,
|
||||
filter: getScaleFilter(builderResult.result),
|
||||
options: `w=-2:h=${resolution}`,
|
||||
options: getScaleFilterArgs(builderResult.result, resolution),
|
||||
outputs: `vout${resolution}`
|
||||
})
|
||||
}
|
||||
|
|
|
@ -25,18 +25,20 @@ function addDefaultEncoderParams (options: {
|
|||
fps: number
|
||||
|
||||
streamNum?: number
|
||||
segmentLength?: number
|
||||
}) {
|
||||
const { command, encoder, fps, streamNum } = options
|
||||
// Keyframe interval defaults to 2 for faster seeking and resolution switching on VODs,
|
||||
// and is the same as the segment length when live to ensure keyframes are put where needed
|
||||
// https://streaminglearningcenter.com/blogs/whats-the-right-keyframe-interval.html
|
||||
const { command, encoder, fps, streamNum, segmentLength = 2 } = options
|
||||
|
||||
if (encoder === 'libx264') {
|
||||
// 3.1 is the minimal resource allocation for our highest supported resolution
|
||||
command.outputOption(buildStreamSuffix('-level:v', streamNum) + ' 3.1')
|
||||
|
||||
if (fps) {
|
||||
// Keyframe interval of 2 seconds for faster seeking and resolution switching.
|
||||
// https://streaminglearningcenter.com/blogs/whats-the-right-keyframe-interval.html
|
||||
// https://superuser.com/a/908325
|
||||
command.outputOption(buildStreamSuffix('-g:v', streamNum) + ' ' + (fps * 2))
|
||||
command.outputOption(buildStreamSuffix('-g:v', streamNum) + ' ' + (fps * segmentLength))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -3,7 +3,7 @@ import { body, param } from 'express-validator'
|
|||
import { HttpStatusCode, ServerErrorCode, UserRight } from '@shared/models'
|
||||
import { MUserAccountUrl, MRoom, MRoomMessageFull } from '@server/types/models'
|
||||
import { isValidVideoCommentText } from '../../helpers/custom-validators/video-comments'
|
||||
import { isIdValid, isDateValid } from '../../helpers/custom-validators/misc'
|
||||
import { isIdValid } from '../../helpers/custom-validators/misc'
|
||||
import { logger } from '../../helpers/logger'
|
||||
import { RoomModel } from '../../models/room/room'
|
||||
import { RoomMessageModel } from '../../models/room/room-message'
|
||||
|
@ -45,10 +45,6 @@ const addRoomMessageValidator = [
|
|||
const listRoomMessagesValidator = [
|
||||
param('roomId').custom(isIdValid),
|
||||
|
||||
param('afterDate')
|
||||
.optional()
|
||||
.custom(isDateValid),
|
||||
|
||||
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
|
||||
logger.debug('Checking listRoomMessagesValidator parameters.', { parameters: req.params, body: req.body })
|
||||
|
||||
|
|
|
@ -159,9 +159,8 @@ export class RoomMessageModel extends Model<Partial<AttributesOnly<RoomMessageMo
|
|||
start: number
|
||||
count: number
|
||||
user?: MUserAccountId
|
||||
afterTimestamp?: string
|
||||
}) {
|
||||
const { roomId, start, count, user, afterTimestamp } = parameters
|
||||
const { roomId, start, count, user } = parameters
|
||||
|
||||
const blockerAccountIds = await RoomMessageModel.buildBlockerAccountIds({ user })
|
||||
|
||||
|
@ -173,15 +172,6 @@ export class RoomMessageModel extends Model<Partial<AttributesOnly<RoomMessageMo
|
|||
}
|
||||
}
|
||||
|
||||
let timefilterWhere = {}
|
||||
if (afterTimestamp) {
|
||||
timefilterWhere = {
|
||||
createdAt: {
|
||||
[Op.gte]: new Date(afterTimestamp),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const queryList = {
|
||||
offset: start,
|
||||
limit: count,
|
||||
|
@ -189,8 +179,7 @@ export class RoomMessageModel extends Model<Partial<AttributesOnly<RoomMessageMo
|
|||
where: {
|
||||
roomId,
|
||||
deletedAt: null,
|
||||
...accountBlockedWhere,
|
||||
...timefilterWhere
|
||||
...accountBlockedWhere
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -43,6 +43,10 @@ function isLastWeek (d: Date) {
|
|||
return getDaysDifferences(now, d) <= 7
|
||||
}
|
||||
|
||||
function round(n: number, precision: number) {
|
||||
return parseFloat(n.toFixed(precision))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function timeToInt (time: number | string) {
|
||||
|
@ -81,8 +85,8 @@ function secondsToTime (seconds: number, full = false, symbol?: string) {
|
|||
else if (full) time += '00' + minuteSymbol
|
||||
|
||||
seconds %= 60
|
||||
if (seconds >= 1 && seconds < 10 && full) time += '0' + seconds + secondsSymbol
|
||||
else if (seconds >= 1) time += seconds + secondsSymbol
|
||||
if (seconds >= 1 && seconds < 10 && full) time += '0' + round(seconds, 3) + secondsSymbol
|
||||
else if (seconds >= 1) time += round(seconds, 3) + secondsSymbol
|
||||
else if (full) time += '00'
|
||||
|
||||
return time
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import { VideoResolution } from '../videos'
|
||||
|
||||
export interface PlaybackMetricCreate {
|
||||
playerMode: 'p2p-media-loader' | 'webtorrent'
|
||||
playerMode: 'p2p-media-loader' | 'webtorrent' | 'lowlatency'
|
||||
|
||||
resolution?: VideoResolution
|
||||
fps?: number
|
||||
|
|
|
@ -38,6 +38,4 @@ export interface VideoChannelSummary {
|
|||
avatars: ActorImage[]
|
||||
// TODO: remove, deprecated in 4.2
|
||||
avatar: ActorImage
|
||||
|
||||
roomId?: number
|
||||
}
|
||||
|
|
|
@ -27,7 +27,8 @@ export interface EncoderOptions {
|
|||
copy?: boolean // Copy stream? Default to false
|
||||
|
||||
scaleFilter?: {
|
||||
name: string
|
||||
name?: string
|
||||
args?: string[]
|
||||
}
|
||||
|
||||
inputOptions?: string[]
|
||||
|
|
Loading…
Reference in New Issue