feat-1322 Version 3 without server side code changes
This commit is contained in:
parent
4d6d2f0479
commit
58f156e748
8 changed files with 320 additions and 5 deletions
|
@ -0,0 +1,41 @@
|
||||||
|
<div class="root">
|
||||||
|
|
||||||
|
<div id="embedContainer" class="" style="position: relative;"></div>
|
||||||
|
|
||||||
|
@if (!selectingFromVideo) {
|
||||||
|
|
||||||
|
<div class="preview-container">
|
||||||
|
|
||||||
|
@if (imageSrc) {
|
||||||
|
<img [ngStyle]="{ width: previewWidth, height: previewHeight }" [src]="imageSrc" class="preview" alt="Preview" i18n-alt />
|
||||||
|
}
|
||||||
|
@else {
|
||||||
|
<div class="preview no-image"></div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="inputs">
|
||||||
|
|
||||||
|
@if (selectingFromVideo) {
|
||||||
|
|
||||||
|
<input type="button" i18n class="peertube-button orange-button" (click)="selectFrame()" value="Use frame" />
|
||||||
|
|
||||||
|
<input type="button" i18n class="peertube-button grey-button" (click)="resetSelectFromVideo()" value="Cancel" />
|
||||||
|
|
||||||
|
}
|
||||||
|
@else {
|
||||||
|
|
||||||
|
<my-reactive-file inputName="uploadNewThumbnail" inputLabel="Upload image" [extensions]="videoImageExtensions"
|
||||||
|
[maxFileSize]="maxVideoImageSize" placement="right" icon="upload" (fileChanged)="onFileChanged($event)"
|
||||||
|
[buttonTooltip]="getReactiveFileButtonTooltip()">
|
||||||
|
</my-reactive-file>
|
||||||
|
|
||||||
|
<input type="button" i18n class="peertube-button grey-button" (click)="selectFromVideo()"
|
||||||
|
value="Select from video" />
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
|
@ -0,0 +1,54 @@
|
||||||
|
@use 'sass:math';
|
||||||
|
@use '_variables' as *;
|
||||||
|
|
||||||
|
.root {
|
||||||
|
height: auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
.preview-container {
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
.preview {
|
||||||
|
object-fit: cover;
|
||||||
|
border-radius: 4px;
|
||||||
|
max-width: 100%;
|
||||||
|
|
||||||
|
&.no-image {
|
||||||
|
border: 2px solid #808080;
|
||||||
|
background-color: pvar(--mainBackgroundColor);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.inputs {
|
||||||
|
|
||||||
|
margin-top: 10px;
|
||||||
|
|
||||||
|
my-reactive-file {
|
||||||
|
display: inline-block;
|
||||||
|
margin-right: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
input {
|
||||||
|
display: inline-block;
|
||||||
|
margin-right: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-embed {
|
||||||
|
|
||||||
|
$video-default-height: 40vh;
|
||||||
|
|
||||||
|
--player-height: #{$video-default-height};
|
||||||
|
// Default player ratio, redefined by the player to automatically adapt player size
|
||||||
|
--player-ratio: #{math.div(16, 9)};
|
||||||
|
|
||||||
|
width: 100%;
|
||||||
|
height: var(--player-height);
|
||||||
|
|
||||||
|
// Can be recalculated by the player depending on video ratio
|
||||||
|
max-width: calc(var(--player-height) * var(--player-ratio));
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,198 @@
|
||||||
|
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'
|
||||||
|
import { CommonModule } from '@angular/common'
|
||||||
|
import { imageToDataURL } from '@root-helpers/images'
|
||||||
|
import { BytesPipe } from '@app/shared/shared-main/angular/bytes.pipe'
|
||||||
|
|
||||||
|
import {
|
||||||
|
Component,
|
||||||
|
forwardRef,
|
||||||
|
Input,
|
||||||
|
OnInit
|
||||||
|
} from '@angular/core'
|
||||||
|
import {
|
||||||
|
ServerService
|
||||||
|
} from '@app/core'
|
||||||
|
import { HTMLServerConfig } from '@peertube/peertube-models'
|
||||||
|
import { ReactiveFileComponent } from '@app/shared/shared-forms/reactive-file.component'
|
||||||
|
import { PeerTubePlayer } from 'src/standalone/embed-player-api/player'
|
||||||
|
import { getAbsoluteAPIUrl } from '@app/helpers'
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'my-thumbnail-manager',
|
||||||
|
styleUrls: [ './thumbnail-manager.component.scss' ],
|
||||||
|
templateUrl: './thumbnail-manager.component.html',
|
||||||
|
standalone: true,
|
||||||
|
imports: [ CommonModule, ReactiveFileComponent ],
|
||||||
|
providers: [
|
||||||
|
{
|
||||||
|
provide: NG_VALUE_ACCESSOR,
|
||||||
|
useExisting: forwardRef(() => ThumbnailManagerComponent),
|
||||||
|
multi: true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
export class ThumbnailManagerComponent implements OnInit, ControlValueAccessor {
|
||||||
|
|
||||||
|
@Input() uuid: string
|
||||||
|
|
||||||
|
previewWidth = '360px'
|
||||||
|
previewHeight = '200px'
|
||||||
|
|
||||||
|
imageSrc: string
|
||||||
|
allowedExtensionsMessage = ''
|
||||||
|
maxSizeText: string
|
||||||
|
|
||||||
|
serverConfig: HTMLServerConfig
|
||||||
|
bytesPipe: BytesPipe
|
||||||
|
imageFile: Blob
|
||||||
|
|
||||||
|
// State Toggle (Upload, Select Frame)
|
||||||
|
selectingFromVideo = false
|
||||||
|
|
||||||
|
player: PeerTubePlayer
|
||||||
|
|
||||||
|
constructor (
|
||||||
|
private serverService: ServerService
|
||||||
|
) {
|
||||||
|
this.bytesPipe = new BytesPipe()
|
||||||
|
this.maxSizeText = $localize`max size`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Section - Upload
|
||||||
|
get videoImageExtensions () {
|
||||||
|
return this.serverConfig.video.image.extensions
|
||||||
|
}
|
||||||
|
|
||||||
|
get maxVideoImageSize () {
|
||||||
|
return this.serverConfig.video.image.size.max
|
||||||
|
}
|
||||||
|
|
||||||
|
get maxVideoImageSizeInBytes () {
|
||||||
|
return this.bytesPipe.transform(this.maxVideoImageSize)
|
||||||
|
}
|
||||||
|
|
||||||
|
getReactiveFileButtonTooltip () {
|
||||||
|
return $localize`(extensions: ${this.videoImageExtensions}, ${this.maxSizeText}\: ${this.maxVideoImageSizeInBytes})`
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnInit () {
|
||||||
|
this.serverConfig = this.serverService.getHTMLConfig()
|
||||||
|
|
||||||
|
this.allowedExtensionsMessage = this.videoImageExtensions.join(', ')
|
||||||
|
}
|
||||||
|
|
||||||
|
onFileChanged (file: Blob) {
|
||||||
|
this.imageFile = file
|
||||||
|
|
||||||
|
this.propagateChange(this.imageFile)
|
||||||
|
this.updatePreview()
|
||||||
|
}
|
||||||
|
|
||||||
|
propagateChange = (_: any) => { /* empty */ }
|
||||||
|
|
||||||
|
writeValue (file: any) {
|
||||||
|
this.imageFile = file
|
||||||
|
this.updatePreview()
|
||||||
|
}
|
||||||
|
|
||||||
|
registerOnChange (fn: (_: any) => void) {
|
||||||
|
this.propagateChange = fn
|
||||||
|
}
|
||||||
|
|
||||||
|
registerOnTouched () {
|
||||||
|
// Unused
|
||||||
|
}
|
||||||
|
|
||||||
|
private updatePreview () {
|
||||||
|
if (this.imageFile) {
|
||||||
|
imageToDataURL(this.imageFile).then(result => this.imageSrc = result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// End Section - Upload
|
||||||
|
|
||||||
|
// Section - Select From Frame
|
||||||
|
selectFromVideo () {
|
||||||
|
|
||||||
|
this.selectingFromVideo = true
|
||||||
|
|
||||||
|
const url = getAbsoluteAPIUrl()
|
||||||
|
|
||||||
|
const iframe = document.createElement('iframe')
|
||||||
|
iframe.src = `${url}/videos/embed/${this.uuid}?api=1&waitPasswordFromEmbedAPI=1&muted=1&title=0&peertubeLink=0`
|
||||||
|
|
||||||
|
iframe.sandbox.add('allow-same-origin', 'allow-scripts', 'allow-popups')
|
||||||
|
|
||||||
|
iframe.height = '100%'
|
||||||
|
iframe.width = '100%'
|
||||||
|
|
||||||
|
const mainElement = document.querySelector('#embedContainer')
|
||||||
|
mainElement.appendChild(iframe)
|
||||||
|
|
||||||
|
mainElement.classList.add('video-embed')
|
||||||
|
|
||||||
|
this.player = new PeerTubePlayer(iframe)
|
||||||
|
}
|
||||||
|
|
||||||
|
resetSelectFromVideo () {
|
||||||
|
|
||||||
|
if (this.player) this.player.destroy()
|
||||||
|
|
||||||
|
const mainElement = document.querySelector('#embedContainer')
|
||||||
|
|
||||||
|
mainElement.classList.remove('video-embed')
|
||||||
|
|
||||||
|
this.selectingFromVideo = false
|
||||||
|
}
|
||||||
|
|
||||||
|
async selectFrame () {
|
||||||
|
|
||||||
|
const dataUrl: string = await this.player.getImageDataUrl()
|
||||||
|
|
||||||
|
// Checking for an empty data URL
|
||||||
|
if (dataUrl.length <= 6) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this.imageSrc = dataUrl
|
||||||
|
|
||||||
|
const blob: Blob = this.dataURItoBlob(dataUrl)
|
||||||
|
|
||||||
|
const file = new File([ blob ], 'PreviewFile.jpg', { type: 'image/jpeg' })
|
||||||
|
|
||||||
|
this.imageFile = file
|
||||||
|
|
||||||
|
this.propagateChange(this.imageFile)
|
||||||
|
|
||||||
|
this.resetSelectFromVideo()
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Credit: https://stackoverflow.com/a/7261048/1030669
|
||||||
|
*/
|
||||||
|
dataURItoBlob (dataURI: string) {
|
||||||
|
// convert base64 to raw binary data held in a string
|
||||||
|
// doesn't handle URLEncoded DataURIs - see SO answer #6850276 for code that does this
|
||||||
|
const byteString = atob(dataURI.split(',')[1])
|
||||||
|
|
||||||
|
// separate out the mime component
|
||||||
|
const mimeString = dataURI.split(',')[0].split(':')[1].split(';')[0]
|
||||||
|
|
||||||
|
// write the bytes of the string to an ArrayBuffer
|
||||||
|
const ab = new ArrayBuffer(byteString.length)
|
||||||
|
|
||||||
|
// create a view into the buffer
|
||||||
|
const ia = new Uint8Array(ab)
|
||||||
|
|
||||||
|
// set the bytes of the buffer to the correct values
|
||||||
|
for (let i = 0; i < byteString.length; i++) {
|
||||||
|
ia[i] = byteString.charCodeAt(i)
|
||||||
|
}
|
||||||
|
|
||||||
|
// write the ArrayBuffer to a blob, and you're done
|
||||||
|
const blob = new Blob([ ab ], { type: mimeString })
|
||||||
|
return blob
|
||||||
|
|
||||||
|
}
|
||||||
|
// End Section - Upload
|
||||||
|
}
|
|
@ -375,10 +375,7 @@
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label i18n for="previewfile">Video thumbnail</label>
|
<label i18n for="previewfile">Video thumbnail</label>
|
||||||
|
|
||||||
<my-preview-upload
|
<my-thumbnail-manager id="previewfile" formControlName="previewfile" [uuid]="videoToUpdate.uuid"></my-thumbnail-manager>
|
||||||
i18n-inputLabel inputLabel="Edit" inputName="previewfile" formControlName="previewfile"
|
|
||||||
previewWidth="360px" previewHeight="200px"
|
|
||||||
></my-preview-upload>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
|
|
|
@ -66,6 +66,7 @@ import { I18nPrimengCalendarService } from './i18n-primeng-calendar.service'
|
||||||
import { VideoCaptionAddModalComponent } from './video-caption-add-modal.component'
|
import { VideoCaptionAddModalComponent } from './video-caption-add-modal.component'
|
||||||
import { VideoCaptionEditModalContentComponent } from './video-caption-edit-modal-content/video-caption-edit-modal-content.component'
|
import { VideoCaptionEditModalContentComponent } from './video-caption-edit-modal-content/video-caption-edit-modal-content.component'
|
||||||
import { VideoEditType } from './video-edit.type'
|
import { VideoEditType } from './video-edit.type'
|
||||||
|
import { ThumbnailManagerComponent } from './thumbnail-manager/thumbnail-manager.component'
|
||||||
|
|
||||||
type VideoLanguages = VideoConstant<string> & { group?: string }
|
type VideoLanguages = VideoConstant<string> & { group?: string }
|
||||||
type PluginField = {
|
type PluginField = {
|
||||||
|
@ -109,7 +110,8 @@ type PluginField = {
|
||||||
PreviewUploadComponent,
|
PreviewUploadComponent,
|
||||||
NgbNavOutlet,
|
NgbNavOutlet,
|
||||||
VideoCaptionAddModalComponent,
|
VideoCaptionAddModalComponent,
|
||||||
DatePipe
|
DatePipe,
|
||||||
|
ThumbnailManagerComponent
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
export class VideoEditComponent implements OnInit, OnDestroy {
|
export class VideoEditComponent implements OnInit, OnDestroy {
|
||||||
|
|
|
@ -204,6 +204,13 @@ export class PeerTubePlayer {
|
||||||
await this.sendMessage('setVideoPassword', password)
|
await this.sendMessage('setVideoPassword', password)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get video frame image as data url
|
||||||
|
*/
|
||||||
|
async getImageDataUrl (): Promise<string> {
|
||||||
|
return this.sendMessage('getImageDataUrl')
|
||||||
|
}
|
||||||
|
|
||||||
private constructChannel () {
|
private constructChannel () {
|
||||||
this.channel = Channel.build({
|
this.channel = Channel.build({
|
||||||
window: this.embedElement.contentWindow,
|
window: this.embedElement.contentWindow,
|
||||||
|
|
|
@ -67,6 +67,8 @@ export class PeerTubeEmbedApi {
|
||||||
channel.bind('playPreviousVideo', (txn, params) => this.embed.playPreviousPlaylistVideo())
|
channel.bind('playPreviousVideo', (txn, params) => this.embed.playPreviousPlaylistVideo())
|
||||||
channel.bind('getCurrentPosition', (txn, params) => this.embed.getCurrentPlaylistPosition())
|
channel.bind('getCurrentPosition', (txn, params) => this.embed.getCurrentPlaylistPosition())
|
||||||
|
|
||||||
|
channel.bind('getImageDataUrl', (txn, params) => this.embed.getImageDataUrl())
|
||||||
|
|
||||||
this.channel = channel
|
this.channel = channel
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -432,6 +432,20 @@ export class PeerTubeEmbed {
|
||||||
|
|
||||||
this.player = this.peertubePlayer.getPlayer()
|
this.player = this.peertubePlayer.getPlayer()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getImageDataUrl (): string {
|
||||||
|
|
||||||
|
const video = this.playerHTML.getInitVideoEl()
|
||||||
|
|
||||||
|
const canvas = document.createElement('canvas')
|
||||||
|
|
||||||
|
canvas.width = video.videoWidth
|
||||||
|
canvas.height = video.videoHeight
|
||||||
|
|
||||||
|
canvas.getContext('2d').drawImage(video, 0, 0, canvas.width, canvas.height)
|
||||||
|
|
||||||
|
return canvas.toDataURL('image/jpeg')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
PeerTubeEmbed.main()
|
PeerTubeEmbed.main()
|
||||||
|
|
Loading…
Reference in a new issue