Add transcription widget

This commit is contained in:
Chocobozzz 2024-08-14 08:52:02 +02:00
parent 3a2e457320
commit be4bf80883
No known key found for this signature in database
GPG Key ID: 583A612D890159BE
24 changed files with 568 additions and 92 deletions

View File

@ -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)

View File

@ -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",

View File

@ -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>

View File

@ -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">

View File

@ -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 () {

View File

@ -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'

View File

@ -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;
}
}

View File

@ -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>

View File

@ -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;
}

View File

@ -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`)
})
}
}
}

View File

@ -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>

View File

@ -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;

View File

@ -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 ]
})

View File

@ -1 +0,0 @@
export * from './video-watch-playlist.component'

View File

@ -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>

View File

@ -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;
}
}

View File

@ -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) {

View File

@ -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

View File

@ -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')
}

View File

@ -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),

View File

@ -46,8 +46,6 @@ my-video-thumbnail,
cursor: pointer;
.position {
@include margin-right(10px);
font-weight: $font-semibold;
color: pvar(--greyForegroundColor);
min-width: 25px;

View File

@ -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

View File

@ -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' ]),

View File

@ -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"