diff --git a/CREDITS.md b/CREDITS.md
index 733ec5afc..97e9e56bb 100644
--- a/CREDITS.md
+++ b/CREDITS.md
@@ -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)
diff --git a/client/package.json b/client/package.json
index 1d34cf39c..eb1a53f7d 100644
--- a/client/package.json
+++ b/client/package.json
@@ -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",
diff --git a/client/src/app/+search/search.component.html b/client/src/app/+search/search.component.html
index a6a28b35e..7e154e953 100644
--- a/client/src/app/+search/search.component.html
+++ b/client/src/app/+search/search.component.html
@@ -12,7 +12,7 @@
-
diff --git a/client/src/app/+videos/+video-watch/shared/action-buttons/action-buttons.component.ts b/client/src/app/+videos/+video-watch/shared/action-buttons/action-buttons.component.ts
index 681f814f6..89a29dad3 100644
--- a/client/src/app/+videos/+video-watch/shared/action-buttons/action-buttons.component.ts
+++ b/client/src/app/+videos/+video-watch/shared/action-buttons/action-buttons.component.ts
@@ -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 () {
diff --git a/client/src/app/+videos/+video-watch/shared/index.ts b/client/src/app/+videos/+video-watch/shared/index.ts
index 069f862e2..8dd804951 100644
--- a/client/src/app/+videos/+video-watch/shared/index.ts
+++ b/client/src/app/+videos/+video-watch/shared/index.ts
@@ -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'
diff --git a/client/src/app/+videos/+video-watch/shared/player-widgets/player-widget.component.scss b/client/src/app/+videos/+video-watch/shared/player-widgets/player-widget.component.scss
new file mode 100644
index 000000000..13163d4ee
--- /dev/null
+++ b/client/src/app/+videos/+video-watch/shared/player-widgets/player-widget.component.scss
@@ -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;
+ }
+}
+
diff --git a/client/src/app/+videos/+video-watch/shared/player-widgets/video-transcription.component.html b/client/src/app/+videos/+video-watch/shared/player-widgets/video-transcription.component.html
new file mode 100644
index 000000000..81f6bf0bd
--- /dev/null
+++ b/client/src/app/+videos/+video-watch/shared/player-widgets/video-transcription.component.html
@@ -0,0 +1,62 @@
+
diff --git a/client/src/app/+videos/+video-watch/shared/player-widgets/video-transcription.component.scss b/client/src/app/+videos/+video-watch/shared/player-widgets/video-transcription.component.scss
new file mode 100644
index 000000000..c3122dc01
--- /dev/null
+++ b/client/src/app/+videos/+video-watch/shared/player-widgets/video-transcription.component.scss
@@ -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;
+}
diff --git a/client/src/app/+videos/+video-watch/shared/player-widgets/video-transcription.component.ts b/client/src/app/+videos/+video-watch/shared/player-widgets/video-transcription.component.ts
new file mode 100644
index 000000000..89f211005
--- /dev/null
+++ b/client/src/app/+videos/+video-watch/shared/player-widgets/video-transcription.component.ts
@@ -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
()
+ @Output() closeTranscription = new EventEmitter()
+
+ 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()
+
+ 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('.segment-' + this.activeSegment.start)
+ if (!element) return // Can happen with a search
+
+ const container = document.querySelector('.widget-root')
+
+ if (isInViewport(element, container)) return
+
+ container.scrollTop = element.offsetTop
+
+ debugLogger(`Set transcription segment ${this.activeSegment.start} in viewport`)
+ })
+ }
+ }
+}
diff --git a/client/src/app/+videos/+video-watch/shared/playlist/video-watch-playlist.component.html b/client/src/app/+videos/+video-watch/shared/player-widgets/video-watch-playlist.component.html
similarity index 93%
rename from client/src/app/+videos/+video-watch/shared/playlist/video-watch-playlist.component.html
rename to client/src/app/+videos/+video-watch/shared/player-widgets/video-watch-playlist.component.html
index c0f45c977..3cbfa33ad 100644
--- a/client/src/app/+videos/+video-watch/shared/playlist/video-watch-playlist.component.html
+++ b/client/src/app/+videos/+video-watch/shared/player-widgets/video-watch-playlist.component.html
@@ -1,9 +1,9 @@
-
-
+ >
+
@@ -53,8 +63,11 @@
diff --git a/client/src/app/+videos/+video-watch/video-watch.component.scss b/client/src/app/+videos/+video-watch/video-watch.component.scss
index d38679a02..61d5ef156 100644
--- a/client/src/app/+videos/+video-watch/video-watch.component.scss
+++ b/client/src/app/+videos/+video-watch/video-watch.component.scss
@@ -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;
}
}
diff --git a/client/src/app/+videos/+video-watch/video-watch.component.ts b/client/src/app/+videos/+video-watch/video-watch.component.ts
index 2bc23a2be..d6dc5914a 100644
--- a/client/src/app/+videos/+video-watch/video-watch.component.ts
+++ b/client/src/app/+videos/+video-watch/video-watch.component.ts
@@ -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) {
diff --git a/client/src/app/shared/shared-forms/select/select-options.component.ts b/client/src/app/shared/shared-forms/select/select-options.component.ts
index 62df5c80e..d699ac283 100644
--- a/client/src/app/shared/shared-forms/select/select-options.component.ts
+++ b/client/src/app/shared/shared-forms/select/select-options.component.ts
@@ -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
diff --git a/client/src/app/shared/shared-icons/global-icon.component.ts b/client/src/app/shared/shared-icons/global-icon.component.ts
index 7f6b49952..ee7e43890 100644
--- a/client/src/app/shared/shared-icons/global-icon.component.ts
+++ b/client/src/app/shared/shared-icons/global-icon.component.ts
@@ -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')
}
diff --git a/client/src/app/shared/shared-video-miniature/video-actions-dropdown.component.ts b/client/src/app/shared/shared-video-miniature/video-actions-dropdown.component.ts
index 12ffb9713..bdae2183a 100644
--- a/client/src/app/shared/shared-video-miniature/video-actions-dropdown.component.ts
+++ b/client/src/app/shared/shared-video-miniature/video-actions-dropdown.component.ts
@@ -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),
diff --git a/client/src/app/shared/shared-video-playlist/video-playlist-element-miniature.component.scss b/client/src/app/shared/shared-video-playlist/video-playlist-element-miniature.component.scss
index d4acae86b..5e7e2b436 100644
--- a/client/src/app/shared/shared-video-playlist/video-playlist-element-miniature.component.scss
+++ b/client/src/app/shared/shared-video-playlist/video-playlist-element-miniature.component.scss
@@ -46,8 +46,6 @@ my-video-thumbnail,
cursor: pointer;
.position {
- @include margin-right(10px);
-
font-weight: $font-semibold;
color: pvar(--greyForegroundColor);
min-width: 25px;
diff --git a/client/src/assets/images/feather/filter.svg b/client/src/assets/images/feather/filter.svg
index 38a47e043..17a9574e9 100644
--- a/client/src/assets/images/feather/filter.svg
+++ b/client/src/assets/images/feather/filter.svg
@@ -1 +1 @@
-
\ No newline at end of file
+
diff --git a/client/src/assets/player/peertube-player.ts b/client/src/assets/player/peertube-player.ts
index 1805a07c4..d4d4d4a7b 100644
--- a/client/src/assets/player/peertube-player.ts
+++ b/client/src/assets/player/peertube-player.ts
@@ -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' ]),
diff --git a/client/yarn.lock b/client/yarn.lock
index 257e3546f..107a86ed6 100644
--- a/client/yarn.lock
+++ b/client/yarn.lock
@@ -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"