Add transcription widget
This commit is contained in:
parent
3a2e457320
commit
be4bf80883
|
@ -754,11 +754,13 @@
|
|||
|
||||
* [Olivier Massain](https://dribbble.com/omassain)
|
||||
* [Marie-Cécile Godwin Paccard](https://mcgodwin.com/)
|
||||
* [La Coopérative des Internets](https://www.lacooperativedesinternets.fr/)
|
||||
|
||||
|
||||
# Icons
|
||||
|
||||
* [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)
|
||||
* `support` by Chocobozzz (CC-BY)
|
||||
* `language` by Aaron Jin (CC-BY)
|
||||
|
|
|
@ -64,6 +64,7 @@
|
|||
"@peertube/p2p-media-loader-core": "^1.0.20",
|
||||
"@peertube/p2p-media-loader-hlsjs": "^1.0.20",
|
||||
"@peertube/xliffmerge": "^2.0.3",
|
||||
"@plussub/srt-vtt-parser": "^2.0.5",
|
||||
"@popperjs/core": "^2.11.5",
|
||||
"@types/chart.js": "^2.9.37",
|
||||
"@types/core-js": "^2.5.2",
|
||||
|
|
|
@ -12,7 +12,7 @@
|
|||
|
||||
<button
|
||||
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>
|
||||
<ng-container i18n>
|
||||
|
@ -22,7 +22,7 @@
|
|||
</button>
|
||||
</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>
|
||||
|
||||
<div *ngIf="error" class="alert alert-danger">{{ error }}</div>
|
||||
|
|
|
@ -34,7 +34,7 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<ng-container *ngIf="!isUserLoggedIn && !video.isLive">
|
||||
@if (!isUserLoggedIn && !video.isLive) {
|
||||
<button
|
||||
*ngIf="isVideoDownloadable()" class="action-button action-button-download"
|
||||
(click)="showDownloadModal()" (keydown.enter)="showDownloadModal()"
|
||||
|
@ -45,15 +45,15 @@
|
|||
</button>
|
||||
|
||||
<my-video-download #videoDownloadModal [videoPassword]="videoPassword"></my-video-download>
|
||||
</ng-container>
|
||||
}
|
||||
|
||||
<ng-container *ngIf="isUserLoggedIn">
|
||||
<my-video-actions-dropdown
|
||||
placement="bottom auto" buttonDirection="horizontal" buttonStyled="true" [video]="video" [videoCaptions]="videoCaptions"
|
||||
actionAvailabilityHint="true"
|
||||
[displayOptions]="videoActionsOptions" (videoRemoved)="onVideoRemoved()"
|
||||
></my-video-actions-dropdown>
|
||||
</ng-container>
|
||||
<my-video-actions-dropdown
|
||||
[video]="video" [videoCaptions]="videoCaptions" [transcriptionWidgetOpened]="transcriptionWidgetOpened"
|
||||
[displayOptions]="videoActionsOptions" (videoRemoved)="onVideoRemoved()"
|
||||
(showTranscriptionWidget)="showTranscriptionWidget.emit()" (hideTranscriptionWidget)="hideTranscriptionWidget.emit()"
|
||||
placement="bottom auto" buttonDirection="horizontal" buttonStyled="true"
|
||||
actionAvailabilityHint="true"
|
||||
></my-video-actions-dropdown>
|
||||
</div>
|
||||
|
||||
<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 { 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 { 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/download/video-download.component'
|
||||
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({
|
||||
selector: 'my-action-buttons',
|
||||
|
@ -38,7 +38,7 @@ import { VideoPlaylist } from '@app/shared/shared-video-playlist/video-playlist.
|
|||
VideoShareComponent
|
||||
]
|
||||
})
|
||||
export class ActionButtonsComponent implements OnInit, OnChanges {
|
||||
export class ActionButtonsComponent implements OnChanges {
|
||||
@ViewChild('videoShareModal') videoShareModal: VideoShareComponent
|
||||
@ViewChild('supportModal') supportModal: SupportModalComponent
|
||||
@ViewChild('videoDownloadModal') videoDownloadModal: VideoDownloadComponent
|
||||
|
@ -51,9 +51,14 @@ export class ActionButtonsComponent implements OnInit, OnChanges {
|
|||
@Input() isUserLoggedIn: boolean
|
||||
@Input() isUserOwner: boolean
|
||||
|
||||
@Input() transcriptionWidgetOpened: boolean
|
||||
|
||||
@Input() currentTime: number
|
||||
@Input() currentPlaylistPosition: number
|
||||
|
||||
@Output() showTranscriptionWidget = new EventEmitter()
|
||||
@Output() hideTranscriptionWidget = new EventEmitter()
|
||||
|
||||
likesBarTooltipText = ''
|
||||
|
||||
tooltipSupport = ''
|
||||
|
@ -70,7 +75,10 @@ export class ActionButtonsComponent implements OnInit, OnChanges {
|
|||
duplicate: true,
|
||||
mute: true,
|
||||
liveInfo: true,
|
||||
stats: true
|
||||
stats: true,
|
||||
generateTranscription: true,
|
||||
transcriptionWidget: true,
|
||||
transcoding: true
|
||||
}
|
||||
|
||||
userRating: UserVideoRateType
|
||||
|
@ -80,16 +88,20 @@ export class ActionButtonsComponent implements OnInit, OnChanges {
|
|||
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 () {
|
||||
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 () {
|
||||
|
|
|
@ -2,6 +2,5 @@ export * from './action-buttons'
|
|||
export * from './comment'
|
||||
export * from './information'
|
||||
export * from './metadata'
|
||||
export * from './playlist'
|
||||
export * from './recommendations'
|
||||
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
|
||||
*ngIf="playlist && (currentPlaylistPosition || noPlaylistVideos)" class="playlist"
|
||||
*ngIf="playlist && (currentPlaylistPosition || noPlaylistVideos)" class="widget-root playlist"
|
||||
myInfiniteScroller [onItself]="true" (nearOfBottom)="onPlaylistVideosNearOfBottom()"
|
||||
>
|
||||
<div class="playlist-info">
|
||||
<div class="playlist-display-name">
|
||||
>
|
||||
<div class="widget-header playlist-info">
|
||||
<div class="widget-title playlist-display-name">
|
||||
{{ playlist.displayName }}
|
||||
|
||||
<span *ngIf="isUnlistedPlaylist()" class="pt-badge badge-warning" i18n>Unlisted</span>
|
|
@ -4,30 +4,6 @@
|
|||
@use '_miniature' as *;
|
||||
|
||||
.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 {
|
||||
color: pvar(--greyForegroundColor);
|
||||
display: flex;
|
|
@ -17,7 +17,7 @@ import { VideoPlaylistService } from '@app/shared/shared-video-playlist/video-pl
|
|||
@Component({
|
||||
selector: 'my-video-watch-playlist',
|
||||
templateUrl: './video-watch-playlist.component.html',
|
||||
styleUrls: [ './video-watch-playlist.component.scss' ],
|
||||
styleUrls: [ './player-widget.component.scss', './video-watch-playlist.component.scss' ],
|
||||
standalone: true,
|
||||
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>
|
||||
</div>
|
||||
|
||||
<my-video-watch-playlist
|
||||
#videoWatchPlaylist [playlist]="playlist"
|
||||
(noVideoFound)="onPlaylistNoVideoFound()" (videoFound)="onPlaylistVideoFound($event)"
|
||||
></my-video-watch-playlist>
|
||||
<div class="player-widget-component">
|
||||
<my-video-watch-playlist
|
||||
#videoWatchPlaylist [playlist]="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>
|
||||
</div>
|
||||
|
@ -53,8 +63,11 @@
|
|||
</div>
|
||||
|
||||
<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()"
|
||||
(showTranscriptionWidget)="transcriptionWidgetOpened = true" (hideTranscriptionWidget)="transcriptionWidgetOpened = false"
|
||||
></my-action-buttons>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -7,7 +7,7 @@
|
|||
$video-default-height: 66vh;
|
||||
$video-max-height: calc(100vh - #{$header-height} - #{$theater-bottom-space});
|
||||
|
||||
@mixin playlist-below-player {
|
||||
@mixin player-widget-below-player {
|
||||
width: 100% !important;
|
||||
height: auto !important;
|
||||
max-height: 300px !important;
|
||||
|
@ -43,8 +43,8 @@ $video-max-height: calc(100vh - #{$header-height} - #{$theater-bottom-space});
|
|||
--player-height: #{$video-max-height};
|
||||
}
|
||||
|
||||
my-video-watch-playlist ::ng-deep .playlist {
|
||||
@include playlist-below-player;
|
||||
.player-widget-component ::ng-deep .widget-root {
|
||||
@include player-widget-below-player;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -233,8 +233,8 @@ my-video-comments {
|
|||
flex-direction: column;
|
||||
justify-content: center;
|
||||
|
||||
my-video-watch-playlist ::ng-deep .playlist {
|
||||
@include playlist-below-player;
|
||||
.player-widget-component ::ng-deep .widget-root {
|
||||
@include player-widget-below-player;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -45,6 +45,7 @@ import {
|
|||
} from '@peertube/peertube-models'
|
||||
import { logger } from '@root-helpers/logger'
|
||||
import { isP2PEnabled, videoRequiresFileToken, videoRequiresUserAuth } from '@root-helpers/video'
|
||||
import debug from 'debug'
|
||||
import { forkJoin, map, Observable, of, Subscription, switchMap } from 'rxjs'
|
||||
import {
|
||||
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 { VideoViewsCounterComponent } from '../../shared/shared-video/video-views-counter.component'
|
||||
import { PlayerStylesComponent } from './player-styles.component'
|
||||
import { VideoWatchPlaylistComponent } from './shared'
|
||||
import { ActionButtonsComponent } from './shared/action-buttons/action-buttons.component'
|
||||
import { VideoCommentsComponent } from './shared/comment/video-comments.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 { VideoAvatarChannelComponent } from './shared/metadata/video-avatar-channel.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'
|
||||
|
||||
const debugLogger = debug('peertube:watch:VideoWatchComponent')
|
||||
|
||||
type URLOptions = {
|
||||
playerMode: PlayerMode
|
||||
|
||||
|
@ -112,7 +116,9 @@ type URLOptions = {
|
|||
VideoCommentsComponent,
|
||||
RecommendedVideosComponent,
|
||||
PrivacyConcernsComponent,
|
||||
PlayerStylesComponent
|
||||
PlayerStylesComponent,
|
||||
VideoWatchPlaylistComponent,
|
||||
VideoTranscriptionComponent
|
||||
]
|
||||
})
|
||||
export class VideoWatchComponent implements OnInit, OnDestroy {
|
||||
|
@ -136,6 +142,8 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
|
|||
remoteServerDown = false
|
||||
noPlaylistVideoFound = false
|
||||
|
||||
transcriptionWidgetOpened = false
|
||||
|
||||
private nextRecommendedVideoUUID = ''
|
||||
private nextRecommendedVideoTitle = ''
|
||||
|
||||
|
@ -239,13 +247,21 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
|
|||
this.nextRecommendedVideoTitle = video.name
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
handleTimestampClicked (timestamp: number) {
|
||||
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()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
onPlaylistVideoFound (videoId: string) {
|
||||
this.loadVideo({ videoId, forceAutoplay: false })
|
||||
}
|
||||
|
@ -309,7 +325,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
|
|||
|
||||
const start = queryParams['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.currentTime = undefined
|
||||
|
||||
if (this.transcriptionWidgetOpened && this.videoCaptions.length === 0) {
|
||||
this.transcriptionWidgetOpened = false
|
||||
}
|
||||
|
||||
if (this.isVideoBlur(this.video)) {
|
||||
const res = await this.confirmService.confirm(
|
||||
$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()
|
||||
|
||||
player.on('timeupdate', () => {
|
||||
// Don't need to trigger angular change for this variable, that is sent to children components on click
|
||||
this.currentTime = Math.floor(player.currentTime())
|
||||
const newTime = 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) {
|
||||
|
|
|
@ -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 { SelectOptionsItem } from '../../../../types/select-options-item.model'
|
||||
import { NgIf } from '@angular/common'
|
||||
|
@ -20,10 +20,13 @@ import { NgSelectModule } from '@ng-select/ng-select'
|
|||
})
|
||||
export class SelectOptionsComponent implements ControlValueAccessor {
|
||||
@Input() items: SelectOptionsItem[] = []
|
||||
@Input() clearable = false
|
||||
@Input() searchable = false
|
||||
|
||||
@Input({ transform: booleanAttribute }) clearable = false
|
||||
@Input({ transform: booleanAttribute }) searchable = false
|
||||
|
||||
@Input() groupBy: string
|
||||
@Input() labelForId: string
|
||||
|
||||
@Input() searchFn: any
|
||||
|
||||
selectedId: number | string
|
||||
|
|
|
@ -20,7 +20,7 @@ const icons = {
|
|||
'flame': require('../../../assets/images/misc/flame.svg'),
|
||||
'local': require('../../../assets/images/misc/local.svg'),
|
||||
|
||||
// feather icons
|
||||
// feather/lucide icons
|
||||
'copy': require('../../../assets/images/feather/copy.svg'),
|
||||
'flag': require('../../../assets/images/feather/flag.svg'),
|
||||
'playlists': require('../../../assets/images/feather/list.svg'),
|
||||
|
@ -78,6 +78,7 @@ const icons = {
|
|||
'codesandbox': require('../../../assets/images/feather/codesandbox.svg'),
|
||||
'award': require('../../../assets/images/feather/award.svg'),
|
||||
'stats': require('../../../assets/images/feather/stats.svg'),
|
||||
'filter': require('../../../assets/images/feather/filter.svg'),
|
||||
'shield': require('../../../assets/images/misc/shield.svg')
|
||||
}
|
||||
|
||||
|
|
|
@ -39,6 +39,7 @@ export type VideoActionsDisplayType = {
|
|||
studio?: boolean
|
||||
stats?: boolean
|
||||
generateTranscription?: boolean
|
||||
transcriptionWidget?: boolean
|
||||
}
|
||||
|
||||
@Component({
|
||||
|
@ -84,7 +85,9 @@ export class VideoActionsDropdownComponent implements OnChanges {
|
|||
removeFiles: false,
|
||||
transcoding: false,
|
||||
studio: true,
|
||||
stats: true
|
||||
stats: true,
|
||||
generateTranscription: false,
|
||||
transcriptionWidget: false
|
||||
}
|
||||
@Input() placement = 'auto'
|
||||
@Input() moreActions: DropdownAction<{ video: Video }>[][] = []
|
||||
|
@ -96,6 +99,8 @@ export class VideoActionsDropdownComponent implements OnChanges {
|
|||
@Input() buttonSize: DropdownButtonSize = 'normal'
|
||||
@Input() buttonDirection: DropdownDirection = 'vertical'
|
||||
|
||||
@Input() transcriptionWidgetOpened: boolean
|
||||
|
||||
@Output() videoFilesRemoved = new EventEmitter()
|
||||
@Output() videoRemoved = new EventEmitter()
|
||||
@Output() videoUnblocked = new EventEmitter()
|
||||
|
@ -104,6 +109,9 @@ export class VideoActionsDropdownComponent implements OnChanges {
|
|||
@Output() transcodingCreated = new EventEmitter()
|
||||
@Output() modalOpened = new EventEmitter()
|
||||
|
||||
@Output() showTranscriptionWidget = new EventEmitter()
|
||||
@Output() hideTranscriptionWidget = new EventEmitter()
|
||||
|
||||
videoActions: DropdownAction<{ video: Video }>[][] = []
|
||||
|
||||
private loaded = false
|
||||
|
@ -140,14 +148,16 @@ export class VideoActionsDropdownComponent implements OnChanges {
|
|||
}
|
||||
|
||||
loadDropdownInformation () {
|
||||
if (!this.isUserLoggedIn() || this.loaded === true) return
|
||||
if (this.loaded === true) return
|
||||
|
||||
this.loaded = true
|
||||
|
||||
if (this.displayOptions.playlist) this.playlistAdd.load()
|
||||
}
|
||||
|
||||
/* Show modals */
|
||||
// ---------------------------------------------------------------------------
|
||||
// Show modals
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
showDownloadModal () {
|
||||
this.modalOpened.emit()
|
||||
|
@ -179,37 +189,55 @@ export class VideoActionsDropdownComponent implements OnChanges {
|
|||
this.liveStreamInformationModal.show(video)
|
||||
}
|
||||
|
||||
/* Actions checker */
|
||||
// ---------------------------------------------------------------------------
|
||||
// Actions checker
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
isVideoUpdatable () {
|
||||
if (!this.user) return false
|
||||
|
||||
return this.video.isUpdatableBy(this.user)
|
||||
}
|
||||
|
||||
isVideoEditable () {
|
||||
if (!this.user) return false
|
||||
|
||||
return this.video.isEditableBy(this.user, this.serverService.getHTMLConfig().videoStudio.enabled)
|
||||
}
|
||||
|
||||
isVideoStatsAvailable () {
|
||||
if (!this.user) return false
|
||||
|
||||
return this.video.isLocal && this.video.isOwnerOrHasSeeAllVideosRight(this.user)
|
||||
}
|
||||
|
||||
isVideoRemovable () {
|
||||
if (!this.user) return false
|
||||
|
||||
return this.video.isRemovableBy(this.user)
|
||||
}
|
||||
|
||||
isVideoBlockable () {
|
||||
if (!this.user) return false
|
||||
|
||||
return this.video.isBlockableBy(this.user)
|
||||
}
|
||||
|
||||
isVideoUnblockable () {
|
||||
if (!this.user) return false
|
||||
|
||||
return this.video.isUnblockableBy(this.user)
|
||||
}
|
||||
|
||||
isVideoLiveInfoAvailable () {
|
||||
if (!this.user) return false
|
||||
|
||||
return this.video.isLiveInfoAvailableBy(this.user)
|
||||
}
|
||||
|
||||
canGenerateTranscription () {
|
||||
if (!this.user) return false
|
||||
|
||||
return this.video.canGenerateTranscription(this.user, this.serverService.getHTMLConfig().videoTranscription.enabled)
|
||||
}
|
||||
|
||||
|
@ -225,6 +253,8 @@ export class VideoActionsDropdownComponent implements OnChanges {
|
|||
}
|
||||
|
||||
isVideoDownloadableByUser () {
|
||||
if (!this.user) return false
|
||||
|
||||
return (
|
||||
this.video &&
|
||||
this.video.isLive !== true &&
|
||||
|
@ -235,22 +265,32 @@ export class VideoActionsDropdownComponent implements OnChanges {
|
|||
// ---------------------------------------------------------------------------
|
||||
|
||||
canVideoBeDuplicated () {
|
||||
if (!this.user) return false
|
||||
|
||||
return !this.video.isLive && this.video.canBeDuplicatedBy(this.user)
|
||||
}
|
||||
|
||||
isVideoAccountMutable () {
|
||||
if (!this.user) return false
|
||||
|
||||
return this.video.account.id !== this.user.account.id
|
||||
}
|
||||
|
||||
canRemoveVideoFiles () {
|
||||
if (!this.user) return false
|
||||
|
||||
return this.video.canRemoveAllHLSOrWebFiles(this.user)
|
||||
}
|
||||
|
||||
canRunTranscoding () {
|
||||
if (!this.user) return false
|
||||
|
||||
return this.video.canRunTranscoding(this.user)
|
||||
}
|
||||
|
||||
/* Action handlers */
|
||||
// ---------------------------------------------------------------------------
|
||||
// Action handlers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async unblockVideo () {
|
||||
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'
|
||||
}
|
||||
],
|
||||
[ // actions regarding the video
|
||||
[ // public actions regarding the video
|
||||
{
|
||||
label: $localize`Download`,
|
||||
handler: () => this.showDownloadModal(),
|
||||
|
@ -417,6 +457,29 @@ export class VideoActionsDropdownComponent implements OnChanges {
|
|||
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`,
|
||||
handler: ({ video }) => this.showLiveInfoModal(video),
|
||||
|
|
|
@ -46,8 +46,6 @@ my-video-thumbnail,
|
|||
cursor: pointer;
|
||||
|
||||
.position {
|
||||
@include margin-right(10px);
|
||||
|
||||
font-weight: $font-semibold;
|
||||
color: pvar(--greyForegroundColor);
|
||||
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'
|
||||
}
|
||||
|
||||
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 () {
|
||||
const hlsOptionsBuilder = new HLSOptionsBuilder({
|
||||
...pick(this.options, [ 'pluginsManager', 'serverUrl', 'authorizationHeader' ]),
|
||||
|
|
|
@ -2436,6 +2436,11 @@
|
|||
resolved "https://registry.yarnpkg.com/@pkgr/core/-/core-0.1.1.tgz#1ec17e2edbec25c8306d424ecfbf13c7de1aaa31"
|
||||
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":
|
||||
version "1.0.0-next.0"
|
||||
resolved "https://registry.yarnpkg.com/@polka/parse/-/parse-1.0.0-next.0.tgz#3551d792acdf4ad0b053072e57498cbe32e45a94"
|
||||
|
|
Loading…
Reference in New Issue