Add transcription widget
This commit is contained in:
parent
3a2e457320
commit
be4bf80883
|
@ -754,11 +754,13 @@
|
||||||
|
|
||||||
* [Olivier Massain](https://dribbble.com/omassain)
|
* [Olivier Massain](https://dribbble.com/omassain)
|
||||||
* [Marie-Cécile Godwin Paccard](https://mcgodwin.com/)
|
* [Marie-Cécile Godwin Paccard](https://mcgodwin.com/)
|
||||||
|
* [La Coopérative des Internets](https://www.lacooperativedesinternets.fr/)
|
||||||
|
|
||||||
|
|
||||||
# Icons
|
# Icons
|
||||||
|
|
||||||
* [Feather Icons](https://feathericons.com) (MIT)
|
* [Feather Icons](https://feathericons.com) (MIT)
|
||||||
|
* [Lucide Icons](https://lucide.dev/) (ISC)
|
||||||
* `playlist add`, `history`, `subscriptions`, `miscellaneous-services.svg`, `tip` by Material UI (Apache 2.0)
|
* `playlist add`, `history`, `subscriptions`, `miscellaneous-services.svg`, `tip` by Material UI (Apache 2.0)
|
||||||
* `support` by Chocobozzz (CC-BY)
|
* `support` by Chocobozzz (CC-BY)
|
||||||
* `language` by Aaron Jin (CC-BY)
|
* `language` by Aaron Jin (CC-BY)
|
||||||
|
|
|
@ -64,6 +64,7 @@
|
||||||
"@peertube/p2p-media-loader-core": "^1.0.20",
|
"@peertube/p2p-media-loader-core": "^1.0.20",
|
||||||
"@peertube/p2p-media-loader-hlsjs": "^1.0.20",
|
"@peertube/p2p-media-loader-hlsjs": "^1.0.20",
|
||||||
"@peertube/xliffmerge": "^2.0.3",
|
"@peertube/xliffmerge": "^2.0.3",
|
||||||
|
"@plussub/srt-vtt-parser": "^2.0.5",
|
||||||
"@popperjs/core": "^2.11.5",
|
"@popperjs/core": "^2.11.5",
|
||||||
"@types/chart.js": "^2.9.37",
|
"@types/chart.js": "^2.9.37",
|
||||||
"@types/core-js": "^2.5.2",
|
"@types/core-js": "^2.5.2",
|
||||||
|
|
|
@ -12,7 +12,7 @@
|
||||||
|
|
||||||
<button
|
<button
|
||||||
class="results-filter-button button-unstyle ms-auto" (click)="isSearchFilterCollapsed = !isSearchFilterCollapsed"
|
class="results-filter-button button-unstyle ms-auto" (click)="isSearchFilterCollapsed = !isSearchFilterCollapsed"
|
||||||
[attr.aria-expanded]="!isSearchFilterCollapsed" aria-controls="collapseBasic"
|
[attr.aria-expanded]="!isSearchFilterCollapsed" aria-controls="search-results-filter"
|
||||||
>
|
>
|
||||||
<span class="icon icon-filter"></span>
|
<span class="icon icon-filter"></span>
|
||||||
<ng-container i18n>
|
<ng-container i18n>
|
||||||
|
@ -22,7 +22,7 @@
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="results-filter" [ngbCollapse]="isSearchFilterCollapsed" [animation]="true">
|
<div id="search-results-filter" class="results-filter" [ngbCollapse]="isSearchFilterCollapsed" [animation]="true">
|
||||||
<my-search-filters [advancedSearch]="advancedSearch" (filtered)="onFiltered()"></my-search-filters>
|
<my-search-filters [advancedSearch]="advancedSearch" (filtered)="onFiltered()"></my-search-filters>
|
||||||
|
|
||||||
<div *ngIf="error" class="alert alert-danger">{{ error }}</div>
|
<div *ngIf="error" class="alert alert-danger">{{ error }}</div>
|
||||||
|
|
|
@ -34,7 +34,7 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ng-container *ngIf="!isUserLoggedIn && !video.isLive">
|
@if (!isUserLoggedIn && !video.isLive) {
|
||||||
<button
|
<button
|
||||||
*ngIf="isVideoDownloadable()" class="action-button action-button-download"
|
*ngIf="isVideoDownloadable()" class="action-button action-button-download"
|
||||||
(click)="showDownloadModal()" (keydown.enter)="showDownloadModal()"
|
(click)="showDownloadModal()" (keydown.enter)="showDownloadModal()"
|
||||||
|
@ -45,15 +45,15 @@
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<my-video-download #videoDownloadModal [videoPassword]="videoPassword"></my-video-download>
|
<my-video-download #videoDownloadModal [videoPassword]="videoPassword"></my-video-download>
|
||||||
</ng-container>
|
}
|
||||||
|
|
||||||
<ng-container *ngIf="isUserLoggedIn">
|
<my-video-actions-dropdown
|
||||||
<my-video-actions-dropdown
|
[video]="video" [videoCaptions]="videoCaptions" [transcriptionWidgetOpened]="transcriptionWidgetOpened"
|
||||||
placement="bottom auto" buttonDirection="horizontal" buttonStyled="true" [video]="video" [videoCaptions]="videoCaptions"
|
[displayOptions]="videoActionsOptions" (videoRemoved)="onVideoRemoved()"
|
||||||
actionAvailabilityHint="true"
|
(showTranscriptionWidget)="showTranscriptionWidget.emit()" (hideTranscriptionWidget)="hideTranscriptionWidget.emit()"
|
||||||
[displayOptions]="videoActionsOptions" (videoRemoved)="onVideoRemoved()"
|
placement="bottom auto" buttonDirection="horizontal" buttonStyled="true"
|
||||||
></my-video-actions-dropdown>
|
actionAvailabilityHint="true"
|
||||||
</ng-container>
|
></my-video-actions-dropdown>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="likes-dislikes-bar-outer-container">
|
<div class="likes-dislikes-bar-outer-container">
|
||||||
|
|
|
@ -1,20 +1,20 @@
|
||||||
import { Component, Input, OnChanges, OnInit, ViewChild } from '@angular/core'
|
import { NgClass, NgIf, NgStyle } from '@angular/common'
|
||||||
|
import { Component, EventEmitter, Input, OnChanges, Output, ViewChild } from '@angular/core'
|
||||||
import { RedirectService, ScreenService } from '@app/core'
|
import { RedirectService, ScreenService } from '@app/core'
|
||||||
import { UserVideoRateType, VideoCaption, VideoPrivacy } from '@peertube/peertube-models'
|
|
||||||
import {
|
|
||||||
VideoActionsDisplayType,
|
|
||||||
VideoActionsDropdownComponent
|
|
||||||
} from '../../../../shared/shared-video-miniature/video-actions-dropdown.component'
|
|
||||||
import { VideoAddToPlaylistComponent } from '../../../../shared/shared-video-playlist/video-add-to-playlist.component'
|
|
||||||
import { GlobalIconComponent } from '../../../../shared/shared-icons/global-icon.component'
|
|
||||||
import { NgbTooltip, NgbDropdown, NgbDropdownToggle, NgbDropdownMenu } from '@ng-bootstrap/ng-bootstrap'
|
|
||||||
import { NgIf, NgClass, NgStyle } from '@angular/common'
|
|
||||||
import { VideoRateComponent } from './video-rate.component'
|
|
||||||
import { VideoDetails } from '@app/shared/shared-main/video/video-details.model'
|
import { VideoDetails } from '@app/shared/shared-main/video/video-details.model'
|
||||||
import { VideoShareComponent } from '@app/shared/shared-share-modal/video-share.component'
|
import { VideoShareComponent } from '@app/shared/shared-share-modal/video-share.component'
|
||||||
import { SupportModalComponent } from '@app/shared/shared-support-modal/support-modal.component'
|
import { SupportModalComponent } from '@app/shared/shared-support-modal/support-modal.component'
|
||||||
import { VideoDownloadComponent } from '@app/shared/shared-video-miniature/download/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'
|
import { VideoPlaylist } from '@app/shared/shared-video-playlist/video-playlist.model'
|
||||||
|
import { NgbDropdown, NgbDropdownMenu, NgbDropdownToggle, NgbTooltip } from '@ng-bootstrap/ng-bootstrap'
|
||||||
|
import { UserVideoRateType, VideoCaption, VideoPrivacy } from '@peertube/peertube-models'
|
||||||
|
import { GlobalIconComponent } from '../../../../shared/shared-icons/global-icon.component'
|
||||||
|
import {
|
||||||
|
VideoActionsDisplayType,
|
||||||
|
VideoActionsDropdownComponent
|
||||||
|
} from '../../../../shared/shared-video-miniature/video-actions-dropdown.component'
|
||||||
|
import { VideoAddToPlaylistComponent } from '../../../../shared/shared-video-playlist/video-add-to-playlist.component'
|
||||||
|
import { VideoRateComponent } from './video-rate.component'
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'my-action-buttons',
|
selector: 'my-action-buttons',
|
||||||
|
@ -38,7 +38,7 @@ import { VideoPlaylist } from '@app/shared/shared-video-playlist/video-playlist.
|
||||||
VideoShareComponent
|
VideoShareComponent
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
export class ActionButtonsComponent implements OnInit, OnChanges {
|
export class ActionButtonsComponent implements OnChanges {
|
||||||
@ViewChild('videoShareModal') videoShareModal: VideoShareComponent
|
@ViewChild('videoShareModal') videoShareModal: VideoShareComponent
|
||||||
@ViewChild('supportModal') supportModal: SupportModalComponent
|
@ViewChild('supportModal') supportModal: SupportModalComponent
|
||||||
@ViewChild('videoDownloadModal') videoDownloadModal: VideoDownloadComponent
|
@ViewChild('videoDownloadModal') videoDownloadModal: VideoDownloadComponent
|
||||||
|
@ -51,9 +51,14 @@ export class ActionButtonsComponent implements OnInit, OnChanges {
|
||||||
@Input() isUserLoggedIn: boolean
|
@Input() isUserLoggedIn: boolean
|
||||||
@Input() isUserOwner: boolean
|
@Input() isUserOwner: boolean
|
||||||
|
|
||||||
|
@Input() transcriptionWidgetOpened: boolean
|
||||||
|
|
||||||
@Input() currentTime: number
|
@Input() currentTime: number
|
||||||
@Input() currentPlaylistPosition: number
|
@Input() currentPlaylistPosition: number
|
||||||
|
|
||||||
|
@Output() showTranscriptionWidget = new EventEmitter()
|
||||||
|
@Output() hideTranscriptionWidget = new EventEmitter()
|
||||||
|
|
||||||
likesBarTooltipText = ''
|
likesBarTooltipText = ''
|
||||||
|
|
||||||
tooltipSupport = ''
|
tooltipSupport = ''
|
||||||
|
@ -70,7 +75,10 @@ export class ActionButtonsComponent implements OnInit, OnChanges {
|
||||||
duplicate: true,
|
duplicate: true,
|
||||||
mute: true,
|
mute: true,
|
||||||
liveInfo: true,
|
liveInfo: true,
|
||||||
stats: true
|
stats: true,
|
||||||
|
generateTranscription: true,
|
||||||
|
transcriptionWidget: true,
|
||||||
|
transcoding: true
|
||||||
}
|
}
|
||||||
|
|
||||||
userRating: UserVideoRateType
|
userRating: UserVideoRateType
|
||||||
|
@ -80,16 +88,20 @@ export class ActionButtonsComponent implements OnInit, OnChanges {
|
||||||
private redirectService: RedirectService
|
private redirectService: RedirectService
|
||||||
) { }
|
) { }
|
||||||
|
|
||||||
ngOnInit () {
|
|
||||||
// Hide the tooltips for unlogged users in mobile view, this adds confusion with the popover
|
|
||||||
if (this.isUserLoggedIn || !this.screenService.isInMobileView()) {
|
|
||||||
this.tooltipSupport = $localize`Open the modal to support the video uploader`
|
|
||||||
this.tooltipSaveToPlaylist = $localize`Save to playlist`
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
ngOnChanges () {
|
ngOnChanges () {
|
||||||
this.setVideoLikesBarTooltipText()
|
this.setVideoLikesBarTooltipText()
|
||||||
|
|
||||||
|
if (this.isUserLoggedIn) {
|
||||||
|
this.videoActionsOptions.download = true
|
||||||
|
|
||||||
|
// Hide the tooltips for unlogged users in mobile view, this adds confusion with the popover
|
||||||
|
if (!this.screenService.isInMobileView()) {
|
||||||
|
this.tooltipSupport = $localize`Open the modal to support the video uploader`
|
||||||
|
this.tooltipSaveToPlaylist = $localize`Save to playlist`
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this.videoActionsOptions.download = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
showDownloadModal () {
|
showDownloadModal () {
|
||||||
|
|
|
@ -2,6 +2,5 @@ export * from './action-buttons'
|
||||||
export * from './comment'
|
export * from './comment'
|
||||||
export * from './information'
|
export * from './information'
|
||||||
export * from './metadata'
|
export * from './metadata'
|
||||||
export * from './playlist'
|
|
||||||
export * from './recommendations'
|
export * from './recommendations'
|
||||||
export * from './timestamp-route-transformer.directive'
|
export * from './timestamp-route-transformer.directive'
|
||||||
|
|
|
@ -0,0 +1,37 @@
|
||||||
|
@use '_variables' as *;
|
||||||
|
@use '_mixins' as *;
|
||||||
|
|
||||||
|
.widget-root {
|
||||||
|
position: relative;
|
||||||
|
min-width: 200px;
|
||||||
|
width: 25vw;
|
||||||
|
max-width: 470px;
|
||||||
|
height: 66vh;
|
||||||
|
background-color: pvar(--mainBackgroundColor);
|
||||||
|
overflow-y: auto;
|
||||||
|
border-bottom: 1px solid $separator-border-color;
|
||||||
|
|
||||||
|
.widget-header {
|
||||||
|
background-color: pvar(--submenuBackgroundColor);
|
||||||
|
padding: 1rem 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.widget-content-padded {
|
||||||
|
padding: 0 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.widget-title {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: $font-semibold;
|
||||||
|
|
||||||
|
.pt-badge {
|
||||||
|
@include margin-left(5px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.widget-content {
|
||||||
|
padding-top: 0.5rem;
|
||||||
|
padding-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -0,0 +1,62 @@
|
||||||
|
<div class="widget-root">
|
||||||
|
|
||||||
|
<div class="widget-header d-flex justify-content-between">
|
||||||
|
<div class="widget-title" i18n>Transcription</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
class="border-0 p-0 me-3 settings-button" title="Settings" i18n-title
|
||||||
|
(click)="isSettingsPanelCollapsed = !isSettingsPanelCollapsed" [attr.aria-expanded]="!isSettingsPanelCollapsed" aria-controls="video-transcription-settings-panel"
|
||||||
|
>
|
||||||
|
<my-global-icon iconName="filter"></my-global-icon>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button class="border-0 p-0" title="Close transcription widget" i18n-title (click)="closeTranscription.emit()">
|
||||||
|
<my-global-icon iconName="cross"></my-global-icon>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="widget-content">
|
||||||
|
<div class="widget-content-padded">
|
||||||
|
|
||||||
|
<div
|
||||||
|
id="video-transcription-settings-panel" class="settings-panel"
|
||||||
|
#settingsPanel #settingsPanelCollapse="ngbCollapse" [ngbCollapse]="isSettingsPanelCollapsed"
|
||||||
|
(shown)="settingsPanelShown = true" (hidden)="settingsPanelShown = false"
|
||||||
|
>
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-body">
|
||||||
|
<label i18n for="transcription-language">Language</label>
|
||||||
|
|
||||||
|
<my-select-options
|
||||||
|
labelForId="transcription-language" [items]="languagesOptions"
|
||||||
|
[(ngModel)]="currentLanguage" (ngModelChange)="updateCurrentCaption()" clearable="false"
|
||||||
|
></my-select-options>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<input
|
||||||
|
type="text" class="mb-3" name="search-transcript" i18n-placeholder placeholder="Search transcript"
|
||||||
|
(input)="onSearchChange($event)"
|
||||||
|
>
|
||||||
|
|
||||||
|
@if (search && segments.length === 0) {
|
||||||
|
<div i18n>No results for your search</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
role="button" tabindex="0" class="segment widget-content-padded pt-1 pb-1"
|
||||||
|
i18n-title title="Jump to this segment"
|
||||||
|
*ngFor="let segment of segments"
|
||||||
|
(keyup.enter)="onSegmentClick($event, segment)" (click)="onSegmentClick($event, segment)"
|
||||||
|
[ngClass]="getSegmentClasses(segment)"
|
||||||
|
>
|
||||||
|
<strong class="segment-start me-2">{{ segment.startFormatted }}</strong>
|
||||||
|
<span class="segment-text fs-7">{{ segment.text }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
|
@ -0,0 +1,26 @@
|
||||||
|
@use '_variables' as *;
|
||||||
|
@use '_mixins' as *;
|
||||||
|
|
||||||
|
.segment {
|
||||||
|
&.active,
|
||||||
|
&:hover {
|
||||||
|
background: pvar(--mainBackgroundHoverColor);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type=text] {
|
||||||
|
@include peertube-input-text(100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-button my-global-icon {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-panel {
|
||||||
|
position: absolute;
|
||||||
|
width: 100%;
|
||||||
|
padding: 0 1.5rem;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
}
|
|
@ -0,0 +1,241 @@
|
||||||
|
import { NgClass, NgFor, NgIf } from '@angular/common'
|
||||||
|
import {
|
||||||
|
Component,
|
||||||
|
ElementRef,
|
||||||
|
EventEmitter,
|
||||||
|
HostListener,
|
||||||
|
Input,
|
||||||
|
OnChanges,
|
||||||
|
OnInit,
|
||||||
|
Output,
|
||||||
|
SimpleChanges,
|
||||||
|
ViewChild
|
||||||
|
} from '@angular/core'
|
||||||
|
import { FormsModule } from '@angular/forms'
|
||||||
|
import { Notifier } from '@app/core'
|
||||||
|
import { durationToString, isInViewport } from '@app/helpers'
|
||||||
|
import { SelectOptionsComponent } from '@app/shared/shared-forms/select/select-options.component'
|
||||||
|
import { VideoCaptionService } from '@app/shared/shared-main/video-caption/video-caption.service'
|
||||||
|
import { NgbCollapse, NgbTooltip } from '@ng-bootstrap/ng-bootstrap'
|
||||||
|
import { Video, VideoCaption } from '@peertube/peertube-models'
|
||||||
|
import { parse } from '@plussub/srt-vtt-parser'
|
||||||
|
import debug from 'debug'
|
||||||
|
import { debounceTime, distinctUntilChanged, Subject } from 'rxjs'
|
||||||
|
import { SelectOptionsItem } from 'src/types'
|
||||||
|
import { GlobalIconComponent } from '../../../../shared/shared-icons/global-icon.component'
|
||||||
|
|
||||||
|
const debugLogger = debug('peertube:watch:VideoTranscriptionComponent')
|
||||||
|
|
||||||
|
type Segment = {
|
||||||
|
start: number
|
||||||
|
startFormatted: string
|
||||||
|
|
||||||
|
end: number
|
||||||
|
|
||||||
|
text: string
|
||||||
|
}
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'my-video-transcription',
|
||||||
|
templateUrl: './video-transcription.component.html',
|
||||||
|
styleUrls: [ './player-widget.component.scss', './video-transcription.component.scss' ],
|
||||||
|
standalone: true,
|
||||||
|
imports: [
|
||||||
|
NgIf,
|
||||||
|
NgClass,
|
||||||
|
NgbTooltip,
|
||||||
|
GlobalIconComponent,
|
||||||
|
NgFor,
|
||||||
|
NgbCollapse,
|
||||||
|
FormsModule,
|
||||||
|
SelectOptionsComponent
|
||||||
|
]
|
||||||
|
})
|
||||||
|
export class VideoTranscriptionComponent implements OnInit, OnChanges {
|
||||||
|
@ViewChild('settingsPanel') settingsPanel: ElementRef
|
||||||
|
|
||||||
|
@Input() video: Video
|
||||||
|
@Input() captions: VideoCaption[]
|
||||||
|
@Input() currentTime: number
|
||||||
|
|
||||||
|
// Output the duration clicked
|
||||||
|
@Output() segmentClicked = new EventEmitter<number>()
|
||||||
|
@Output() closeTranscription = new EventEmitter<void>()
|
||||||
|
|
||||||
|
currentCaption: VideoCaption
|
||||||
|
segments: Segment[] = []
|
||||||
|
activeSegment: Segment
|
||||||
|
|
||||||
|
search = ''
|
||||||
|
|
||||||
|
currentLanguage: string
|
||||||
|
languagesOptions: SelectOptionsItem[] = []
|
||||||
|
|
||||||
|
isSettingsPanelCollapsed: boolean
|
||||||
|
// true when collapsed has been shown (after the transition)
|
||||||
|
settingsPanelShown: boolean
|
||||||
|
|
||||||
|
private segmentsStore: Segment[] = []
|
||||||
|
private searchSubject = new Subject<string>()
|
||||||
|
|
||||||
|
constructor (
|
||||||
|
private notifier: Notifier,
|
||||||
|
private captionService: VideoCaptionService
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
@HostListener('document:click', [ '$event' ])
|
||||||
|
clickout (event: Event) {
|
||||||
|
if (!this.settingsPanelShown) return
|
||||||
|
|
||||||
|
if (!this.settingsPanel?.nativeElement.contains(event.target)) {
|
||||||
|
this.isSettingsPanelCollapsed = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnInit () {
|
||||||
|
this.searchSubject.asObservable()
|
||||||
|
.pipe(
|
||||||
|
debounceTime(100),
|
||||||
|
distinctUntilChanged()
|
||||||
|
)
|
||||||
|
.subscribe(search => this.filterSegments(search))
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnChanges (changes: SimpleChanges) {
|
||||||
|
if (changes['video'] || changes['captions']) {
|
||||||
|
this.load()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (changes['currentTime']) {
|
||||||
|
this.findActiveSegment()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getSegmentClasses (segment: Segment) {
|
||||||
|
return { active: this.activeSegment === segment, ['segment-' + segment.start]: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
updateCurrentCaption () {
|
||||||
|
this.currentCaption = this.captions.find(c => c.language.id === this.currentLanguage)
|
||||||
|
|
||||||
|
this.parseCurrentCaption()
|
||||||
|
}
|
||||||
|
|
||||||
|
private load () {
|
||||||
|
this.search = ''
|
||||||
|
|
||||||
|
this.segmentsStore = []
|
||||||
|
this.segments = []
|
||||||
|
|
||||||
|
this.activeSegment = undefined
|
||||||
|
this.currentCaption = undefined
|
||||||
|
|
||||||
|
this.isSettingsPanelCollapsed = true
|
||||||
|
this.settingsPanelShown = false
|
||||||
|
|
||||||
|
this.languagesOptions = []
|
||||||
|
|
||||||
|
if (!this.video || !this.captions || this.captions.length === 0) return
|
||||||
|
|
||||||
|
this.currentLanguage = this.captions.some(c => c.language.id === this.video.language.id)
|
||||||
|
? this.video.language.id
|
||||||
|
: this.captions[0].language.id
|
||||||
|
|
||||||
|
this.languagesOptions = this.captions.map(c => ({
|
||||||
|
id: c.language.id,
|
||||||
|
label: c.automaticallyGenerated
|
||||||
|
? $localize`${c.language.label} (automatically generated)`
|
||||||
|
: c.language.label
|
||||||
|
}))
|
||||||
|
|
||||||
|
this.updateCurrentCaption()
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseCurrentCaption () {
|
||||||
|
this.captionService.getCaptionContent({ captionPath: this.currentCaption.captionPath })
|
||||||
|
.subscribe({
|
||||||
|
next: content => {
|
||||||
|
try {
|
||||||
|
const entries = parse(content).entries
|
||||||
|
|
||||||
|
this.segmentsStore = entries.map(({ from, to, text }) => {
|
||||||
|
const start = Math.ceil(from / 1000)
|
||||||
|
const end = Math.ceil(to / 1000)
|
||||||
|
|
||||||
|
return {
|
||||||
|
start,
|
||||||
|
startFormatted: durationToString(start),
|
||||||
|
end,
|
||||||
|
text
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
this.segments = this.segmentsStore
|
||||||
|
} catch (err) {
|
||||||
|
this.notifier.error($localize`Cannot load transcript: ${err.message}`)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
error: err => this.notifier.error(err.message)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
onSearchChange (event: Event) {
|
||||||
|
const target = event.target as HTMLInputElement
|
||||||
|
|
||||||
|
this.searchSubject.next(target.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
onSegmentClick (event: Event, segment: Segment) {
|
||||||
|
event.preventDefault()
|
||||||
|
|
||||||
|
this.segmentClicked.emit(segment.start)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
private filterSegments (search: string) {
|
||||||
|
this.search = search
|
||||||
|
|
||||||
|
const searchLowercase = search.toLocaleLowerCase()
|
||||||
|
|
||||||
|
this.segments = this.segmentsStore.filter(s => {
|
||||||
|
return s.text.toLocaleLowerCase().includes(searchLowercase)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private findActiveSegment () {
|
||||||
|
const lastActiveSegment = this.activeSegment
|
||||||
|
this.activeSegment = undefined
|
||||||
|
|
||||||
|
if (isNaN(this.currentTime)) return
|
||||||
|
|
||||||
|
for (let i = this.segmentsStore.length - 1; i >= 0; i--) {
|
||||||
|
const current = this.segmentsStore[i]
|
||||||
|
|
||||||
|
if (current.start < this.currentTime) {
|
||||||
|
this.activeSegment = current
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lastActiveSegment !== this.activeSegment) {
|
||||||
|
setTimeout(() => {
|
||||||
|
const element = document.querySelector<HTMLElement>('.segment-' + this.activeSegment.start)
|
||||||
|
if (!element) return // Can happen with a search
|
||||||
|
|
||||||
|
const container = document.querySelector<HTMLElement>('.widget-root')
|
||||||
|
|
||||||
|
if (isInViewport(element, container)) return
|
||||||
|
|
||||||
|
container.scrollTop = element.offsetTop
|
||||||
|
|
||||||
|
debugLogger(`Set transcription segment ${this.activeSegment.start} in viewport`)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,9 +1,9 @@
|
||||||
<div
|
<div
|
||||||
*ngIf="playlist && (currentPlaylistPosition || noPlaylistVideos)" class="playlist"
|
*ngIf="playlist && (currentPlaylistPosition || noPlaylistVideos)" class="widget-root playlist"
|
||||||
myInfiniteScroller [onItself]="true" (nearOfBottom)="onPlaylistVideosNearOfBottom()"
|
myInfiniteScroller [onItself]="true" (nearOfBottom)="onPlaylistVideosNearOfBottom()"
|
||||||
>
|
>
|
||||||
<div class="playlist-info">
|
<div class="widget-header playlist-info">
|
||||||
<div class="playlist-display-name">
|
<div class="widget-title playlist-display-name">
|
||||||
{{ playlist.displayName }}
|
{{ playlist.displayName }}
|
||||||
|
|
||||||
<span *ngIf="isUnlistedPlaylist()" class="pt-badge badge-warning" i18n>Unlisted</span>
|
<span *ngIf="isUnlistedPlaylist()" class="pt-badge badge-warning" i18n>Unlisted</span>
|
|
@ -4,30 +4,6 @@
|
||||||
@use '_miniature' as *;
|
@use '_miniature' as *;
|
||||||
|
|
||||||
.playlist {
|
.playlist {
|
||||||
position: relative;
|
|
||||||
min-width: 200px;
|
|
||||||
width: 25vw;
|
|
||||||
max-width: 470px;
|
|
||||||
height: 66vh;
|
|
||||||
background-color: pvar(--mainBackgroundColor);
|
|
||||||
overflow-y: auto;
|
|
||||||
border-bottom: 1px solid $separator-border-color;
|
|
||||||
|
|
||||||
.playlist-info {
|
|
||||||
padding: 5px 30px;
|
|
||||||
background-color: pvar(--greyBackgroundColor);
|
|
||||||
}
|
|
||||||
|
|
||||||
.playlist-display-name {
|
|
||||||
font-size: 18px;
|
|
||||||
font-weight: $font-semibold;
|
|
||||||
margin-bottom: 5px;
|
|
||||||
|
|
||||||
.pt-badge {
|
|
||||||
@include margin-left(5px);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.playlist-by-index {
|
.playlist-by-index {
|
||||||
color: pvar(--greyForegroundColor);
|
color: pvar(--greyForegroundColor);
|
||||||
display: flex;
|
display: flex;
|
|
@ -17,7 +17,7 @@ import { VideoPlaylistService } from '@app/shared/shared-video-playlist/video-pl
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'my-video-watch-playlist',
|
selector: 'my-video-watch-playlist',
|
||||||
templateUrl: './video-watch-playlist.component.html',
|
templateUrl: './video-watch-playlist.component.html',
|
||||||
styleUrls: [ './video-watch-playlist.component.scss' ],
|
styleUrls: [ './player-widget.component.scss', './video-watch-playlist.component.scss' ],
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [ NgIf, InfiniteScrollerDirective, NgClass, NgbTooltip, GlobalIconComponent, NgFor, VideoPlaylistElementMiniatureComponent ]
|
imports: [ NgIf, InfiniteScrollerDirective, NgClass, NgbTooltip, GlobalIconComponent, NgFor, VideoPlaylistElementMiniatureComponent ]
|
||||||
})
|
})
|
|
@ -1 +0,0 @@
|
||||||
export * from './video-watch-playlist.component'
|
|
|
@ -13,10 +13,20 @@
|
||||||
<video #playerElement class="video-js vjs-peertube-skin" playsinline="true"></video>
|
<video #playerElement class="video-js vjs-peertube-skin" playsinline="true"></video>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<my-video-watch-playlist
|
<div class="player-widget-component">
|
||||||
#videoWatchPlaylist [playlist]="playlist"
|
<my-video-watch-playlist
|
||||||
(noVideoFound)="onPlaylistNoVideoFound()" (videoFound)="onPlaylistVideoFound($event)"
|
#videoWatchPlaylist [playlist]="playlist"
|
||||||
></my-video-watch-playlist>
|
[hidden]="transcriptionWidgetOpened"
|
||||||
|
(noVideoFound)="onPlaylistNoVideoFound()" (videoFound)="onPlaylistVideoFound($event)"
|
||||||
|
></my-video-watch-playlist>
|
||||||
|
|
||||||
|
@if (transcriptionWidgetOpened) {
|
||||||
|
<my-video-transcription
|
||||||
|
[video]="video" [captions]="videoCaptions" [currentTime]="getCurrentTime()"
|
||||||
|
(segmentClicked)="handleTimestampClicked($event)" (closeTranscription)="transcriptionWidgetOpened = false"
|
||||||
|
></my-video-transcription>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
<my-plugin-placeholder pluginId="player-next"></my-plugin-placeholder>
|
<my-plugin-placeholder pluginId="player-next"></my-plugin-placeholder>
|
||||||
</div>
|
</div>
|
||||||
|
@ -53,8 +63,11 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<my-action-buttons
|
<my-action-buttons
|
||||||
[video]="video" [videoPassword]="videoPassword" [isUserLoggedIn]="isUserLoggedIn()" [isUserOwner]="isUserOwner()" [videoCaptions]="videoCaptions"
|
[video]="video" [videoPassword]="videoPassword" [videoCaptions]="videoCaptions"
|
||||||
|
[isUserLoggedIn]="isUserLoggedIn()" [isUserOwner]="isUserOwner()"
|
||||||
|
[transcriptionWidgetOpened]="transcriptionWidgetOpened"
|
||||||
[playlist]="playlist" [currentTime]="getCurrentTime()" [currentPlaylistPosition]="getCurrentPlaylistPosition()"
|
[playlist]="playlist" [currentTime]="getCurrentTime()" [currentPlaylistPosition]="getCurrentPlaylistPosition()"
|
||||||
|
(showTranscriptionWidget)="transcriptionWidgetOpened = true" (hideTranscriptionWidget)="transcriptionWidgetOpened = false"
|
||||||
></my-action-buttons>
|
></my-action-buttons>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -7,7 +7,7 @@
|
||||||
$video-default-height: 66vh;
|
$video-default-height: 66vh;
|
||||||
$video-max-height: calc(100vh - #{$header-height} - #{$theater-bottom-space});
|
$video-max-height: calc(100vh - #{$header-height} - #{$theater-bottom-space});
|
||||||
|
|
||||||
@mixin playlist-below-player {
|
@mixin player-widget-below-player {
|
||||||
width: 100% !important;
|
width: 100% !important;
|
||||||
height: auto !important;
|
height: auto !important;
|
||||||
max-height: 300px !important;
|
max-height: 300px !important;
|
||||||
|
@ -43,8 +43,8 @@ $video-max-height: calc(100vh - #{$header-height} - #{$theater-bottom-space});
|
||||||
--player-height: #{$video-max-height};
|
--player-height: #{$video-max-height};
|
||||||
}
|
}
|
||||||
|
|
||||||
my-video-watch-playlist ::ng-deep .playlist {
|
.player-widget-component ::ng-deep .widget-root {
|
||||||
@include playlist-below-player;
|
@include player-widget-below-player;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -233,8 +233,8 @@ my-video-comments {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
|
||||||
my-video-watch-playlist ::ng-deep .playlist {
|
.player-widget-component ::ng-deep .widget-root {
|
||||||
@include playlist-below-player;
|
@include player-widget-below-player;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -45,6 +45,7 @@ import {
|
||||||
} from '@peertube/peertube-models'
|
} from '@peertube/peertube-models'
|
||||||
import { logger } from '@root-helpers/logger'
|
import { logger } from '@root-helpers/logger'
|
||||||
import { isP2PEnabled, videoRequiresFileToken, videoRequiresUserAuth } from '@root-helpers/video'
|
import { isP2PEnabled, videoRequiresFileToken, videoRequiresUserAuth } from '@root-helpers/video'
|
||||||
|
import debug from 'debug'
|
||||||
import { forkJoin, map, Observable, of, Subscription, switchMap } from 'rxjs'
|
import { forkJoin, map, Observable, of, Subscription, switchMap } from 'rxjs'
|
||||||
import {
|
import {
|
||||||
HLSOptions,
|
HLSOptions,
|
||||||
|
@ -60,7 +61,6 @@ import { DateToggleComponent } from '../../shared/shared-main/date/date-toggle.c
|
||||||
import { PluginPlaceholderComponent } from '../../shared/shared-main/plugins/plugin-placeholder.component'
|
import { PluginPlaceholderComponent } from '../../shared/shared-main/plugins/plugin-placeholder.component'
|
||||||
import { VideoViewsCounterComponent } from '../../shared/shared-video/video-views-counter.component'
|
import { VideoViewsCounterComponent } from '../../shared/shared-video/video-views-counter.component'
|
||||||
import { PlayerStylesComponent } from './player-styles.component'
|
import { PlayerStylesComponent } from './player-styles.component'
|
||||||
import { VideoWatchPlaylistComponent } from './shared'
|
|
||||||
import { ActionButtonsComponent } from './shared/action-buttons/action-buttons.component'
|
import { ActionButtonsComponent } from './shared/action-buttons/action-buttons.component'
|
||||||
import { VideoCommentsComponent } from './shared/comment/video-comments.component'
|
import { VideoCommentsComponent } from './shared/comment/video-comments.component'
|
||||||
import { PrivacyConcernsComponent } from './shared/information/privacy-concerns.component'
|
import { PrivacyConcernsComponent } from './shared/information/privacy-concerns.component'
|
||||||
|
@ -68,8 +68,12 @@ import { VideoAlertComponent } from './shared/information/video-alert.component'
|
||||||
import { VideoAttributesComponent } from './shared/metadata/video-attributes.component'
|
import { VideoAttributesComponent } from './shared/metadata/video-attributes.component'
|
||||||
import { VideoAvatarChannelComponent } from './shared/metadata/video-avatar-channel.component'
|
import { VideoAvatarChannelComponent } from './shared/metadata/video-avatar-channel.component'
|
||||||
import { VideoDescriptionComponent } from './shared/metadata/video-description.component'
|
import { VideoDescriptionComponent } from './shared/metadata/video-description.component'
|
||||||
|
import { VideoTranscriptionComponent } from './shared/player-widgets/video-transcription.component'
|
||||||
|
import { VideoWatchPlaylistComponent } from './shared/player-widgets/video-watch-playlist.component'
|
||||||
import { RecommendedVideosComponent } from './shared/recommendations/recommended-videos.component'
|
import { RecommendedVideosComponent } from './shared/recommendations/recommended-videos.component'
|
||||||
|
|
||||||
|
const debugLogger = debug('peertube:watch:VideoWatchComponent')
|
||||||
|
|
||||||
type URLOptions = {
|
type URLOptions = {
|
||||||
playerMode: PlayerMode
|
playerMode: PlayerMode
|
||||||
|
|
||||||
|
@ -112,7 +116,9 @@ type URLOptions = {
|
||||||
VideoCommentsComponent,
|
VideoCommentsComponent,
|
||||||
RecommendedVideosComponent,
|
RecommendedVideosComponent,
|
||||||
PrivacyConcernsComponent,
|
PrivacyConcernsComponent,
|
||||||
PlayerStylesComponent
|
PlayerStylesComponent,
|
||||||
|
VideoWatchPlaylistComponent,
|
||||||
|
VideoTranscriptionComponent
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
export class VideoWatchComponent implements OnInit, OnDestroy {
|
export class VideoWatchComponent implements OnInit, OnDestroy {
|
||||||
|
@ -136,6 +142,8 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
|
||||||
remoteServerDown = false
|
remoteServerDown = false
|
||||||
noPlaylistVideoFound = false
|
noPlaylistVideoFound = false
|
||||||
|
|
||||||
|
transcriptionWidgetOpened = false
|
||||||
|
|
||||||
private nextRecommendedVideoUUID = ''
|
private nextRecommendedVideoUUID = ''
|
||||||
private nextRecommendedVideoTitle = ''
|
private nextRecommendedVideoTitle = ''
|
||||||
|
|
||||||
|
@ -239,13 +247,21 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
|
||||||
this.nextRecommendedVideoTitle = video.name
|
this.nextRecommendedVideoTitle = video.name
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
handleTimestampClicked (timestamp: number) {
|
handleTimestampClicked (timestamp: number) {
|
||||||
if (!this.peertubePlayer || this.video.isLive) return
|
if (!this.peertubePlayer || this.video.isLive) return
|
||||||
|
|
||||||
this.peertubePlayer.getPlayer().currentTime(timestamp)
|
const player = this.peertubePlayer.getPlayer()
|
||||||
|
if (!player) return
|
||||||
|
|
||||||
|
this.peertubePlayer.setCurrentTime(timestamp)
|
||||||
|
|
||||||
scrollToTop()
|
scrollToTop()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
onPlaylistVideoFound (videoId: string) {
|
onPlaylistVideoFound (videoId: string) {
|
||||||
this.loadVideo({ videoId, forceAutoplay: false })
|
this.loadVideo({ videoId, forceAutoplay: false })
|
||||||
}
|
}
|
||||||
|
@ -309,7 +325,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
|
||||||
|
|
||||||
const start = queryParams['start']
|
const start = queryParams['start']
|
||||||
if (this.peertubePlayer?.getPlayer() && start) {
|
if (this.peertubePlayer?.getPlayer() && start) {
|
||||||
this.peertubePlayer.getPlayer().currentTime(parseInt(start, 10))
|
this.peertubePlayer.setCurrentTime(parseInt(start, 10))
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
@ -492,6 +508,10 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
|
||||||
this.remoteServerDown = false
|
this.remoteServerDown = false
|
||||||
this.currentTime = undefined
|
this.currentTime = undefined
|
||||||
|
|
||||||
|
if (this.transcriptionWidgetOpened && this.videoCaptions.length === 0) {
|
||||||
|
this.transcriptionWidgetOpened = false
|
||||||
|
}
|
||||||
|
|
||||||
if (this.isVideoBlur(this.video)) {
|
if (this.isVideoBlur(this.video)) {
|
||||||
const res = await this.confirmService.confirm(
|
const res = await this.confirmService.confirm(
|
||||||
$localize`This video contains mature or explicit content. Are you sure you want to watch it?`,
|
$localize`This video contains mature or explicit content. Are you sure you want to watch it?`,
|
||||||
|
@ -556,8 +576,14 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
|
||||||
const player = this.peertubePlayer.getPlayer()
|
const player = this.peertubePlayer.getPlayer()
|
||||||
|
|
||||||
player.on('timeupdate', () => {
|
player.on('timeupdate', () => {
|
||||||
// Don't need to trigger angular change for this variable, that is sent to children components on click
|
const newTime = Math.floor(player.currentTime())
|
||||||
this.currentTime = Math.floor(player.currentTime())
|
|
||||||
|
// Update only if we have at least 1 second difference
|
||||||
|
if (!this.currentTime || Math.abs(newTime - this.currentTime) >= 1) {
|
||||||
|
debugLogger('Updating current time to ' + newTime)
|
||||||
|
|
||||||
|
this.zone.run(() => this.currentTime = newTime)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
if (this.video.isLive) {
|
if (this.video.isLive) {
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import { Component, forwardRef, HostListener, Input } from '@angular/core'
|
import { booleanAttribute, Component, forwardRef, HostListener, Input } from '@angular/core'
|
||||||
import { ControlValueAccessor, NG_VALUE_ACCESSOR, FormsModule } from '@angular/forms'
|
import { ControlValueAccessor, NG_VALUE_ACCESSOR, FormsModule } from '@angular/forms'
|
||||||
import { SelectOptionsItem } from '../../../../types/select-options-item.model'
|
import { SelectOptionsItem } from '../../../../types/select-options-item.model'
|
||||||
import { NgIf } from '@angular/common'
|
import { NgIf } from '@angular/common'
|
||||||
|
@ -20,10 +20,13 @@ import { NgSelectModule } from '@ng-select/ng-select'
|
||||||
})
|
})
|
||||||
export class SelectOptionsComponent implements ControlValueAccessor {
|
export class SelectOptionsComponent implements ControlValueAccessor {
|
||||||
@Input() items: SelectOptionsItem[] = []
|
@Input() items: SelectOptionsItem[] = []
|
||||||
@Input() clearable = false
|
|
||||||
@Input() searchable = false
|
@Input({ transform: booleanAttribute }) clearable = false
|
||||||
|
@Input({ transform: booleanAttribute }) searchable = false
|
||||||
|
|
||||||
@Input() groupBy: string
|
@Input() groupBy: string
|
||||||
@Input() labelForId: string
|
@Input() labelForId: string
|
||||||
|
|
||||||
@Input() searchFn: any
|
@Input() searchFn: any
|
||||||
|
|
||||||
selectedId: number | string
|
selectedId: number | string
|
||||||
|
|
|
@ -20,7 +20,7 @@ const icons = {
|
||||||
'flame': require('../../../assets/images/misc/flame.svg'),
|
'flame': require('../../../assets/images/misc/flame.svg'),
|
||||||
'local': require('../../../assets/images/misc/local.svg'),
|
'local': require('../../../assets/images/misc/local.svg'),
|
||||||
|
|
||||||
// feather icons
|
// feather/lucide icons
|
||||||
'copy': require('../../../assets/images/feather/copy.svg'),
|
'copy': require('../../../assets/images/feather/copy.svg'),
|
||||||
'flag': require('../../../assets/images/feather/flag.svg'),
|
'flag': require('../../../assets/images/feather/flag.svg'),
|
||||||
'playlists': require('../../../assets/images/feather/list.svg'),
|
'playlists': require('../../../assets/images/feather/list.svg'),
|
||||||
|
@ -78,6 +78,7 @@ const icons = {
|
||||||
'codesandbox': require('../../../assets/images/feather/codesandbox.svg'),
|
'codesandbox': require('../../../assets/images/feather/codesandbox.svg'),
|
||||||
'award': require('../../../assets/images/feather/award.svg'),
|
'award': require('../../../assets/images/feather/award.svg'),
|
||||||
'stats': require('../../../assets/images/feather/stats.svg'),
|
'stats': require('../../../assets/images/feather/stats.svg'),
|
||||||
|
'filter': require('../../../assets/images/feather/filter.svg'),
|
||||||
'shield': require('../../../assets/images/misc/shield.svg')
|
'shield': require('../../../assets/images/misc/shield.svg')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -39,6 +39,7 @@ export type VideoActionsDisplayType = {
|
||||||
studio?: boolean
|
studio?: boolean
|
||||||
stats?: boolean
|
stats?: boolean
|
||||||
generateTranscription?: boolean
|
generateTranscription?: boolean
|
||||||
|
transcriptionWidget?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
|
@ -84,7 +85,9 @@ export class VideoActionsDropdownComponent implements OnChanges {
|
||||||
removeFiles: false,
|
removeFiles: false,
|
||||||
transcoding: false,
|
transcoding: false,
|
||||||
studio: true,
|
studio: true,
|
||||||
stats: true
|
stats: true,
|
||||||
|
generateTranscription: false,
|
||||||
|
transcriptionWidget: false
|
||||||
}
|
}
|
||||||
@Input() placement = 'auto'
|
@Input() placement = 'auto'
|
||||||
@Input() moreActions: DropdownAction<{ video: Video }>[][] = []
|
@Input() moreActions: DropdownAction<{ video: Video }>[][] = []
|
||||||
|
@ -96,6 +99,8 @@ export class VideoActionsDropdownComponent implements OnChanges {
|
||||||
@Input() buttonSize: DropdownButtonSize = 'normal'
|
@Input() buttonSize: DropdownButtonSize = 'normal'
|
||||||
@Input() buttonDirection: DropdownDirection = 'vertical'
|
@Input() buttonDirection: DropdownDirection = 'vertical'
|
||||||
|
|
||||||
|
@Input() transcriptionWidgetOpened: boolean
|
||||||
|
|
||||||
@Output() videoFilesRemoved = new EventEmitter()
|
@Output() videoFilesRemoved = new EventEmitter()
|
||||||
@Output() videoRemoved = new EventEmitter()
|
@Output() videoRemoved = new EventEmitter()
|
||||||
@Output() videoUnblocked = new EventEmitter()
|
@Output() videoUnblocked = new EventEmitter()
|
||||||
|
@ -104,6 +109,9 @@ export class VideoActionsDropdownComponent implements OnChanges {
|
||||||
@Output() transcodingCreated = new EventEmitter()
|
@Output() transcodingCreated = new EventEmitter()
|
||||||
@Output() modalOpened = new EventEmitter()
|
@Output() modalOpened = new EventEmitter()
|
||||||
|
|
||||||
|
@Output() showTranscriptionWidget = new EventEmitter()
|
||||||
|
@Output() hideTranscriptionWidget = new EventEmitter()
|
||||||
|
|
||||||
videoActions: DropdownAction<{ video: Video }>[][] = []
|
videoActions: DropdownAction<{ video: Video }>[][] = []
|
||||||
|
|
||||||
private loaded = false
|
private loaded = false
|
||||||
|
@ -140,14 +148,16 @@ export class VideoActionsDropdownComponent implements OnChanges {
|
||||||
}
|
}
|
||||||
|
|
||||||
loadDropdownInformation () {
|
loadDropdownInformation () {
|
||||||
if (!this.isUserLoggedIn() || this.loaded === true) return
|
if (this.loaded === true) return
|
||||||
|
|
||||||
this.loaded = true
|
this.loaded = true
|
||||||
|
|
||||||
if (this.displayOptions.playlist) this.playlistAdd.load()
|
if (this.displayOptions.playlist) this.playlistAdd.load()
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Show modals */
|
// ---------------------------------------------------------------------------
|
||||||
|
// Show modals
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
showDownloadModal () {
|
showDownloadModal () {
|
||||||
this.modalOpened.emit()
|
this.modalOpened.emit()
|
||||||
|
@ -179,37 +189,55 @@ export class VideoActionsDropdownComponent implements OnChanges {
|
||||||
this.liveStreamInformationModal.show(video)
|
this.liveStreamInformationModal.show(video)
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Actions checker */
|
// ---------------------------------------------------------------------------
|
||||||
|
// Actions checker
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
isVideoUpdatable () {
|
isVideoUpdatable () {
|
||||||
|
if (!this.user) return false
|
||||||
|
|
||||||
return this.video.isUpdatableBy(this.user)
|
return this.video.isUpdatableBy(this.user)
|
||||||
}
|
}
|
||||||
|
|
||||||
isVideoEditable () {
|
isVideoEditable () {
|
||||||
|
if (!this.user) return false
|
||||||
|
|
||||||
return this.video.isEditableBy(this.user, this.serverService.getHTMLConfig().videoStudio.enabled)
|
return this.video.isEditableBy(this.user, this.serverService.getHTMLConfig().videoStudio.enabled)
|
||||||
}
|
}
|
||||||
|
|
||||||
isVideoStatsAvailable () {
|
isVideoStatsAvailable () {
|
||||||
|
if (!this.user) return false
|
||||||
|
|
||||||
return this.video.isLocal && this.video.isOwnerOrHasSeeAllVideosRight(this.user)
|
return this.video.isLocal && this.video.isOwnerOrHasSeeAllVideosRight(this.user)
|
||||||
}
|
}
|
||||||
|
|
||||||
isVideoRemovable () {
|
isVideoRemovable () {
|
||||||
|
if (!this.user) return false
|
||||||
|
|
||||||
return this.video.isRemovableBy(this.user)
|
return this.video.isRemovableBy(this.user)
|
||||||
}
|
}
|
||||||
|
|
||||||
isVideoBlockable () {
|
isVideoBlockable () {
|
||||||
|
if (!this.user) return false
|
||||||
|
|
||||||
return this.video.isBlockableBy(this.user)
|
return this.video.isBlockableBy(this.user)
|
||||||
}
|
}
|
||||||
|
|
||||||
isVideoUnblockable () {
|
isVideoUnblockable () {
|
||||||
|
if (!this.user) return false
|
||||||
|
|
||||||
return this.video.isUnblockableBy(this.user)
|
return this.video.isUnblockableBy(this.user)
|
||||||
}
|
}
|
||||||
|
|
||||||
isVideoLiveInfoAvailable () {
|
isVideoLiveInfoAvailable () {
|
||||||
|
if (!this.user) return false
|
||||||
|
|
||||||
return this.video.isLiveInfoAvailableBy(this.user)
|
return this.video.isLiveInfoAvailableBy(this.user)
|
||||||
}
|
}
|
||||||
|
|
||||||
canGenerateTranscription () {
|
canGenerateTranscription () {
|
||||||
|
if (!this.user) return false
|
||||||
|
|
||||||
return this.video.canGenerateTranscription(this.user, this.serverService.getHTMLConfig().videoTranscription.enabled)
|
return this.video.canGenerateTranscription(this.user, this.serverService.getHTMLConfig().videoTranscription.enabled)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -225,6 +253,8 @@ export class VideoActionsDropdownComponent implements OnChanges {
|
||||||
}
|
}
|
||||||
|
|
||||||
isVideoDownloadableByUser () {
|
isVideoDownloadableByUser () {
|
||||||
|
if (!this.user) return false
|
||||||
|
|
||||||
return (
|
return (
|
||||||
this.video &&
|
this.video &&
|
||||||
this.video.isLive !== true &&
|
this.video.isLive !== true &&
|
||||||
|
@ -235,22 +265,32 @@ export class VideoActionsDropdownComponent implements OnChanges {
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
canVideoBeDuplicated () {
|
canVideoBeDuplicated () {
|
||||||
|
if (!this.user) return false
|
||||||
|
|
||||||
return !this.video.isLive && this.video.canBeDuplicatedBy(this.user)
|
return !this.video.isLive && this.video.canBeDuplicatedBy(this.user)
|
||||||
}
|
}
|
||||||
|
|
||||||
isVideoAccountMutable () {
|
isVideoAccountMutable () {
|
||||||
|
if (!this.user) return false
|
||||||
|
|
||||||
return this.video.account.id !== this.user.account.id
|
return this.video.account.id !== this.user.account.id
|
||||||
}
|
}
|
||||||
|
|
||||||
canRemoveVideoFiles () {
|
canRemoveVideoFiles () {
|
||||||
|
if (!this.user) return false
|
||||||
|
|
||||||
return this.video.canRemoveAllHLSOrWebFiles(this.user)
|
return this.video.canRemoveAllHLSOrWebFiles(this.user)
|
||||||
}
|
}
|
||||||
|
|
||||||
canRunTranscoding () {
|
canRunTranscoding () {
|
||||||
|
if (!this.user) return false
|
||||||
|
|
||||||
return this.video.canRunTranscoding(this.user)
|
return this.video.canRunTranscoding(this.user)
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Action handlers */
|
// ---------------------------------------------------------------------------
|
||||||
|
// Action handlers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
async unblockVideo () {
|
async unblockVideo () {
|
||||||
const confirmMessage = $localize`Do you really want to unblock ${this.video.name}? It will be available again in the videos list.`
|
const confirmMessage = $localize`Do you really want to unblock ${this.video.name}? It will be available again in the videos list.`
|
||||||
|
@ -400,7 +440,7 @@ export class VideoActionsDropdownComponent implements OnChanges {
|
||||||
iconName: 'playlist-add'
|
iconName: 'playlist-add'
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
[ // actions regarding the video
|
[ // public actions regarding the video
|
||||||
{
|
{
|
||||||
label: $localize`Download`,
|
label: $localize`Download`,
|
||||||
handler: () => this.showDownloadModal(),
|
handler: () => this.showDownloadModal(),
|
||||||
|
@ -417,6 +457,29 @@ export class VideoActionsDropdownComponent implements OnChanges {
|
||||||
return $localize`This option is visible only to you`
|
return $localize`This option is visible only to you`
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: $localize`Show transcription`,
|
||||||
|
handler: () => this.showTranscriptionWidget.emit(),
|
||||||
|
isDisplayed: () => {
|
||||||
|
if (!this.displayOptions.transcriptionWidget) return false
|
||||||
|
if (this.transcriptionWidgetOpened) return false
|
||||||
|
|
||||||
|
return Array.isArray(this.videoCaptions) && this.videoCaptions.length !== 0
|
||||||
|
},
|
||||||
|
iconName: 'video-lang'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: $localize`Hide transcription`,
|
||||||
|
handler: () => this.hideTranscriptionWidget.emit(),
|
||||||
|
isDisplayed: () => {
|
||||||
|
if (!this.displayOptions.transcriptionWidget) return false
|
||||||
|
|
||||||
|
return this.transcriptionWidgetOpened === true
|
||||||
|
},
|
||||||
|
iconName: 'video-lang'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
[ // private actions regarding the video
|
||||||
{
|
{
|
||||||
label: $localize`Display live information`,
|
label: $localize`Display live information`,
|
||||||
handler: ({ video }) => this.showLiveInfoModal(video),
|
handler: ({ video }) => this.showLiveInfoModal(video),
|
||||||
|
|
|
@ -46,8 +46,6 @@ my-video-thumbnail,
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
|
||||||
.position {
|
.position {
|
||||||
@include margin-right(10px);
|
|
||||||
|
|
||||||
font-weight: $font-semibold;
|
font-weight: $font-semibold;
|
||||||
color: pvar(--greyForegroundColor);
|
color: pvar(--greyForegroundColor);
|
||||||
min-width: 25px;
|
min-width: 25px;
|
||||||
|
|
|
@ -1 +1 @@
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-filter"><polygon points="22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3"></polygon></svg>
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-sliders-horizontal"><line x1="21" x2="14" y1="4" y2="4"/><line x1="10" x2="3" y1="4" y2="4"/><line x1="21" x2="12" y1="12" y2="12"/><line x1="8" x2="3" y1="12" y2="12"/><line x1="21" x2="16" y1="20" y2="20"/><line x1="12" x2="3" y1="20" y2="20"/><line x1="14" x2="14" y1="2" y2="6"/><line x1="8" x2="8" y1="10" y2="14"/><line x1="16" x2="16" y1="18" y2="22"/></svg>
|
||||||
|
|
Before Width: | Height: | Size: 290 B After Width: | Height: | Size: 568 B |
|
@ -151,6 +151,18 @@ export class PeerTubePlayer {
|
||||||
(this.player.el() as HTMLElement).style.pointerEvents = 'none'
|
(this.player.el() as HTMLElement).style.pointerEvents = 'none'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setCurrentTime (currentTime: number) {
|
||||||
|
if (this.player.paused()) {
|
||||||
|
this.currentLoadOptions.startTime = currentTime
|
||||||
|
|
||||||
|
this.player.play()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this.player.currentTime(currentTime)
|
||||||
|
this.player.userActive(true)
|
||||||
|
}
|
||||||
|
|
||||||
private async loadP2PMediaLoader () {
|
private async loadP2PMediaLoader () {
|
||||||
const hlsOptionsBuilder = new HLSOptionsBuilder({
|
const hlsOptionsBuilder = new HLSOptionsBuilder({
|
||||||
...pick(this.options, [ 'pluginsManager', 'serverUrl', 'authorizationHeader' ]),
|
...pick(this.options, [ 'pluginsManager', 'serverUrl', 'authorizationHeader' ]),
|
||||||
|
|
|
@ -2436,6 +2436,11 @@
|
||||||
resolved "https://registry.yarnpkg.com/@pkgr/core/-/core-0.1.1.tgz#1ec17e2edbec25c8306d424ecfbf13c7de1aaa31"
|
resolved "https://registry.yarnpkg.com/@pkgr/core/-/core-0.1.1.tgz#1ec17e2edbec25c8306d424ecfbf13c7de1aaa31"
|
||||||
integrity sha512-cq8o4cWH0ibXh9VGi5P20Tu9XF/0fFXl9EUinr9QfTM7a7p0oTA4iJRCQWppXR1Pg8dSM0UCItCkPwsk9qWWYA==
|
integrity sha512-cq8o4cWH0ibXh9VGi5P20Tu9XF/0fFXl9EUinr9QfTM7a7p0oTA4iJRCQWppXR1Pg8dSM0UCItCkPwsk9qWWYA==
|
||||||
|
|
||||||
|
"@plussub/srt-vtt-parser@^2.0.5":
|
||||||
|
version "2.0.5"
|
||||||
|
resolved "https://registry.yarnpkg.com/@plussub/srt-vtt-parser/-/srt-vtt-parser-2.0.5.tgz#4836d1fe9c912b4f48b8c0ce6a9c0c9755b1c66e"
|
||||||
|
integrity sha512-cOedEgu7gyea9k+ixkPCQGf8ABBctFWWsBYnVCzzmuoHz45awc9vKtveHzn7VugR36fzFqgkXaLEn2HdZnzFdQ==
|
||||||
|
|
||||||
"@polka/parse@^1.0.0-next.0":
|
"@polka/parse@^1.0.0-next.0":
|
||||||
version "1.0.0-next.0"
|
version "1.0.0-next.0"
|
||||||
resolved "https://registry.yarnpkg.com/@polka/parse/-/parse-1.0.0-next.0.tgz#3551d792acdf4ad0b053072e57498cbe32e45a94"
|
resolved "https://registry.yarnpkg.com/@polka/parse/-/parse-1.0.0-next.0.tgz#3551d792acdf4ad0b053072e57498cbe32e45a94"
|
||||||
|
|
Loading…
Reference in New Issue