Separate HLS audio and video streams

Allows:
  * The HLS player to propose an "Audio only" resolution
  * The live to output an "Audio only" resolution
  * The live to ingest and output an "Audio only" stream

 This feature is under a config for VOD videos and is enabled by default for lives

 In the future we can imagine:
  * To propose multiple audio streams for a specific video
  * To ingest an audio only VOD and just output an audio only "video"
    (the player would play the audio file and PeerTube would not
    generate additional resolutions)

This commit introduce a new way to download videos:
 * Add "/download/videos/generate/:videoId" endpoint where PeerTube can
   mux an audio only and a video only file to a mp4 container
 * The download client modal introduces a new default panel where the
   user can choose resolutions it wants to download
This commit is contained in:
Chocobozzz
2024-07-23 16:38:51 +02:00
committed by Chocobozzz
parent e77ba2dfbc
commit 816f346a60
186 changed files with 5748 additions and 2807 deletions

View File

@@ -11,12 +11,13 @@ export type ResolutionOption = {
@Injectable()
export class EditConfigurationService {
getVODResolutions () {
getTranscodingResolutions () {
return [
{
id: '0p',
label: $localize`Audio-only`,
description: $localize`A <code>.mp4</code> that keeps the original audio track, with no video`
// eslint-disable-next-line max-len
description: $localize`"Split audio and video" must be enabled for the PeerTube player to propose an "Audio only" resolution to users`
},
{
id: '144p',
@@ -53,14 +54,14 @@ export class EditConfigurationService {
]
}
getLiveResolutions () {
return this.getVODResolutions().filter(r => r.id !== '0p')
}
isTranscodingEnabled (form: FormGroup) {
return form.value['transcoding']['enabled'] === true
}
isHLSEnabled (form: FormGroup) {
return form.value['transcoding']['hls']['enabled'] === true
}
isRemoteRunnerVODEnabled (form: FormGroup) {
return form.value['transcoding']['remoteRunners']['enabled'] === true
}

View File

@@ -152,3 +152,8 @@ my-actor-banner-edit {
max-width: $form-max-width;
}
h4 {
font-weight: $font-bold;
margin-bottom: 0.5rem;
font-size: 1rem;
}

View File

@@ -1,7 +1,6 @@
import omit from 'lodash-es/omit'
import { forkJoin } from 'rxjs'
import { SelectOptionsItem } from 'src/types/select-options-item.model'
import { NgFor, NgIf } from '@angular/common'
import { Component, OnInit } from '@angular/core'
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
import { ActivatedRoute, Router } from '@angular/router'
import { ConfigService } from '@app/+admin/config/shared/config.service'
import { Notifier } from '@app/core'
@@ -28,18 +27,19 @@ import {
import { USER_VIDEO_QUOTA_DAILY_VALIDATOR, USER_VIDEO_QUOTA_VALIDATOR } from '@app/shared/form-validators/user-validators'
import { FormReactive } from '@app/shared/shared-forms/form-reactive'
import { FormReactiveService } from '@app/shared/shared-forms/form-reactive.service'
import { CustomPageService } from '@app/shared/shared-main/custom-page/custom-page.service'
import { NgbNav, NgbNavContent, NgbNavItem, NgbNavLink, NgbNavLinkBase, NgbNavOutlet } from '@ng-bootstrap/ng-bootstrap'
import { CustomConfig, CustomPage, HTMLServerConfig } from '@peertube/peertube-models'
import { EditConfigurationService } from './edit-configuration.service'
import omit from 'lodash-es/omit'
import { forkJoin } from 'rxjs'
import { SelectOptionsItem } from 'src/types/select-options-item.model'
import { EditAdvancedConfigurationComponent } from './edit-advanced-configuration.component'
import { EditBasicConfigurationComponent } from './edit-basic-configuration.component'
import { EditConfigurationService } from './edit-configuration.service'
import { EditHomepageComponent } from './edit-homepage.component'
import { EditInstanceInformationComponent } from './edit-instance-information.component'
import { EditLiveConfigurationComponent } from './edit-live-configuration.component'
import { EditVODTranscodingComponent } from './edit-vod-transcoding.component'
import { EditBasicConfigurationComponent } from './edit-basic-configuration.component'
import { EditInstanceInformationComponent } from './edit-instance-information.component'
import { EditHomepageComponent } from './edit-homepage.component'
import { NgbNav, NgbNavItem, NgbNavLink, NgbNavLinkBase, NgbNavContent, NgbNavOutlet } from '@ng-bootstrap/ng-bootstrap'
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
import { NgIf, NgFor } from '@angular/common'
import { CustomPageService } from '@app/shared/shared-main/custom-page/custom-page.service'
type ComponentCustomConfig = CustomConfig & {
instanceCustomHomepage: CustomPage
@@ -230,7 +230,8 @@ export class EditCustomConfigComponent extends FormReactive implements OnInit {
keep: null
},
hls: {
enabled: null
enabled: null,
splitAudioAndVideo: null
},
webVideos: {
enabled: null
@@ -341,12 +342,10 @@ export class EditCustomConfigComponent extends FormReactive implements OnInit {
}
}
for (const resolution of this.editConfigurationService.getVODResolutions()) {
for (const resolution of this.editConfigurationService.getTranscodingResolutions()) {
defaultValues.transcoding.resolutions[resolution.id] = 'false'
formGroupData.transcoding.resolutions[resolution.id] = null
}
for (const resolution of this.editConfigurationService.getLiveResolutions()) {
defaultValues.live.transcoding.resolutions[resolution.id] = 'false'
formGroupData.live.transcoding.resolutions[resolution.id] = null
}

View File

@@ -114,33 +114,36 @@
<div class="callout callout-light pt-2 mt-2 pb-0">
<h3 class="callout-title" i18n>Output formats</h3>
<div class="form-group" [ngClass]="getDisabledLiveTranscodingClass()">
<label i18n for="liveTranscodingThreads">Live resolutions to generate</label>
<div [ngClass]="getDisabledLiveTranscodingClass()">
<div class="ms-2 mt-2 d-flex flex-column">
<div class="ms-2 mt-3">
<h4 i18n>Live resolutions to generate</h4>
<ng-container formGroupName="resolutions">
<div class="form-group" *ngFor="let resolution of liveResolutions">
<div class="mt-3">
<ng-container formGroupName="resolutions">
<div class="form-group" *ngFor="let resolution of liveResolutions">
<my-peertube-checkbox
[inputName]="getResolutionKey(resolution.id)" [formControlName]="resolution.id"
labelText="{{resolution.label}}"
>
<ng-template *ngIf="resolution.description" ptTemplate="help">
<div [innerHTML]="resolution.description"></div>
</ng-template>
</my-peertube-checkbox>
</div>
</ng-container>
<div class="form-group">
<my-peertube-checkbox
[inputName]="getResolutionKey(resolution.id)" [formControlName]="resolution.id"
labelText="{{resolution.label}}"
inputName="liveTranscodingAlwaysTranscodeOriginalResolution" formControlName="alwaysTranscodeOriginalResolution"
i18n-labelText labelText="Also transcode original resolution"
>
<ng-template *ngIf="resolution.description" ptTemplate="help">
<div [innerHTML]="resolution.description"></div>
</ng-template>
<ng-container i18n ngProjectAs="description">
Even if it's above your maximum enabled resolution
</ng-container>
</my-peertube-checkbox>
</div>
</ng-container>
<div class="form-group">
<my-peertube-checkbox
inputName="transcodingAlwaysTranscodeOriginalResolution" formControlName="alwaysTranscodeOriginalResolution"
i18n-labelText labelText="Also transcode original resolution"
>
<ng-container i18n ngProjectAs="description">
Even if it's above your maximum enabled resolution
</ng-container>
</my-peertube-checkbox>
</div>
</div>
</div>
@@ -148,7 +151,7 @@
<div class="form-group mt-4" formGroupName="remoteRunners" [ngClass]="getDisabledLiveTranscodingClass()">
<my-peertube-checkbox
inputName="transcodingRemoteRunnersEnabled" formControlName="enabled"
inputName="liveTranscodingRemoteRunnersEnabled" formControlName="enabled"
i18n-labelText labelText="Enable remote runners for lives"
>
<ng-container ngProjectAs="description">

View File

@@ -56,7 +56,7 @@ export class EditLiveConfigurationComponent implements OnInit, OnChanges {
{ id: 1000 * 3600 * 10, label: $localize`10 hours` }
]
this.liveResolutions = this.editConfigurationService.getLiveResolutions()
this.liveResolutions = this.editConfigurationService.getTranscodingResolutions()
}
ngOnChanges (changes: SimpleChanges) {

View File

@@ -115,7 +115,25 @@
<p>If you also enabled Web Videos support, it will multiply videos storage by 2</p>
</ng-container>
</ng-template>
<ng-container ngProjectAs="extra">
<div class="form-group" [ngClass]="getHLSDisabledClass()">
<my-peertube-checkbox
inputName="transcodingHlsSplitAudioAndVideo" formControlName="splitAudioAndVideo"
i18n-labelText labelText="Split audio and video streams"
>
<ng-template ptTemplate="help">
<ng-container i18n>Store the audio stream in a separate file from the video.</ng-container> <br />
<ng-container i18n>This option adds the ability for the HLS player to propose the "Audio only" quality to users.</ng-container> <br />
<ng-container i18n>It also saves disk space by not duplicating the audio stream in each resolution file</ng-container>
</ng-template>
</my-peertube-checkbox>
</div>
</ng-container>
</my-peertube-checkbox>
</div>
</ng-container>
@@ -123,16 +141,6 @@
<div class="mb-2 fw-bold" i18n>Resolutions to generate</div>
<div class="ms-2 d-flex flex-column">
<my-peertube-checkbox
inputName="transcodingAlwaysTranscodeOriginalResolution" formControlName="alwaysTranscodeOriginalResolution"
i18n-labelText labelText="Always transcode original resolution"
>
</my-peertube-checkbox>
<span class="mt-3 mb-2 small muted" i18n>
The original file resolution will be the default target if no option is selected.
</span>
<ng-container formGroupName="resolutions">
<div class="form-group" *ngFor="let resolution of resolutions">
<my-peertube-checkbox
@@ -145,6 +153,15 @@
</my-peertube-checkbox>
</div>
</ng-container>
<my-peertube-checkbox
inputName="transcodingAlwaysTranscodeOriginalResolution" formControlName="alwaysTranscodeOriginalResolution"
i18n-labelText labelText="Also transcode original resolution"
>
<ng-container i18n ngProjectAs="description">
Even if it's above your maximum enabled resolution
</ng-container>
</my-peertube-checkbox>
</div>
</div>
</div>

View File

@@ -1,15 +1,16 @@
import { SelectOptionsItem } from 'src/types/select-options-item.model'
import { NgClass, NgFor, NgIf } from '@angular/common'
import { Component, Input, OnChanges, OnInit, SimpleChanges } from '@angular/core'
import { FormGroup, FormsModule, ReactiveFormsModule } from '@angular/forms'
import { RouterLink } from '@angular/router'
import { Notifier } from '@app/core'
import { HTMLServerConfig } from '@peertube/peertube-models'
import { SelectOptionsItem } from 'src/types/select-options-item.model'
import { PeertubeCheckboxComponent } from '../../../shared/shared-forms/peertube-checkbox.component'
import { SelectCustomValueComponent } from '../../../shared/shared-forms/select/select-custom-value.component'
import { SelectOptionsComponent } from '../../../shared/shared-forms/select/select-options.component'
import { PeerTubeTemplateDirective } from '../../../shared/shared-main/angular/peertube-template.directive'
import { ConfigService } from '../shared/config.service'
import { EditConfigurationService, ResolutionOption } from './edit-configuration.service'
import { SelectOptionsComponent } from '../../../shared/shared-forms/select/select-options.component'
import { SelectCustomValueComponent } from '../../../shared/shared-forms/select/select-custom-value.component'
import { RouterLink } from '@angular/router'
import { NgClass, NgFor, NgIf } from '@angular/common'
import { PeerTubeTemplateDirective } from '../../../shared/shared-main/angular/peertube-template.directive'
import { PeertubeCheckboxComponent } from '../../../shared/shared-forms/peertube-checkbox.component'
@Component({
selector: 'my-edit-vod-transcoding',
@@ -42,12 +43,13 @@ export class EditVODTranscodingComponent implements OnInit, OnChanges {
constructor (
private configService: ConfigService,
private editConfigurationService: EditConfigurationService
private editConfigurationService: EditConfigurationService,
private notifier: Notifier
) { }
ngOnInit () {
this.transcodingThreadOptions = this.configService.transcodingThreadOptions
this.resolutions = this.editConfigurationService.getVODResolutions()
this.resolutions = this.editConfigurationService.getTranscodingResolutions()
this.checkTranscodingFields()
}
@@ -84,6 +86,10 @@ export class EditVODTranscodingComponent implements OnInit, OnChanges {
return this.editConfigurationService.isTranscodingEnabled(this.form)
}
isHLSEnabled () {
return this.editConfigurationService.isHLSEnabled(this.form)
}
isStudioEnabled () {
return this.editConfigurationService.isStudioEnabled(this.form)
}
@@ -92,6 +98,10 @@ export class EditVODTranscodingComponent implements OnInit, OnChanges {
return { 'disabled-checkbox-extra': !this.isTranscodingEnabled() }
}
getHLSDisabledClass () {
return { 'disabled-checkbox-extra': !this.isHLSEnabled() }
}
getLocalTranscodingDisabledClass () {
return { 'disabled-checkbox-extra': !this.isTranscodingEnabled() || this.isRemoteRunnerVODEnabled() }
}
@@ -111,33 +121,31 @@ export class EditVODTranscodingComponent implements OnInit, OnChanges {
const webVideosControl = this.form.get('transcoding.webVideos.enabled')
webVideosControl.valueChanges
.subscribe(newValue => {
if (newValue === false && !hlsControl.disabled) {
hlsControl.disable()
}
.subscribe(newValue => {
if (newValue === false && hlsControl.value === false) {
hlsControl.setValue(true)
if (newValue === true && !hlsControl.enabled) {
hlsControl.enable()
}
})
// eslint-disable-next-line max-len
this.notifier.info($localize`Automatically enable HLS transcoding because at least 1 output format must be enabled when transcoding is enabled`, '', 10000)
}
})
hlsControl.valueChanges
.subscribe(newValue => {
if (newValue === false && !webVideosControl.disabled) {
webVideosControl.disable()
}
.subscribe(newValue => {
if (newValue === false && webVideosControl.value === false) {
webVideosControl.setValue(true)
if (newValue === true && !webVideosControl.enabled) {
webVideosControl.enable()
}
})
// eslint-disable-next-line max-len
this.notifier.info($localize`Automatically enable Web Videos transcoding because at least 1 output format must be enabled when transcoding is enabled`, '', 10000)
}
})
transcodingControl.valueChanges
.subscribe(newValue => {
if (newValue === false) {
videoStudioControl.setValue(false)
}
})
.subscribe(newValue => {
if (newValue === false) {
videoStudioControl.setValue(false)
}
})
transcodingControl.updateValueAndValidity()
webVideosControl.updateValueAndValidity()

View File

@@ -13,7 +13,7 @@ import { VideoRateComponent } from './video-rate.component'
import { VideoDetails } from '@app/shared/shared-main/video/video-details.model'
import { VideoShareComponent } from '@app/shared/shared-share-modal/video-share.component'
import { SupportModalComponent } from '@app/shared/shared-support-modal/support-modal.component'
import { VideoDownloadComponent } from '@app/shared/shared-video-miniature/video-download.component'
import { VideoDownloadComponent } from '@app/shared/shared-video-miniature/download/video-download.component'
import { VideoPlaylist } from '@app/shared/shared-video-playlist/video-playlist.model'
@Component({

View File

@@ -65,13 +65,4 @@ export class VideoDetails extends Video implements VideoDetailsServerModel {
hasHlsPlaylist () {
return !!this.getHlsPlaylist()
}
getFiles () {
if (this.files.length !== 0) return this.files
const hls = this.getHlsPlaylist()
if (hls) return hls.files
return []
}
}

View File

@@ -16,6 +16,7 @@ import {
VideoChannel as VideoChannelServerModel,
VideoConstant,
VideoDetails as VideoDetailsServerModel,
VideoFile,
VideoFileMetadata,
VideoIncludeType,
VideoPrivacy,
@@ -54,6 +55,7 @@ export type CommonVideoParams = {
@Injectable()
export class VideoService {
static BASE_VIDEO_DOWNLOAD_URL = environment.originServerUrl + '/download/videos/generate'
static BASE_VIDEO_URL = environment.apiUrl + '/api/v1/videos'
static BASE_FEEDS_URL = environment.apiUrl + '/feeds/videos.'
static PODCAST_FEEDS_URL = environment.apiUrl + '/feeds/podcast/videos.xml'
@@ -388,6 +390,22 @@ export class VideoService {
// ---------------------------------------------------------------------------
generateDownloadUrl (options: {
video: Video
files: VideoFile[]
}) {
const { video, files } = options
if (files.length === 0) throw new Error('Cannot generate download URL without files')
let url = `${VideoService.BASE_VIDEO_DOWNLOAD_URL}/${video.uuid}?`
url += files.map(f => 'videoFileIds=' + f.id).join('&')
return url
}
// ---------------------------------------------------------------------------
getStoryboards (videoId: string | number, videoPassword: string) {
const headers = VideoPasswordService.buildVideoPasswordHeader(videoPassword)

View File

@@ -179,17 +179,17 @@ export class UserSubscriptionService {
}
doesSubscriptionExist (nameWithHost: string) {
debugLogger('Running subscription check for %d.', nameWithHost)
debugLogger('Running subscription check for ' + nameWithHost)
if (nameWithHost in this.myAccountSubscriptionCache) {
debugLogger('Found cache for %d.', nameWithHost)
debugLogger('Found cache for ' + nameWithHost)
return of(this.myAccountSubscriptionCache[nameWithHost])
}
this.existsSubject.next(nameWithHost)
debugLogger('Fetching from network for %d.', nameWithHost)
debugLogger('Fetching from network for ' + nameWithHost)
return this.existsObservable.pipe(
filter(existsResult => existsResult[nameWithHost] !== undefined),
map(existsResult => existsResult[nameWithHost]),

View File

@@ -0,0 +1,24 @@
<ul ngbNav #nav="ngbNav" class="nav-tabs" [(activeId)]="activeNavId">
<li *ngFor="let caption of getCaptions()" [ngbNavItem]="caption.language.id">
<button ngbNavLink>
{{ caption.language.label }}
<ng-container *ngIf="caption.automaticallyGenerated" i18n>(auto-generated)</ng-container>
</button>
<ng-template ngbNavContent>
<div class="nav-content">
<my-input-text [show]="true" [readonly]="true" [withCopy]="true" [withToggle]="false" [value]="getCaptionLink()"></my-input-text>
</div>
</ng-template>
</li>
</ul>
<div [ngbNavOutlet]="nav" class="mt-2"></div>
<div class="modal-footer inputs">
<ng-content select="cancel-button"></ng-content>
<input type="submit" i18n-value value="Download" class="peertube-button orange-button" (click)="download()" />
</div>

View File

@@ -0,0 +1,71 @@
import { NgFor, NgIf } from '@angular/common'
import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core'
import { NgbNav, NgbNavContent, NgbNavItem, NgbNavLink, NgbNavLinkBase, NgbNavOutlet } from '@ng-bootstrap/ng-bootstrap'
import { VideoCaption } from '@peertube/peertube-models'
import { logger } from '@root-helpers/logger'
import { InputTextComponent } from '../../shared-forms/input-text.component'
@Component({
selector: 'my-subtitle-files-download',
templateUrl: './subtitle-files-download.component.html',
standalone: true,
imports: [
NgIf,
NgFor,
InputTextComponent,
NgbNav,
NgbNavItem,
NgbNavLink,
NgbNavLinkBase,
NgbNavContent,
NgbNavOutlet
]
})
export class SubtitleFilesDownloadComponent implements OnInit {
@Input({ required: true }) videoCaptions: VideoCaption[]
@Output() downloaded = new EventEmitter<void>()
activeNavId: string
getCaptions () {
if (!this.videoCaptions) return []
return this.videoCaptions
}
ngOnInit () {
if (this.hasCaptions()) {
this.activeNavId = this.videoCaptions[0].language.id
}
}
download () {
window.location.assign(this.getCaptionLink())
this.downloaded.emit()
}
hasCaptions () {
return this.getCaptions().length !== 0
}
getCaption () {
const caption = this.getCaptions()
.find(c => c.language.id === this.activeNavId)
if (!caption) {
logger.error(`Cannot find caption ${this.activeNavId}`)
return undefined
}
return caption
}
getCaptionLink () {
const caption = this.getCaption()
if (!caption) return ''
return window.location.origin + caption.captionPath
}
}

View File

@@ -0,0 +1,54 @@
<ng-template #modal let-hide="close">
<div class="modal-header">
<h4 class="modal-title">
<ng-container i18n>Download</ng-container>
<div class="peertube-select-container title-select">
<select id="type" name="type" [(ngModel)]="type" class="form-control">
<option value="video-generate" i18n>Video</option>
<option value="video-files" i18n>Video files</option>
<option *ngIf="hasCaptions()" value="subtitle-files" i18n>Subtitle files</option>
</select>
</div>
</h4>
<button class="border-0 p-0" title="Close this modal" i18n-title (click)="hide()">
<my-global-icon iconName="cross"></my-global-icon>
</button>
</div>
<div class="modal-body" [ngClass]="{ 'opacity-0': !loaded }">
<ng-template #cancelBlock>
<input
type="button" role="button" i18n-value value="Cancel" class="peertube-button grey-button"
(click)="hide()" (key.enter)="hide()"
>
</ng-template>
@switch (type) {
@case ('video-generate') {
<my-video-generate-download [video]="video" [originalVideoFile]="originalVideoFile" [videoFileToken]="videoFileToken" (downloaded)="onDownloaded()">
<ng-container ngProjectAs="cancel-button">
<ng-template [ngTemplateOutlet]="cancelBlock"></ng-template>
</ng-container>
</my-video-generate-download>
}
@case ('video-files') {
<my-video-files-download [video]="video" [originalVideoFile]="originalVideoFile" [videoFileToken]="videoFileToken" (downloaded)="onDownloaded()">
<ng-container ngProjectAs="cancel-button">
<ng-template [ngTemplateOutlet]="cancelBlock"></ng-template>
</ng-container>
</my-video-files-download>
}
@case ('subtitle-files') {
<my-subtitle-files-download [videoCaptions]="getCaptions()" (downloaded)="onDownloaded()">
<ng-container ngProjectAs="cancel-button">
<ng-template [ngTemplateOutlet]="cancelBlock"></ng-template>
</ng-container>
</my-subtitle-files-download>
}
}
</div>
</ng-template>

View File

@@ -0,0 +1,40 @@
@use '_variables' as *;
@use '_mixins' as *;
.modal-body ::ng-deep {
.nav-content {
margin-top: 30px;
}
my-global-icon[iconName=shield] {
@include margin-left(10px);
width: 16px;
position: relative;
top: -2px;
}
.modal-footer {
padding-inline-end: 0;
margin-top: 1rem;
> *:last-child {
margin-inline-end: 0;
}
}
}
.peertube-select-container.title-select {
@include peertube-select-container(auto);
display: inline-block;
margin-left: 10px;
vertical-align: top;
}
#dropdown-download-type {
cursor: pointer;
}

View File

@@ -0,0 +1,123 @@
import { NgClass, NgIf, NgTemplateOutlet } from '@angular/common'
import { Component, ElementRef, Input, ViewChild } from '@angular/core'
import { FormsModule } from '@angular/forms'
import { AuthService, HooksService } from '@app/core'
import { GlobalIconComponent } from '@app/shared/shared-icons/global-icon.component'
import { NgbModal, NgbModalRef } from '@ng-bootstrap/ng-bootstrap'
import { VideoCaption, VideoSource } from '@peertube/peertube-models'
import { videoRequiresFileToken } from '@root-helpers/video'
import { of } from 'rxjs'
import { catchError } from 'rxjs/operators'
import { VideoDetails } from '../../shared-main/video/video-details.model'
import { VideoFileTokenService } from '../../shared-main/video/video-file-token.service'
import { VideoService } from '../../shared-main/video/video.service'
import { SubtitleFilesDownloadComponent } from './subtitle-files-download.component'
import { VideoFilesDownloadComponent } from './video-files-download.component'
import { VideoGenerateDownloadComponent } from './video-generate-download.component'
type DownloadType = 'video-generate' | 'video-files' | 'subtitle-files'
@Component({
selector: 'my-video-download',
templateUrl: './video-download.component.html',
styleUrls: [ './video-download.component.scss' ],
standalone: true,
imports: [
SubtitleFilesDownloadComponent,
VideoFilesDownloadComponent,
VideoGenerateDownloadComponent,
GlobalIconComponent,
NgIf,
FormsModule,
NgClass,
NgTemplateOutlet
]
})
export class VideoDownloadComponent {
@ViewChild('modal', { static: true }) modal: ElementRef
@Input() videoPassword: string
video: VideoDetails
type: DownloadType = 'video-generate'
videoFileToken: string
originalVideoFile: VideoSource
loaded = false
private videoCaptions: VideoCaption[]
private activeModal: NgbModalRef
constructor (
private modalService: NgbModal,
private authService: AuthService,
private videoService: VideoService,
private videoFileTokenService: VideoFileTokenService,
private hooks: HooksService
) {}
getCaptions () {
if (!this.videoCaptions) return []
return this.videoCaptions
}
show (video: VideoDetails, videoCaptions?: VideoCaption[]) {
this.loaded = false
this.videoFileToken = undefined
this.originalVideoFile = undefined
this.video = video
this.videoCaptions = videoCaptions
this.activeModal = this.modalService.open(this.modal, { centered: true })
this.getOriginalVideoFileObs()
.subscribe(source => {
if (source?.fileDownloadUrl) {
this.originalVideoFile = source
}
if (this.originalVideoFile || videoRequiresFileToken(this.video)) {
this.videoFileTokenService.getVideoFileToken({ videoUUID: this.video.uuid, videoPassword: this.videoPassword })
.subscribe(({ token }) => {
this.videoFileToken = token
this.loaded = true
})
} else {
this.loaded = true
}
})
this.activeModal.shown.subscribe(() => {
this.hooks.runAction('action:modal.video-download.shown', 'common')
})
}
private getOriginalVideoFileObs () {
if (!this.video.isLocal || !this.authService.isLoggedIn()) return of(undefined)
const user = this.authService.getUser()
if (!this.video.isOwnerOrHasSeeAllVideosRight(user)) return of(undefined)
return this.videoService.getSource(this.video.id)
.pipe(catchError(err => {
console.error('Cannot get source file', err)
return of(undefined)
}))
}
// ---------------------------------------------------------------------------
onDownloaded () {
this.activeModal.close()
}
hasCaptions () {
return this.getCaptions().length !== 0
}
}

View File

@@ -0,0 +1,123 @@
<div class="alert alert-warning" *ngIf="isConfidentialVideo()" i18n>
The following link contains a private token and should not be shared with anyone.
</div>
<div ngbNav #resolutionNav="ngbNav" class="nav-tabs" [activeId]="activeResolutionId" (activeIdChange)="onResolutionIdChange($event)">
<ng-template #rootNavContent>
<div class="nav-content">
<my-input-text [show]="true" [readonly]="true" [withCopy]="true" [withToggle]="false" [value]="getVideoFileLink()"></my-input-text>
</div>
</ng-template>
<ng-container *ngIf="originalVideoFile" ngbNavItem="original">
<a ngbNavLink>
<ng-container i18n>Original file</ng-container>
<my-global-icon i18n-ngbTooltip ngbTooltip="Other users cannot download the original file" iconName="shield"></my-global-icon>
</a>
<ng-template ngbNavContent>
<ng-template [ngTemplateOutlet]="rootNavContent"></ng-template>
</ng-template>
</ng-container>
<ng-container *ngFor="let file of getVideoFiles()" [ngbNavItem]="file.resolution.id">
<a ngbNavLink>{{ file.resolution.label }}</a>
<ng-template ngbNavContent>
<ng-template [ngTemplateOutlet]="rootNavContent"></ng-template>
</ng-template>
</ng-container>
</div>
<div [ngbNavOutlet]="resolutionNav"></div>
<div class="advanced-filters" [ngbCollapse]="isAdvancedCustomizationCollapsed" [animation]="true">
<div ngbNav #navMetadata="ngbNav" class="nav-tabs nav-metadata">
<ng-template #metadataInfo let-item>
<div class="metadata-attribute">
<span>{{ item.value.label }}</span>
@if (item.value.value) {
<span>{{ item.value.value }}</span>
} @else {
<span i18n>Unknown</span>
}
</div>
</ng-template>
<ng-container ngbNavItem>
<a ngbNavLink i18n>Format</a>
<ng-template ngbNavContent>
<div class="file-metadata">
@for (item of videoFileMetadataFormat | keyvalue; track item.key) {
<ng-template [ngTemplateOutlet]="metadataInfo" [ngTemplateOutletContext]="{ $implicit: item }"></ng-template>
}
</div>
</ng-template>
</ng-container>
<ng-container ngbNavItem *ngIf="videoFileMetadataVideoStream !== undefined">
<a ngbNavLink i18n>Video stream</a>
<ng-template ngbNavContent>
<div class="file-metadata">
@for (item of videoFileMetadataVideoStream | keyvalue; track item.key) {
<ng-template [ngTemplateOutlet]="metadataInfo" [ngTemplateOutletContext]="{ $implicit: item }"></ng-template>
}
</div>
</ng-template>
</ng-container>
<ng-container ngbNavItem *ngIf="videoFileMetadataAudioStream !== undefined">
<a ngbNavLink i18n>Audio stream</a>
<ng-template ngbNavContent>
<div class="file-metadata">
@for (item of videoFileMetadataAudioStream | keyvalue; track item.key) {
<ng-template [ngTemplateOutlet]="metadataInfo" [ngTemplateOutletContext]="{ $implicit: item }"></ng-template>
}
</div>
</ng-template>
</ng-container>
</div>
<div *ngIf="hasMetadata()" [ngbNavOutlet]="navMetadata"></div>
<div [hidden]="originalVideoFile || !getVideoFile()?.torrentDownloadUrl" class="download-type">
<div class="peertube-radio-container">
<input type="radio" name="download" id="download-direct" [(ngModel)]="downloadType" value="direct">
<label i18n for="download-direct">Direct download</label>
</div>
<div class="peertube-radio-container">
<input type="radio" name="download" id="download-torrent" [(ngModel)]="downloadType" value="torrent">
<label i18n for="download-torrent">Torrent (.torrent file)</label>
</div>
</div>
</div>
<button
(click)="isAdvancedCustomizationCollapsed = !isAdvancedCustomizationCollapsed"
class="advanced-filters-button button-unstyle"
[attr.aria-expanded]="!isAdvancedCustomizationCollapsed" aria-controls="collapseBasic"
>
@if (isAdvancedCustomizationCollapsed) {
<span class="chevron-down"></span>
<ng-container i18n>More information/options</ng-container>
} @else {
<span class="chevron-up"></span>
<ng-container i18n>Less information/options</ng-container>
}
</button>
<div class="modal-footer inputs">
<ng-content select="cancel-button"></ng-content>
<input type="submit" i18n-value value="Download" class="peertube-button orange-button" (click)="download()" />
</div>

View File

@@ -1,17 +1,6 @@
@use '_variables' as *;
@use '_mixins' as *;
.nav-content {
margin-top: 30px;
}
my-global-icon[iconName=shield] {
@include margin-left(10px);
width: 16px;
margin-top: -3px;
}
.advanced-filters-button {
display: flex;
justify-content: center;
@@ -25,28 +14,6 @@ my-global-icon[iconName=shield] {
}
}
.peertube-select-container.title-select {
@include peertube-select-container(auto);
display: inline-block;
margin-left: 10px;
vertical-align: top;
}
#dropdown-download-type {
cursor: pointer;
}
.download-type {
margin-top: 20px;
.peertube-radio-container {
@include margin-right(30px);
display: inline-block;
}
}
.nav-metadata {
margin-top: 20px;
}
@@ -69,3 +36,13 @@ my-global-icon[iconName=shield] {
font-weight: $font-bold;
}
}
.download-type {
margin-top: 20px;
.peertube-radio-container {
@include margin-right(30px);
display: inline-block;
}
}

View File

@@ -1,11 +1,8 @@
import { KeyValuePipe, NgClass, NgFor, NgIf, NgTemplateOutlet } from '@angular/common'
import { Component, ElementRef, Inject, Input, LOCALE_ID, ViewChild } from '@angular/core'
import { Component, EventEmitter, Inject, Input, LOCALE_ID, OnInit, Output } from '@angular/core'
import { FormsModule } from '@angular/forms'
import { AuthService, HooksService } from '@app/core'
import {
NgbCollapse,
NgbModal,
NgbModalRef,
NgbNav,
NgbNavContent,
NgbNavItem,
@@ -15,34 +12,32 @@ import {
NgbTooltip
} from '@ng-bootstrap/ng-bootstrap'
import { objectKeysTyped, pick } from '@peertube/peertube-core-utils'
import { VideoCaption, VideoFile, VideoFileMetadata, VideoSource } from '@peertube/peertube-models'
import { VideoFile, VideoFileMetadata, VideoSource } from '@peertube/peertube-models'
import { logger } from '@root-helpers/logger'
import { videoRequiresFileToken } from '@root-helpers/video'
import { mapValues } from 'lodash-es'
import { firstValueFrom, of } from 'rxjs'
import { catchError, tap } from 'rxjs/operators'
import { InputTextComponent } from '../shared-forms/input-text.component'
import { GlobalIconComponent } from '../shared-icons/global-icon.component'
import { BytesPipe } from '../shared-main/angular/bytes.pipe'
import { NumberFormatterPipe } from '../shared-main/angular/number-formatter.pipe'
import { VideoDetails } from '../shared-main/video/video-details.model'
import { VideoFileTokenService } from '../shared-main/video/video-file-token.service'
import { VideoService } from '../shared-main/video/video.service'
import { firstValueFrom } from 'rxjs'
import { tap } from 'rxjs/operators'
import { InputTextComponent } from '../../shared-forms/input-text.component'
import { GlobalIconComponent } from '../../shared-icons/global-icon.component'
import { BytesPipe } from '../../shared-main/angular/bytes.pipe'
import { NumberFormatterPipe } from '../../shared-main/angular/number-formatter.pipe'
import { VideoDetails } from '../../shared-main/video/video-details.model'
import { VideoService } from '../../shared-main/video/video.service'
type DownloadType = 'video' | 'subtitles'
type FileMetadata = { [key: string]: { label: string, value: string | number } }
@Component({
selector: 'my-video-download',
templateUrl: './video-download.component.html',
styleUrls: [ './video-download.component.scss' ],
selector: 'my-video-files-download',
templateUrl: './video-files-download.component.html',
styleUrls: [ './video-files-download.component.scss' ],
standalone: true,
imports: [
NgIf,
FormsModule,
GlobalIconComponent,
NgbNav,
NgFor,
NgbNav,
NgbNavItem,
NgbNavLink,
NgbNavLinkBase,
@@ -56,15 +51,16 @@ type FileMetadata = { [key: string]: { label: string, value: string | number } }
NgClass
]
})
export class VideoDownloadComponent {
@ViewChild('modal', { static: true }) modal: ElementRef
export class VideoFilesDownloadComponent implements OnInit {
@Input({ required: true }) video: VideoDetails
@Input() originalVideoFile: VideoSource
@Input() videoFileToken: string
@Input() videoPassword: string
@Output() downloaded = new EventEmitter<void>()
downloadType: 'direct' | 'torrent' = 'direct'
resolutionId: number | 'original' = -1
subtitleLanguageId: string
activeResolutionId: number | 'original' = -1
videoFileMetadataFormat: FileMetadata
videoFileMetadataVideoStream: FileMetadata | undefined
@@ -72,133 +68,50 @@ export class VideoDownloadComponent {
isAdvancedCustomizationCollapsed = true
type: DownloadType = 'video'
videoFileToken: string
originalVideoFile: VideoSource
loaded = false
private activeModal: NgbModalRef
private bytesPipe: BytesPipe
private numbersPipe: NumberFormatterPipe
private video: VideoDetails
private videoCaptions: VideoCaption[]
constructor (
@Inject(LOCALE_ID) private localeId: string,
private modalService: NgbModal,
private authService: AuthService,
private videoService: VideoService,
private videoFileTokenService: VideoFileTokenService,
private hooks: HooksService
private videoService: VideoService
) {
this.bytesPipe = new BytesPipe()
this.numbersPipe = new NumberFormatterPipe(this.localeId)
}
get typeText () {
return this.type === 'video'
? $localize`video`
: $localize`subtitles`
}
getVideoFiles () {
if (!this.video) return []
return this.video.getFiles()
}
getCaptions () {
if (!this.videoCaptions) return []
return this.videoCaptions
}
show (video: VideoDetails, videoCaptions?: VideoCaption[]) {
this.loaded = false
this.videoFileToken = undefined
this.originalVideoFile = undefined
this.video = video
this.videoCaptions = videoCaptions
this.activeModal = this.modalService.open(this.modal, { centered: true })
ngOnInit () {
if (this.hasFiles()) {
this.onResolutionIdChange(this.getVideoFiles()[0].resolution.id)
}
if (this.hasCaptions()) {
this.subtitleLanguageId = this.videoCaptions[0].language.id
}
this.getOriginalVideoFileObs()
.subscribe(source => {
if (source?.fileDownloadUrl) {
this.originalVideoFile = source
}
if (this.originalVideoFile || this.isConfidentialVideo()) {
this.videoFileTokenService.getVideoFileToken({ videoUUID: this.video.uuid, videoPassword: this.videoPassword })
.subscribe(({ token }) => {
this.videoFileToken = token
this.loaded = true
})
} else {
this.loaded = true
}
})
this.activeModal.shown.subscribe(() => {
this.hooks.runAction('action:modal.video-download.shown', 'common')
})
}
private getOriginalVideoFileObs () {
if (!this.video.isLocal || !this.authService.isLoggedIn()) return of(undefined)
getVideoFiles () {
if (!this.video) return []
if (this.video.files.length !== 0) return this.video.files
const user = this.authService.getUser()
if (!this.video.isOwnerOrHasSeeAllVideosRight(user)) return of(undefined)
const hls = this.video.getHlsPlaylist()
if (hls) return hls.files
return this.videoService.getSource(this.video.id)
.pipe(catchError(err => {
console.error('Cannot get source file', err)
return of(undefined)
}))
return []
}
// ---------------------------------------------------------------------------
onClose () {
this.video = undefined
this.videoCaptions = undefined
}
download () {
window.location.assign(this.getLink())
window.location.assign(this.getVideoFileLink())
this.activeModal.close()
this.downloaded.emit()
}
getLink () {
return this.type === 'subtitles' && this.videoCaptions
? this.getCaptionLink()
: this.getVideoFileLink()
}
// ---------------------------------------------------------------------------
async onResolutionIdChange (resolutionId: number | 'original') {
this.resolutionId = resolutionId
this.activeResolutionId = resolutionId
let metadata: VideoFileMetadata
if (this.resolutionId === 'original') {
if (this.activeResolutionId === 'original') {
metadata = this.originalVideoFile.metadata
} else {
const videoFile = this.getVideoFile()
@@ -218,22 +131,20 @@ export class VideoDownloadComponent {
this.videoFileMetadataAudioStream = this.getMetadataStream(metadata.streams, 'audio')
}
onSubtitleIdChange (subtitleId: string) {
this.subtitleLanguageId = subtitleId
}
// ---------------------------------------------------------------------------
hasFiles () {
return this.getVideoFiles().length !== 0
}
getVideoFile () {
if (this.resolutionId === 'original') return undefined
if (this.activeResolutionId === 'original') return undefined
const file = this.getVideoFiles()
.find(f => f.resolution.id === this.resolutionId)
.find(f => f.resolution.id === this.activeResolutionId)
if (!file) {
logger.error(`Could not find file with resolution ${this.resolutionId}`)
logger.error(`Could not find file with resolution ${this.activeResolutionId}`)
return undefined
}
@@ -241,11 +152,11 @@ export class VideoDownloadComponent {
}
getVideoFileLink () {
const suffix = this.resolutionId === 'original' || this.isConfidentialVideo()
const suffix = this.activeResolutionId === 'original' || this.isConfidentialVideo()
? '?videoFileToken=' + this.videoFileToken
: ''
if (this.resolutionId === 'original') {
if (this.activeResolutionId === 'original') {
return this.originalVideoFile.fileDownloadUrl + suffix
}
@@ -261,36 +172,13 @@ export class VideoDownloadComponent {
}
}
hasCaptions () {
return this.getCaptions().length !== 0
}
getCaption () {
const caption = this.getCaptions()
.find(c => c.language.id === this.subtitleLanguageId)
if (!caption) {
logger.error(`Cannot find caption ${this.subtitleLanguageId}`)
return undefined
}
return caption
}
getCaptionLink () {
const caption = this.getCaption()
if (!caption) return ''
return window.location.origin + caption.captionPath
}
// ---------------------------------------------------------------------------
isConfidentialVideo () {
return this.resolutionId === 'original' || videoRequiresFileToken(this.video)
return this.activeResolutionId === 'original' || videoRequiresFileToken(this.video)
}
switchToType (type: DownloadType) {
this.type = type
}
// ---------------------------------------------------------------------------
hasMetadata () {
return !!this.videoFileMetadataFormat

View File

@@ -0,0 +1,37 @@
<div class="form-group">
<div *ngIf="originalVideoFile" class="peertube-radio-container">
<input type="radio" name="video-file" id="original-file" [(ngModel)]="videoFileChosen" value="file-original">
<label for="original-file">
<strong i18n>Original file</strong>
<span class="muted">{{ originalVideoFile.size | bytes: 1 }} | {{ originalVideoFile.width }}x{{ originalVideoFile.height }}</span>
<my-global-icon i18n-ngbTooltip ngbTooltip="Other users cannot download the original file" iconName="shield"></my-global-icon>
</label>
</div>
@for (file of videoFiles; track file.id) {
<div class="peertube-radio-container">
<input type="radio" name="video-file" [id]="'file-' + file.id" [(ngModel)]="videoFileChosen" [value]="'file-' + file.id">
<label [for]="'file-' + file.id">
<strong>{{ file.resolution.label }}</strong>
<span class="muted">{{ getFileSize(file) | bytes: 1 }} @if (file.width) { | {{ file.width }}x{{ file.height }} }</span>
</label>
</div>
}
</div>
<div class="form-group" *ngIf="hasAudioSplitted()">
<my-peertube-checkbox inputName="includeAudio" [(ngModel)]="includeAudio" i18n-labelText labelText="Include audio"></my-peertube-checkbox>
</div>
<div class="modal-footer inputs">
<ng-content select="cancel-button"></ng-content>
<input type="submit" i18n-value value="Download" class="peertube-button orange-button" (click)="download()" />
</div>

View File

@@ -0,0 +1,9 @@
@use '_variables' as *;
@use '_mixins' as *;
.peertube-radio-container strong {
@include margin-right(0.5rem);
display: inline-block;
min-width: 80px;
}

View File

@@ -0,0 +1,130 @@
import { KeyValuePipe, NgClass, NgFor, NgIf, NgTemplateOutlet } from '@angular/common'
import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core'
import { FormsModule } from '@angular/forms'
import { PeertubeCheckboxComponent } from '@app/shared/shared-forms/peertube-checkbox.component'
import { VideoService } from '@app/shared/shared-main/video/video.service'
import {
NgbTooltip
} from '@ng-bootstrap/ng-bootstrap'
import { maxBy } from '@peertube/peertube-core-utils'
import { VideoFile, VideoResolution, VideoSource } from '@peertube/peertube-models'
import { videoRequiresFileToken } from '@root-helpers/video'
import { GlobalIconComponent } from '../../shared-icons/global-icon.component'
import { BytesPipe } from '../../shared-main/angular/bytes.pipe'
import { VideoDetails } from '../../shared-main/video/video-details.model'
@Component({
selector: 'my-video-generate-download',
templateUrl: './video-generate-download.component.html',
styleUrls: [ './video-generate-download.component.scss' ],
standalone: true,
imports: [
NgIf,
FormsModule,
GlobalIconComponent,
PeertubeCheckboxComponent,
NgFor,
KeyValuePipe,
NgbTooltip,
NgTemplateOutlet,
NgClass,
BytesPipe
]
})
export class VideoGenerateDownloadComponent implements OnInit {
@Input({ required: true }) video: VideoDetails
@Input() originalVideoFile: VideoSource
@Input() videoFileToken: string
@Output() downloaded = new EventEmitter<void>()
includeAudio = true
videoFileChosen = ''
videoFiles: VideoFile[]
constructor (private videoService: VideoService) {
}
ngOnInit () {
this.videoFiles = this.buildVideoFiles()
if (this.videoFiles.length === 0) return
this.videoFileChosen = 'file-' + maxBy(this.videoFiles, 'resolution').id
}
getFileSize (file: VideoFile) {
if (file.hasAudio && file.hasVideo) return file.size
if (file.hasAudio) return file.size
if (this.includeAudio) {
const audio = this.findAudioFileOnly()
return file.size + (audio.size || 0)
}
return file.size
}
hasAudioSplitted () {
if (this.videoFileChosen === 'file-original') return false
return this.findCurrentFile().hasAudio === false &&
this.videoFiles.some(f => f.hasVideo === false && f.hasAudio === true)
}
// ---------------------------------------------------------------------------
download () {
window.location.assign(this.getVideoFileLink())
this.downloaded.emit()
}
// ---------------------------------------------------------------------------
getVideoFileLink () {
const suffix = this.videoFileChosen === 'file-original' || this.isConfidentialVideo()
? '?videoFileToken=' + this.videoFileToken
: ''
if (this.videoFileChosen === 'file-original') {
return this.originalVideoFile.fileDownloadUrl + suffix
}
const file = this.findCurrentFile()
if (!file) return ''
const files = [ file ]
if (this.hasAudioSplitted() && this.includeAudio) {
files.push(this.findAudioFileOnly())
}
return this.videoService.generateDownloadUrl({ video: this.video, files })
}
// ---------------------------------------------------------------------------
isConfidentialVideo () {
return this.videoFileChosen === 'file-original' || videoRequiresFileToken(this.video)
}
// ---------------------------------------------------------------------------
private buildVideoFiles () {
if (!this.video) return []
const hls = this.video.getHlsPlaylist()
if (hls) return hls.files
return this.video.files
}
private findCurrentFile () {
return this.videoFiles.find(f => this.videoFileChosen === 'file-' + f.id)
}
private findAudioFileOnly () {
return this.videoFiles.find(f => f.resolution.id === VideoResolution.H_NOVIDEO)
}
}

View File

@@ -22,7 +22,7 @@ import { VideoBlockComponent } from '../shared-moderation/video-block.component'
import { VideoBlockService } from '../shared-moderation/video-block.service'
import { LiveStreamInformationComponent } from '../shared-video-live/live-stream-information.component'
import { VideoAddToPlaylistComponent } from '../shared-video-playlist/video-add-to-playlist.component'
import { VideoDownloadComponent } from './video-download.component'
import { VideoDownloadComponent } from './download/video-download.component'
export type VideoActionsDisplayType = {
playlist?: boolean

View File

@@ -1,177 +0,0 @@
<ng-template #modal let-hide="close">
<div class="modal-header">
<h4 class="modal-title">
<ng-container i18n>Download</ng-container>
<div class="peertube-select-container title-select" *ngIf="hasCaptions()">
<select id="type" name="type" [(ngModel)]="type" class="form-control">
<option value="video" i18n>Video</option>
<option value="subtitles" i18n>Subtitles</option>
</select>
</div>
</h4>
<button class="border-0 p-0" title="Close this modal" i18n-title (click)="hide()">
<my-global-icon iconName="cross"></my-global-icon>
</button>
</div>
<div class="modal-body" [ngClass]="{ 'opacity-0': !loaded }">
<div class="alert alert-warning" *ngIf="isConfidentialVideo()" i18n>
The following link contains a private token and should not be shared with anyone.
</div>
<!-- Subtitle tab -->
<ng-container *ngIf="type === 'subtitles'">
<div ngbNav #subtitleNav="ngbNav" class="nav-tabs" [activeId]="subtitleLanguageId" (activeIdChange)="onSubtitleIdChange($event)">
<ng-container *ngFor="let caption of getCaptions()" [ngbNavItem]="caption.language.id">
<a ngbNavLink>
{{ caption.language.label }}
<ng-container *ngIf="caption.automaticallyGenerated" i18n>(auto-generated)</ng-container>
</a>
<ng-template ngbNavContent>
<div class="nav-content">
<my-input-text
*ngIf="!isConfidentialVideo()"
[show]="true" [readonly]="true" [withCopy]="true" [withToggle]="false" [value]="getLink()"
></my-input-text>
</div>
</ng-template>
</ng-container>
</div>
<div [ngbNavOutlet]="subtitleNav"></div>
</ng-container>
<!-- Video tab -->
<ng-container *ngIf="type === 'video'">
<div ngbNav #resolutionNav="ngbNav" class="nav-tabs" [activeId]="resolutionId" (activeIdChange)="onResolutionIdChange($event)">
<ng-template #rootNavContent>
<div class="nav-content">
<my-input-text [show]="true" [readonly]="true" [withCopy]="true" [withToggle]="false" [value]="getLink()"></my-input-text>
</div>
</ng-template>
<ng-container *ngIf="originalVideoFile" ngbNavItem="original">
<a ngbNavLink>
<ng-container i18n>Original file</ng-container>
<my-global-icon ngbTooltip="Other users cannot download the original file" iconName="shield"></my-global-icon>
</a>
<ng-template ngbNavContent>
<ng-template [ngTemplateOutlet]="rootNavContent"></ng-template>
</ng-template>
</ng-container>
<ng-container *ngFor="let file of getVideoFiles()" [ngbNavItem]="file.resolution.id">
<a ngbNavLink>{{ file.resolution.label }}</a>
<ng-template ngbNavContent>
<ng-template [ngTemplateOutlet]="rootNavContent"></ng-template>
</ng-template>
</ng-container>
</div>
<div [ngbNavOutlet]="resolutionNav"></div>
<div class="advanced-filters" [ngbCollapse]="isAdvancedCustomizationCollapsed" [animation]="true">
<div ngbNav #navMetadata="ngbNav" class="nav-tabs nav-metadata">
<ng-template #metadataInfo let-item>
<div class="metadata-attribute">
<span>{{ item.value.label }}</span>
@if (item.value.value) {
<span>{{ item.value.value }}</span>
} @else {
<span i18n>Unknown</span>
}
</div>
</ng-template>
<ng-container ngbNavItem>
<a ngbNavLink i18n>Format</a>
<ng-template ngbNavContent>
<div class="file-metadata">
@for (item of videoFileMetadataFormat | keyvalue; track item) {
<ng-template [ngTemplateOutlet]="metadataInfo" [ngTemplateOutletContext]="{ $implicit: item }"></ng-template>
}
</div>
</ng-template>
</ng-container>
<ng-container ngbNavItem [disabled]="videoFileMetadataVideoStream === undefined">
<a ngbNavLink i18n>Video stream</a>
<ng-template ngbNavContent>
<div class="file-metadata">
@for (item of videoFileMetadataVideoStream | keyvalue; track item) {
<ng-template [ngTemplateOutlet]="metadataInfo" [ngTemplateOutletContext]="{ $implicit: item }"></ng-template>
}
</div>
</ng-template>
</ng-container>
<ng-container ngbNavItem [disabled]="videoFileMetadataAudioStream === undefined">
<a ngbNavLink i18n>Audio stream</a>
<ng-template ngbNavContent>
<div class="file-metadata">
@for (item of videoFileMetadataAudioStream | keyvalue; track item) {
<ng-template [ngTemplateOutlet]="metadataInfo" [ngTemplateOutletContext]="{ $implicit: item }"></ng-template>
}
</div>
</ng-template>
</ng-container>
</div>
<div *ngIf="hasMetadata()" [ngbNavOutlet]="navMetadata"></div>
<div [hidden]="originalVideoFile || !getVideoFile()?.torrentDownloadUrl" class="download-type">
<div class="peertube-radio-container">
<input type="radio" name="download" id="download-direct" [(ngModel)]="downloadType" value="direct">
<label i18n for="download-direct">Direct download</label>
</div>
<div class="peertube-radio-container">
<input type="radio" name="download" id="download-torrent" [(ngModel)]="downloadType" value="torrent">
<label i18n for="download-torrent">Torrent (.torrent file)</label>
</div>
</div>
</div>
<button
(click)="isAdvancedCustomizationCollapsed = !isAdvancedCustomizationCollapsed"
class="advanced-filters-button button-unstyle"
[attr.aria-expanded]="!isAdvancedCustomizationCollapsed" aria-controls="collapseBasic"
>
<ng-container *ngIf="isAdvancedCustomizationCollapsed">
<span class="chevron-down"></span>
<ng-container i18n>More information/options</ng-container>
</ng-container>
<ng-container *ngIf="!isAdvancedCustomizationCollapsed">
<span class="chevron-up"></span>
<ng-container i18n>Less information/options</ng-container>
</ng-container>
</button>
</ng-container>
</div>
<div class="modal-footer inputs">
<input
type="button" role="button" i18n-value value="Cancel" class="peertube-button grey-button"
(click)="hide()" (key.enter)="hide()"
>
<input type="submit" i18n-value value="Download" class="peertube-button orange-button" (click)="download()" />
</div>
</ng-template>

View File

@@ -1,19 +1,15 @@
// Thanks https://github.com/streamroot/videojs-hlsjs-plugin
// We duplicated this plugin to choose the hls.js version we want, because streamroot only provide a bundled file
import Hlsjs, { ErrorData, HlsConfig, Level, LevelSwitchingData, ManifestParsedData } from 'hls.js'
import videojs from 'video.js'
import { logger } from '@root-helpers/logger'
import { HlsjsConfigHandlerOptions, PeerTubeResolution, VideoJSTechHLS } from '../../types'
import Hlsjs, { ErrorData, Level, LevelSwitchingData, ManifestParsedData } from 'hls.js'
import videojs from 'video.js'
import { HLSPluginOptions, HlsjsConfigHandlerOptions, PeerTubeResolution, VideoJSTechHLS } from '../../types'
type ErrorCounts = {
[ type: string ]: number
}
type Metadata = {
levels: Level[]
}
// ---------------------------------------------------------------------------
// Source handler registration
// ---------------------------------------------------------------------------
@@ -126,10 +122,10 @@ export class Html5Hlsjs {
private maxNetworkErrorRecovery = 5
private hls: Hlsjs
private hlsjsConfig: Partial<HlsConfig & { cueHandler: any }> = null
private hlsjsConfig: HLSPluginOptions = null
private _duration: number = null
private metadata: Metadata = null
private metadata: ManifestParsedData = null
private isLive: boolean = null
private dvrDuration: number = null
private edgeMargin: number = null
@@ -139,6 +135,8 @@ export class Html5Hlsjs {
error: null
}
private audioMode = false
constructor (vjs: typeof videojs, source: videojs.Tech.SourceObject, tech: videojs.Tech) {
this.vjs = vjs
this.source = source
@@ -206,50 +204,14 @@ export class Html5Hlsjs {
return this.vjs.createTimeRanges()
}
// See comment for `initialize` method.
dispose () {
this.videoElement.removeEventListener('play', this.handlers.play)
this.videoElement.removeEventListener('error', this.handlers.error)
// FIXME: https://github.com/video-dev/hls.js/issues/4092
const untypedHLS = this.hls as any
untypedHLS.log = untypedHLS.warn = () => {
// empty
}
this.hls.destroy()
}
static addHook (type: string, callback: HookFn) {
Html5Hlsjs.hooks[type] = this.hooks[type] || []
Html5Hlsjs.hooks[type].push(callback)
}
static removeHook (type: string, callback: HookFn) {
if (Html5Hlsjs.hooks[type] === undefined) return false
const index = Html5Hlsjs.hooks[type].indexOf(callback)
if (index === -1) return false
Html5Hlsjs.hooks[type].splice(index, 1)
return true
}
static removeAllHooks () {
Html5Hlsjs.hooks = {}
}
private _executeHooksFor (type: string) {
if (Html5Hlsjs.hooks[type] === undefined) {
return
}
// ES3 and IE < 9
for (let i = 0; i < Html5Hlsjs.hooks[type].length; i++) {
Html5Hlsjs.hooks[type][i](this.player, this.hls)
}
}
// ---------------------------------------------------------------------------
private _getHumanErrorMsg (error: { message: string, code?: number }) {
switch (error.code) {
@@ -265,11 +227,14 @@ export class Html5Hlsjs {
}
this.hls.destroy()
logger.info('bubbling error up to VIDEOJS')
this.tech.error = () => ({
...error,
message: this._getHumanErrorMsg(error)
})
this.tech.trigger('error')
}
@@ -335,16 +300,18 @@ export class Html5Hlsjs {
}
}
// ---------------------------------------------------------------------------
private buildLevelLabel (level: Level) {
if (this.player.srOptions_.levelLabelHandler) {
return this.player.srOptions_.levelLabelHandler(level as any)
return this.player.srOptions_.levelLabelHandler(level, this.player)
}
if (level.height) return level.height + 'p'
if (level.width) return Math.round(level.width * 9 / 16) + 'p'
if (level.bitrate) return (level.bitrate / 1000) + 'kbps'
return '0'
return this.player.localize('Audio only')
}
private _removeQuality (index: number) {
@@ -367,50 +334,61 @@ export class Html5Hlsjs {
label: this.buildLevelLabel(level),
selected: level.id === this.hls.manualLevel,
selectCallback: () => {
this.hls.currentLevel = index
}
selectCallback: () => this.manuallySelectVideoLevel(index)
})
})
// Add a manually injected "Audio only" quality that will reloads hls.js
const videoResolutions = resolutions.filter(r => r.height !== 0)
if (videoResolutions.length !== 0 && this.getSeparateAudioTrack()) {
const audioTrackUrl = this.getSeparateAudioTrack()
resolutions.push({
id: -2, // -1 is for "Auto quality"
label: this.player.localize('Audio only'),
selected: false,
selectCallback: () => {
if (this.audioMode) return
this.audioMode = true
this.updateToAudioOrVideo(audioTrackUrl)
}
})
}
resolutions.push({
id: -1,
label: this.player.localize('Auto'),
selected: true,
selectCallback: () => this.hls.currentLevel = -1
selectCallback: () => this.manuallySelectVideoLevel(-1)
})
this.player.peertubeResolutions().add(resolutions)
}
private manuallySelectVideoLevel (index: number) {
if (this.audioMode) {
this.audioMode = false
this.updateToAudioOrVideo(this.source.src, index)
return
}
this.hls.currentLevel = index
}
private _startLoad () {
this.hls.startLoad(-1)
this.videoElement.removeEventListener('play', this.handlers.play)
}
private _oneLevelObjClone (obj: { [ id: string ]: any }) {
const result: { [id: string]: any } = {}
const objKeys = Object.keys(obj)
for (let i = 0; i < objKeys.length; i++) {
result[objKeys[i]] = obj[objKeys[i]]
}
return result
}
private _onMetaData (_event: any, data: ManifestParsedData) {
// This could arrive before 'loadedqualitydata' handlers is registered, remember it so we can raise it later
this.metadata = data
this._notifyVideoQualities()
}
private _initHlsjs () {
const techOptions = this.tech.options_ as HlsjsConfigHandlerOptions
const srOptions_ = this.player.srOptions_
const hlsjsConfigRef = srOptions_?.hlsjsConfig || techOptions.hlsjsConfig
// Hls.js will write to the reference thus change the object for later streams
this.hlsjsConfig = hlsjsConfigRef ? this._oneLevelObjClone(hlsjsConfigRef) : {}
private initialize () {
this.buildBaseConfig()
if ([ '', 'auto' ].includes(this.videoElement.preload) && !this.videoElement.autoplay && this.hlsjsConfig.autoStartLoad === undefined) {
this.hlsjsConfig.autoStartLoad = false
@@ -423,9 +401,10 @@ export class Html5Hlsjs {
this.videoElement.addEventListener('play', this.handlers.play)
}
this.hls = new Hlsjs(this.hlsjsConfig)
const loader = this.hlsjsConfig.loaderBuilder()
this.hls = new Hlsjs({ ...this.hlsjsConfig, loader })
this._executeHooksFor('beforeinitialize')
this.player.trigger('hlsjs-initialized', { hlsjs: this.hls, engine: loader.getEngine() })
this.hls.on(Hlsjs.Events.ERROR, (event, data) => this._onError(event, data))
this.hls.on(Hlsjs.Events.MANIFEST_PARSED, (event, data) => this._onMetaData(event, data))
@@ -446,30 +425,83 @@ export class Html5Hlsjs {
if (this.isLive) this.maxNetworkErrorRecovery = 30
})
this.registerLevelEventSwitch()
this.hls.once(Hlsjs.Events.FRAG_LOADED, () => {
// Emit custom 'loadedmetadata' event for parity with `videojs-contrib-hls`
// Ref: https://github.com/videojs/videojs-contrib-hls#loadedmetadata
this.tech.trigger('loadedmetadata')
})
this.hls.on(Hlsjs.Events.LEVEL_SWITCHING, (_e, data: LevelSwitchingData) => {
const resolutionId = this.hls.autoLevelEnabled
? -1
: data.level
const autoResolutionChosenId = this.hls.autoLevelEnabled
? data.level
: -1
this.player.peertubeResolutions().select({ id: resolutionId, autoResolutionChosenId, fireCallback: false })
})
this.hls.attachMedia(this.videoElement)
this.hls.loadSource(this.source.src)
}
private initialize () {
this._initHlsjs()
private updateToAudioOrVideo (newSource: string, startLevel?: number) {
this.player.addClass('vjs-updating-resolution')
const currentTime = this.player.currentTime()
this.dispose()
this.buildBaseConfig()
this.hlsjsConfig.autoStartLoad = true
this.player.autoplay('play')
const loader = this.hlsjsConfig.loaderBuilder()
this.hls = new Hlsjs({
...this.hlsjsConfig,
loader,
startPosition: this.duration() === Infinity
? undefined
: currentTime,
startLevel
})
this.player.trigger('hlsjs-initialized', { hlsjs: this.hls, engine: loader.getEngine() })
this.hls.on(Hlsjs.Events.ERROR, (event, data) => this._onError(event, data))
this.registerLevelEventSwitch()
this.hls.attachMedia(this.videoElement)
this.hls.loadSource(newSource)
this.player.one('canplay', () => {
this.player.removeClass('vjs-updating-resolution')
})
}
private registerLevelEventSwitch () {
this.hls.on(Hlsjs.Events.LEVEL_SWITCHING, (_e, data: LevelSwitchingData) => {
let resolutionId = data.level
let autoResolutionChosenId = -1
if (this.audioMode) {
resolutionId = -2
} else if (this.hls.autoLevelEnabled) {
resolutionId = -1
autoResolutionChosenId = data.level
}
this.player.peertubeResolutions().select({ id: resolutionId, autoResolutionChosenId, fireCallback: false })
})
}
private buildBaseConfig () {
const techOptions = this.tech.options_ as HlsjsConfigHandlerOptions
const srOptions_ = this.player.srOptions_
const hlsjsConfigRef = srOptions_?.hlsjsConfig || techOptions.hlsjsConfig
// Hls.js will write to the reference thus change the object for later streams
this.hlsjsConfig = hlsjsConfigRef
? { ...hlsjsConfigRef }
: {}
}
private getSeparateAudioTrack () {
if (this.metadata.audioTracks.length === 0) return undefined
return this.metadata.audioTracks[0].url
}
}

View File

@@ -6,6 +6,9 @@ import Hlsjs from 'hls.js'
import videojs from 'video.js'
import { P2PMediaLoaderPluginOptions, PlayerNetworkInfo } from '../../types'
import { SettingsButton } from '../settings/settings-menu-button'
import debug from 'debug'
const debugLogger = debug('peertube:player:p2p-media-loader')
const Plugin = videojs.getPlugin('plugin')
class P2pMediaLoaderPlugin extends Plugin {
@@ -56,19 +59,23 @@ class P2pMediaLoaderPlugin extends Plugin {
return
}
// FIXME: typings https://github.com/Microsoft/TypeScript/issues/14080
(videojs as any).Html5Hlsjs.addHook('beforeinitialize', (_videojsPlayer: any, hlsjs: any) => {
player.on('hlsjs-initialized', (_: any, { hlsjs, engine }) => {
this.p2pEngine?.removeAllListeners()
this.p2pEngine?.destroy()
clearInterval(this.networkInfoInterval)
this.hlsjs = hlsjs
this.p2pEngine = engine
debugLogger('hls.js initialized, initializing p2p-media-loader plugin', { hlsjs, engine })
player.ready(() => this.initializePlugin())
})
player.src({
type: options.type,
src: options.src
})
player.ready(() => {
this.initializePlugin()
})
}
dispose () {
@@ -76,9 +83,7 @@ class P2pMediaLoaderPlugin extends Plugin {
this.p2pEngine?.destroy()
this.hlsjs?.destroy()
this.options.segmentValidator?.destroy();
(videojs as any).Html5Hlsjs?.removeAllHooks()
this.options.segmentValidator?.destroy()
clearInterval(this.networkInfoInterval)
@@ -112,8 +117,6 @@ class P2pMediaLoaderPlugin extends Plugin {
private initializePlugin () {
initHlsJsPlayer(this.player, this.hlsjs)
this.p2pEngine = this.options.loader.getEngine()
this.p2pEngine.on(Events.SegmentError, (segment: Segment, err) => {
if (navigator.onLine === false) return

View File

@@ -3,6 +3,9 @@ import { logger } from '@root-helpers/logger'
import { wait } from '@root-helpers/utils'
import { removeQueryParams } from '@peertube/peertube-core-utils'
import { isSameOrigin } from '../common'
import debug from 'debug'
const debugLogger = debug('peertube:player:segment-validator')
type SegmentsJSON = { [filename: string]: string | { [byterange: string]: string } }
@@ -67,6 +70,8 @@ export class SegmentValidator {
throw new Error(`Unknown segment name ${filename}/${range} in segment validator`)
}
debugLogger(`Validating ${filename} range ${segment.range}`)
const calculatedSha = await this.sha256Hex(segment.data)
if (calculatedSha !== hashShouldBe) {
throw new Error(

View File

@@ -4,7 +4,13 @@ import { LiveVideoLatencyMode } from '@peertube/peertube-models'
import { logger } from '@root-helpers/logger'
import { peertubeLocalStorage } from '@root-helpers/peertube-web-storage'
import { getAverageBandwidthInStore } from '../../peertube-player-local-storage'
import { P2PMediaLoader, P2PMediaLoaderPluginOptions, PeerTubePlayerContructorOptions, PeerTubePlayerLoadOptions } from '../../types'
import {
HLSLoaderClass,
HLSPluginOptions,
P2PMediaLoaderPluginOptions,
PeerTubePlayerContructorOptions,
PeerTubePlayerLoadOptions
} from '../../types'
import { getRtcConfig, isSameOrigin } from '../common'
import { RedundancyUrlManager } from '../p2p-media-loader/redundancy-url-manager'
import { segmentUrlBuilderFactory } from '../p2p-media-loader/segment-url-builder'
@@ -47,7 +53,7 @@ export class HLSOptionsBuilder {
'filter:internal.player.p2p-media-loader.options.result',
this.getP2PMediaLoaderOptions({ redundancyUrlManager, segmentValidator })
)
const loader = new Engine(p2pMediaLoaderConfig).createLoaderClass() as unknown as P2PMediaLoader
const loaderBuilder = () => new Engine(p2pMediaLoaderConfig).createLoaderClass() as unknown as HLSLoaderClass
const p2pMediaLoader: P2PMediaLoaderPluginOptions = {
requiresUserAuth: this.options.requiresUserAuth,
@@ -58,19 +64,22 @@ export class HLSOptionsBuilder {
redundancyUrlManager,
type: 'application/x-mpegURL',
src: this.options.hls.playlistUrl,
segmentValidator,
loader
segmentValidator
}
const hlsjs = {
hlsjsConfig: this.getHLSJSOptions(loader),
hlsjsConfig: this.getHLSJSOptions(loaderBuilder),
levelLabelHandler: (level: { height: number, width: number }) => {
levelLabelHandler: (level: { height: number, width: number }, player: videojs.VideoJsPlayer) => {
const resolution = Math.min(level.height || 0, level.width || 0)
const file = this.options.hls.videoFiles.find(f => f.resolution.id === resolution)
// We don't have files for live videos
if (!file) return level.height
if (!file) {
if (resolution === 0) return player.localize('Audio only')
return level.height + 'p'
}
let label = file.resolution.label
if (file.fps >= 50) label += file.fps
@@ -185,7 +194,7 @@ export class HLSOptionsBuilder {
// ---------------------------------------------------------------------------
private getHLSJSOptions (loader: P2PMediaLoader) {
private getHLSJSOptions (loaderBuilder: () => HLSLoaderClass): HLSPluginOptions {
const specificLiveOrVODOptions = this.options.isLive
? this.getHLSLiveOptions()
: this.getHLSVODOptions()
@@ -194,7 +203,7 @@ export class HLSOptionsBuilder {
capLevelToPlayerSize: true,
autoStartLoad: false,
loader,
loaderBuilder,
...specificLiveOrVODOptions
}

View File

@@ -56,7 +56,9 @@ class PeerTubeResolutionsPlugin extends Plugin {
if (this.currentSelection?.id === id && this.autoResolutionChosenId === autoResolutionChosenId) return
this.autoResolutionChosenId = autoResolutionChosenId
if (autoResolutionChosenId !== undefined) {
this.autoResolutionChosenId = autoResolutionChosenId
}
for (const r of this.resolutions) {
r.selected = r.id === id

View File

@@ -42,7 +42,7 @@ class ResolutionMenuButton extends MenuButton {
for (const r of resolutions) {
const label = r.label === '0p'
? this.player().localize('Audio-only')
? this.player().localize('Audio only')
: r.label
const component = new ResolutionMenuItem(

View File

@@ -1,8 +1,10 @@
import { HlsConfig, Level } from 'hls.js'
import videojs from 'video.js'
import { Engine } from '@peertube/p2p-media-loader-hlsjs'
import { VideoChapter, VideoFile, VideoPlaylist, VideoPlaylistElement } from '@peertube/peertube-models'
import type { HlsConfig, Level, Loader, LoaderContext } from 'hls.js'
import videojs from 'video.js'
import { BezelsPlugin } from '../shared/bezels/bezels-plugin'
import { ContextMenuPlugin } from '../shared/context-menu'
import { ChaptersPlugin } from '../shared/control-bar/chapters-plugin'
import { StoryboardPlugin } from '../shared/control-bar/storyboard-plugin'
import { PeerTubeDockPlugin, PeerTubeDockPluginOptions } from '../shared/dock/peertube-dock-plugin'
import { HotkeysOptions, PeerTubeHotkeysPlugin } from '../shared/hotkeys/peertube-hotkeys-plugin'
@@ -10,6 +12,7 @@ import { PeerTubeMobilePlugin } from '../shared/mobile/peertube-mobile-plugin'
import { Html5Hlsjs } from '../shared/p2p-media-loader/hls-plugin'
import { P2pMediaLoaderPlugin } from '../shared/p2p-media-loader/p2p-media-loader-plugin'
import { RedundancyUrlManager } from '../shared/p2p-media-loader/redundancy-url-manager'
import { SegmentValidator } from '../shared/p2p-media-loader/segment-validator'
import { PeerTubePlugin } from '../shared/peertube/peertube-plugin'
import { PlaylistPlugin } from '../shared/playlist/playlist-plugin'
import { PeerTubeResolutionsPlugin } from '../shared/resolutions/peertube-resolutions-plugin'
@@ -18,9 +21,6 @@ import { StatsForNerdsPlugin } from '../shared/stats/stats-plugin'
import { UpNextPlugin } from '../shared/upnext/upnext-plugin'
import { WebVideoPlugin } from '../shared/web-video/web-video-plugin'
import { PlayerMode } from './peertube-player-options'
import { SegmentValidator } from '../shared/p2p-media-loader/segment-validator'
import { ChaptersPlugin } from '../shared/control-bar/chapters-plugin'
import { ContextMenuPlugin } from '../shared/context-menu'
declare module 'video.js' {
@@ -79,10 +79,10 @@ export interface VideoJSTechHLS extends videojs.Tech {
export interface HlsjsConfigHandlerOptions {
hlsjsConfig?: HlsConfig
levelLabelHandler?: (level: Level) => string
levelLabelHandler?: (level: Level, player: videojs.Player) => string
}
type PeerTubeResolution = {
export type PeerTubeResolution = {
id: number
height?: number
@@ -94,21 +94,21 @@ type PeerTubeResolution = {
selectCallback: () => void
}
type VideoJSCaption = {
export type VideoJSCaption = {
label: string
language: string
src: string
automaticallyGenerated: boolean
}
type VideoJSStoryboard = {
export type VideoJSStoryboard = {
url: string
width: number
height: number
interval: number
}
type PeerTubePluginOptions = {
export type PeerTubePluginOptions = {
autoPlayerRatio: {
cssRatioVariable: string
cssPlayerPortraitModeVariable: string
@@ -136,14 +136,14 @@ type PeerTubePluginOptions = {
poster: () => string
}
type MetricsPluginOptions = {
export type MetricsPluginOptions = {
mode: () => PlayerMode
metricsUrl: () => string
metricsInterval: () => number
videoUUID: () => string
}
type ContextMenuPluginOptions = {
export type ContextMenuPluginOptions = {
content: () => {
icon?: string
label: string
@@ -151,23 +151,23 @@ type ContextMenuPluginOptions = {
}[]
}
type ContextMenuItemOptions = {
export type ContextMenuItemOptions = {
listener: (e: videojs.EventTarget.Event) => void
label: string
}
type StoryboardOptions = {
export type StoryboardOptions = {
url: string
width: number
height: number
interval: number
}
type ChaptersOptions = {
export type ChaptersOptions = {
chapters: VideoChapter[]
}
type PlaylistPluginOptions = {
export type PlaylistPluginOptions = {
elements: VideoPlaylistElement[]
playlist: VideoPlaylist
@@ -177,7 +177,7 @@ type PlaylistPluginOptions = {
onItemClicked: (element: VideoPlaylistElement) => void
}
type UpNextPluginOptions = {
export type UpNextPluginOptions = {
timeout: number
next: () => void
@@ -186,33 +186,40 @@ type UpNextPluginOptions = {
isSuspended: () => boolean
}
type ProgressBarMarkerComponentOptions = {
export type ProgressBarMarkerComponentOptions = {
timecode: number
}
type NextPreviousVideoButtonOptions = {
export type NextPreviousVideoButtonOptions = {
type: 'next' | 'previous'
handler?: () => void
isDisplayed: () => boolean
isDisabled: () => boolean
}
type PeerTubeLinkButtonOptions = {
export type PeerTubeLinkButtonOptions = {
isDisplayed: () => boolean
shortUUID: () => string
instanceName: string
}
type TheaterButtonOptions = {
export type TheaterButtonOptions = {
isDisplayed: () => boolean
}
type WebVideoPluginOptions = {
export type WebVideoPluginOptions = {
videoFiles: VideoFile[]
videoFileToken: () => string
}
type P2PMediaLoaderPluginOptions = {
export type HLSLoaderClass = {
new (confg: HlsConfig): Loader<LoaderContext>
getEngine(): Engine
}
export type HLSPluginOptions = Partial<HlsConfig & { cueHandler: any, loaderBuilder: () => HLSLoaderClass }>
export type P2PMediaLoaderPluginOptions = {
redundancyUrlManager: RedundancyUrlManager | null
segmentValidator: SegmentValidator | null
@@ -221,8 +228,6 @@ type P2PMediaLoaderPluginOptions = {
p2pEnabled: boolean
loader: P2PMediaLoader
requiresUserAuth: boolean
videoFileToken: () => string
}
@@ -233,7 +238,7 @@ export type P2PMediaLoader = {
destroy: () => void
}
type VideoJSPluginOptions = {
export type VideoJSPluginOptions = {
playlist?: PlaylistPluginOptions
peertube: PeerTubePluginOptions
@@ -244,7 +249,7 @@ type VideoJSPluginOptions = {
p2pMediaLoader?: P2PMediaLoaderPluginOptions
}
type LoadedQualityData = {
export type LoadedQualityData = {
qualitySwitchCallback: (resolutionId: number, type: 'video') => void
qualityData: {
video: {
@@ -255,17 +260,17 @@ type LoadedQualityData = {
}
}
type ResolutionUpdateData = {
export type ResolutionUpdateData = {
auto: boolean
resolutionId: number
id?: number
}
type AutoResolutionUpdateData = {
export type AutoResolutionUpdateData = {
possible: boolean
}
type PlayerNetworkInfo = {
export type PlayerNetworkInfo = {
source: 'web-video' | 'p2p-media-loader'
http: {
@@ -288,34 +293,8 @@ type PlayerNetworkInfo = {
bandwidthEstimate?: number
}
type PlaylistItemOptions = {
export type PlaylistItemOptions = {
element: VideoPlaylistElement
onClicked: () => void
}
export {
PlayerNetworkInfo,
TheaterButtonOptions,
VideoJSStoryboard,
PlaylistItemOptions,
NextPreviousVideoButtonOptions,
ResolutionUpdateData,
AutoResolutionUpdateData,
ProgressBarMarkerComponentOptions,
PlaylistPluginOptions,
MetricsPluginOptions,
VideoJSCaption,
PeerTubePluginOptions,
WebVideoPluginOptions,
P2PMediaLoaderPluginOptions,
ContextMenuItemOptions,
PeerTubeResolution,
VideoJSPluginOptions,
ContextMenuPluginOptions,
UpNextPluginOptions,
LoadedQualityData,
StoryboardOptions,
ChaptersOptions,
PeerTubeLinkButtonOptions
}

View File

@@ -0,0 +1 @@
VITE_BACKEND_URL="http://localhost:9000"

View File

@@ -24,7 +24,8 @@ import {
PlaylistFetcher,
PlaylistTracker,
Translations,
VideoFetcher
VideoFetcher,
getBackendUrl
} from './shared'
import { PlayerHTML } from './shared/player-html'
@@ -58,7 +59,7 @@ export class PeerTubeEmbed {
private requiresPassword: boolean
constructor (videoWrapperId: string) {
logger.registerServerSending(window.location.origin)
logger.registerServerSending(getBackendUrl())
this.http = new AuthHTTP()
@@ -73,7 +74,9 @@ export class PeerTubeEmbed {
try {
this.config = JSON.parse((window as any)['PeerTubeServerConfig'])
} catch (err) {
logger.error('Cannot parse HTML config.', err)
if (!(import.meta as any).env.DEV) {
logger.error('Cannot parse HTML config.', err)
}
}
}
@@ -90,12 +93,12 @@ export class PeerTubeEmbed {
// ---------------------------------------------------------------------------
async init () {
this.translationsPromise = TranslationsManager.getServerTranslations(window.location.origin, navigator.language)
this.translationsPromise = TranslationsManager.getServerTranslations(getBackendUrl(), navigator.language)
this.PeerTubePlayerManagerModulePromise = import('../../assets/player/peertube-player')
// Issue when we parsed config from HTML, fallback to API
if (!this.config) {
this.config = await this.http.fetch('/api/v1/config', { optionalAuth: false })
this.config = await this.http.fetch(getBackendUrl() + '/api/v1/config', { optionalAuth: false })
.then(res => res.json())
}
@@ -265,7 +268,7 @@ export class PeerTubeEmbed {
// If already played, we are in a playlist so we don't want to display the poster between videos
if (!this.alreadyPlayed) {
this.peertubePlayer.setPoster(window.location.origin + video.previewPath)
this.peertubePlayer.setPoster(getBackendUrl() + video.previewPath)
}
const playlist = this.playlistTracker
@@ -351,6 +354,16 @@ export class PeerTubeEmbed {
// ---------------------------------------------------------------------------
private getResourceId () {
const search = window.location.search
if (search.startsWith('?videoId=')) {
return search.replace(/^\?videoId=/, '')
}
if (search.startsWith('?videoPlaylistId=')) {
return search.replace(/^\?videoPlaylistId=/, '')
}
const urlParts = window.location.pathname.split('/')
return urlParts[urlParts.length - 1]
}

View File

@@ -5,5 +5,6 @@ export * from './player-html'
export * from './player-options-builder'
export * from './playlist-fetcher'
export * from './playlist-tracker'
export * from './url'
export * from './translations'
export * from './video-fetcher'

View File

@@ -1,7 +1,8 @@
import { Socket } from 'socket.io-client'
import { LiveVideoEventPayload, VideoDetails, VideoState, VideoStateType } from '@peertube/peertube-models'
import { Socket } from 'socket.io-client'
import { PlayerHTML } from './player-html'
import { Translations } from './translations'
import { getBackendUrl } from './url'
export class LiveManager {
private liveSocket: Socket
@@ -22,7 +23,7 @@ export class LiveManager {
if (!this.liveSocket) {
const io = (await import('socket.io-client')).io
this.liveSocket = io(window.location.origin + '/live-videos')
this.liveSocket = io(getBackendUrl() + '/live-videos')
}
const listener = (payload: LiveVideoEventPayload) => {

View File

@@ -4,6 +4,7 @@ import { PluginInfo, PluginsManager } from '../../../root-helpers'
import { RegisterClientHelpers } from '../../../types'
import { AuthHTTP } from './auth-http'
import { Translations } from './translations'
import { getBackendUrl } from './url'
export class PeerTubePlugin {
@@ -83,6 +84,6 @@ export class PeerTubePlugin {
}
private getPluginUrl () {
return window.location.origin + '/api/v1/plugins'
return getBackendUrl() + '/api/v1/plugins'
}
}

View File

@@ -27,6 +27,7 @@ import { PlayerHTML } from './player-html'
import { PlaylistTracker } from './playlist-tracker'
import { Translations } from './translations'
import { VideoFetcher } from './video-fetcher'
import { getBackendUrl } from './url'
export class PlayerOptionsBuilder {
private autoplay: boolean
@@ -190,7 +191,7 @@ export class PlayerOptionsBuilder {
videoViewIntervalMs: serverConfig.views.videos.watchingInterval.anonymous,
metricsUrl: serverConfig.openTelemetry.metrics.enabled
? window.location.origin + '/api/v1/metrics/playback'
? getBackendUrl() + '/api/v1/metrics/playback'
: null,
metricsInterval: serverConfig.openTelemetry.metrics.playbackStatsInterval,
@@ -204,7 +205,7 @@ export class PlayerOptionsBuilder {
theaterButton: false,
serverUrl: window.location.origin,
serverUrl: getBackendUrl(),
language: navigator.language,
pluginsManager: this.peertubePlugin.getPluginsManager(),
@@ -292,9 +293,9 @@ export class PlayerOptionsBuilder {
duration: video.duration,
videoRatio: video.aspectRatio,
poster: window.location.origin + video.previewPath,
poster: getBackendUrl() + video.previewPath,
embedUrl: window.location.origin + video.embedPath,
embedUrl: getBackendUrl() + video.embedPath,
embedTitle: video.name,
requiresUserAuth: videoRequiresUserAuth(video),
@@ -333,7 +334,7 @@ export class PlayerOptionsBuilder {
if (!storyboards || storyboards.length === 0) return undefined
return {
url: window.location.origin + storyboards[0].storyboardPath,
url: getBackendUrl() + storyboards[0].storyboardPath,
height: storyboards[0].spriteHeight,
width: storyboards[0].spriteWidth,
interval: storyboards[0].spriteDuration
@@ -426,7 +427,7 @@ export class PlayerOptionsBuilder {
label: peertubeTranslate(c.language.label, translations),
language: c.language.id,
automaticallyGenerated: c.automaticallyGenerated,
src: window.location.origin + c.captionPath
src: getBackendUrl() + c.captionPath
}))
}

View File

@@ -1,6 +1,7 @@
import { HttpStatusCode, ResultList, VideoPlaylistElement } from '@peertube/peertube-models'
import { logger } from '../../../root-helpers'
import { AuthHTTP } from './auth-http'
import { getBackendUrl } from './url'
export class PlaylistFetcher {
@@ -68,6 +69,6 @@ export class PlaylistFetcher {
}
private getPlaylistUrl (id: string) {
return window.location.origin + '/api/v1/video-playlists/' + id
return getBackendUrl() + '/api/v1/video-playlists/' + id
}
}

View File

@@ -0,0 +1,3 @@
export function getBackendUrl () {
return (import.meta as any).env.VITE_BACKEND_URL || window.location.origin
}

View File

@@ -2,6 +2,7 @@ import { HttpStatusCode, LiveVideo, VideoDetails, VideoToken } from '@peertube/p
import { logger } from '../../../root-helpers'
import { PeerTubeServerError } from '../../../types'
import { AuthHTTP } from './auth-http'
import { getBackendUrl } from './url'
export class VideoFetcher {
@@ -70,11 +71,11 @@ export class VideoFetcher {
}
private getVideoUrl (id: string) {
return window.location.origin + '/api/v1/videos/' + id
return getBackendUrl() + '/api/v1/videos/' + id
}
private getLiveUrl (videoId: string) {
return window.location.origin + '/api/v1/videos/live/' + videoId
return getBackendUrl() + '/api/v1/videos/live/' + videoId
}
private loadStoryboards (videoUUID: string): Promise<Response> {
@@ -82,7 +83,7 @@ export class VideoFetcher {
}
private getStoryboardsUrl (videoId: string) {
return window.location.origin + '/api/v1/videos/' + videoId + '/storyboards'
return getBackendUrl() + '/api/v1/videos/' + videoId + '/storyboards'
}
private getVideoTokenUrl (id: string) {

View File

@@ -9,15 +9,40 @@ const __dirname = dirname(fileURLToPath(import.meta.url))
const root = resolve(__dirname, '../../../')
export default defineConfig(() => {
export default defineConfig(({ mode }) => {
return {
base: '/client/standalone/videos/',
base: mode === 'development'
? ''
: '/client/standalone/videos/',
root: resolve(root, 'src', 'standalone', 'videos'),
server: {
proxy: {
'^/(videos|video-playlists)/(test-)?embed/[^\/\.]+$': {
target: 'http://localhost:5173',
rewrite: (path) => {
return path.replace('/videos/embed/', 'embed.html?videoId=')
.replace('/videos/test-embed/', 'test-embed.html?')
.replace('/video-playlists/embed/', 'embed.html?videoPlaylistId=')
.replace('/video-playlists/test-embed/', 'test-embed.html?videoPlaylistId=')
}
},
'^/(videos|video-playlists)/(test-)?embed/.*': {
target: 'http://localhost:5173',
rewrite: (path) => {
return path.replace(/\/(videos|video-playlists)\/(test-)?embed\//, '')
}
},
'^/lazy-static': {
target: 'http://localhost:9000'
}
}
},
resolve: {
alias: [
{ find: /^video.js$/, replacement: resolve(root, './node_modules/video.js/core.js') },
{ find: /^hls.js$/, replacement: resolve(root, './node_modules/hls.js/dist/hls.light.mjs') },
{ find: '@root-helpers', replacement: resolve(root, './src/root-helpers') }
],
},
@@ -33,6 +58,7 @@ export default defineConfig(() => {
build: {
outDir: resolve(root, 'dist', 'standalone', 'videos'),
emptyOutDir: true,
sourcemap: mode === 'development',
target: [ 'firefox78', 'ios12' ],

View File

@@ -28,9 +28,6 @@
],
"baseUrl": "./",
"paths": {
"hls.js": [
"node_modules/hls.js/dist/hls.light"
],
"video.js": [
"node_modules/video.js/core"
],