aboutsummaryrefslogtreecommitdiffhomepage
path: root/client/src
diff options
context:
space:
mode:
Diffstat (limited to 'client/src')
-rw-r--r--client/src/app/+admin/overview/users/user-list/user-list.component.html2
-rw-r--r--client/src/app/+admin/overview/users/user-list/user-list.component.ts44
-rw-r--r--client/src/app/+my-library/+my-video-channels/my-video-channels.component.html4
-rw-r--r--client/src/app/core/core.module.ts4
-rw-r--r--client/src/app/core/rest/rest-table.ts8
-rw-r--r--client/src/app/shared/shared-forms/reactive-file.component.ts2
-rw-r--r--client/src/app/shared/shared-main/video-channel/video-channel.model.ts5
-rw-r--r--client/src/app/shared/shared-video-miniature/video-filters-header.component.html1
-rw-r--r--client/src/app/shared/shared-video-miniature/video-filters-header.component.scss2
-rw-r--r--client/src/assets/player/shared/webtorrent/webtorrent-plugin.ts5
-rw-r--r--client/src/root-helpers/index.ts1
-rw-r--r--client/src/root-helpers/url.ts26
-rw-r--r--client/src/root-helpers/utils.ts10
-rw-r--r--client/src/sass/player/peertube-skin.scss9
-rw-r--r--client/src/standalone/videos/embed-api.ts10
-rw-r--r--client/src/standalone/videos/embed.scss11
-rw-r--r--client/src/standalone/videos/embed.ts872
-rw-r--r--client/src/standalone/videos/shared/auth-http.ts105
-rw-r--r--client/src/standalone/videos/shared/index.ts9
-rw-r--r--client/src/standalone/videos/shared/live-manager.ts69
-rw-r--r--client/src/standalone/videos/shared/peertube-plugin.ts85
-rw-r--r--client/src/standalone/videos/shared/player-html.ts91
-rw-r--r--client/src/standalone/videos/shared/player-manager-options.ts323
-rw-r--r--client/src/standalone/videos/shared/playlist-fetcher.ts72
-rw-r--r--client/src/standalone/videos/shared/playlist-tracker.ts93
-rw-r--r--client/src/standalone/videos/shared/translations.ts5
-rw-r--r--client/src/standalone/videos/shared/video-fetcher.ts63
27 files changed, 1201 insertions, 730 deletions
diff --git a/client/src/app/+admin/overview/users/user-list/user-list.component.html b/client/src/app/+admin/overview/users/user-list/user-list.component.html
index 30d10e3cf..62eeef8fe 100644
--- a/client/src/app/+admin/overview/users/user-list/user-list.component.html
+++ b/client/src/app/+admin/overview/users/user-list/user-list.component.html
@@ -5,7 +5,7 @@
5 5
6<p-table 6<p-table
7 [value]="users" [paginator]="totalRecords > 0" [totalRecords]="totalRecords" [rows]="rowsPerPage" [rowsPerPageOptions]="rowsPerPageOptions" 7 [value]="users" [paginator]="totalRecords > 0" [totalRecords]="totalRecords" [rows]="rowsPerPage" [rowsPerPageOptions]="rowsPerPageOptions"
8 [sortField]="sort.field" [sortOrder]="sort.order" dataKey="id" [resizableColumns]="true" [(selection)]="selectedUsers" 8 [sortField]="sort.field" [sortOrder]="sort.order" dataKey="id" [resizableColumns]="true" [(selection)]="selectedUsers"
9 [lazy]="true" (onLazyLoad)="loadLazy($event)" [lazyLoadOnInit]="false" [selectionPageOnly]="true" 9 [lazy]="true" (onLazyLoad)="loadLazy($event)" [lazyLoadOnInit]="false" [selectionPageOnly]="true"
10 [showCurrentPageReport]="true" i18n-currentPageReportTemplate 10 [showCurrentPageReport]="true" i18n-currentPageReportTemplate
11 currentPageReportTemplate="Showing {{'{first}'}} to {{'{last}'}} of {{'{totalRecords}'}} users" 11 currentPageReportTemplate="Showing {{'{first}'}} to {{'{last}'}} of {{'{totalRecords}'}} users"
diff --git a/client/src/app/+admin/overview/users/user-list/user-list.component.ts b/client/src/app/+admin/overview/users/user-list/user-list.component.ts
index d22e1355e..9d11bd02e 100644
--- a/client/src/app/+admin/overview/users/user-list/user-list.component.ts
+++ b/client/src/app/+admin/overview/users/user-list/user-list.component.ts
@@ -1,7 +1,7 @@
1import { SortMeta } from 'primeng/api' 1import { SortMeta } from 'primeng/api'
2import { Component, OnInit, ViewChild } from '@angular/core' 2import { Component, OnInit, ViewChild } from '@angular/core'
3import { ActivatedRoute, Router } from '@angular/router' 3import { ActivatedRoute, Router } from '@angular/router'
4import { AuthService, ConfirmService, Notifier, RestPagination, RestTable, ServerService } from '@app/core' 4import { AuthService, ConfirmService, LocalStorageService, Notifier, RestPagination, RestTable, ServerService } from '@app/core'
5import { getAPIHost } from '@app/helpers' 5import { getAPIHost } from '@app/helpers'
6import { AdvancedInputFilter } from '@app/shared/shared-forms' 6import { AdvancedInputFilter } from '@app/shared/shared-forms'
7import { Actor, DropdownAction } from '@app/shared/shared-main' 7import { Actor, DropdownAction } from '@app/shared/shared-main'
@@ -22,6 +22,8 @@ type UserForList = User & {
22 styleUrls: [ './user-list.component.scss' ] 22 styleUrls: [ './user-list.component.scss' ]
23}) 23})
24export class UserListComponent extends RestTable implements OnInit { 24export class UserListComponent extends RestTable implements OnInit {
25 private static readonly LOCAL_STORAGE_SELECTED_COLUMNS_KEY = 'admin-user-list-selected-columns'
26
25 @ViewChild('userBanModal', { static: true }) userBanModal: UserBanModalComponent 27 @ViewChild('userBanModal', { static: true }) userBanModal: UserBanModalComponent
26 28
27 users: (User & { accountMutedStatus: AccountMutedStatus })[] = [] 29 users: (User & { accountMutedStatus: AccountMutedStatus })[] = []
@@ -56,7 +58,7 @@ export class UserListComponent extends RestTable implements OnInit {
56 58
57 requiresEmailVerification = false 59 requiresEmailVerification = false
58 60
59 private _selectedColumns: string[] 61 private _selectedColumns: string[] = []
60 62
61 constructor ( 63 constructor (
62 protected route: ActivatedRoute, 64 protected route: ActivatedRoute,
@@ -66,7 +68,8 @@ export class UserListComponent extends RestTable implements OnInit {
66 private serverService: ServerService, 68 private serverService: ServerService,
67 private auth: AuthService, 69 private auth: AuthService,
68 private blocklist: BlocklistService, 70 private blocklist: BlocklistService,
69 private userAdminService: UserAdminService 71 private userAdminService: UserAdminService,
72 private peertubeLocalStorage: LocalStorageService
70 ) { 73 ) {
71 super() 74 super()
72 } 75 }
@@ -76,11 +79,13 @@ export class UserListComponent extends RestTable implements OnInit {
76 } 79 }
77 80
78 get selectedColumns () { 81 get selectedColumns () {
79 return this._selectedColumns 82 return this._selectedColumns || []
80 } 83 }
81 84
82 set selectedColumns (val: string[]) { 85 set selectedColumns (val: string[]) {
83 this._selectedColumns = val 86 this._selectedColumns = val
87
88 this.saveSelectedColumns()
84 } 89 }
85 90
86 ngOnInit () { 91 ngOnInit () {
@@ -126,14 +131,35 @@ export class UserListComponent extends RestTable implements OnInit {
126 { id: 'role', label: $localize`Role` }, 131 { id: 'role', label: $localize`Role` },
127 { id: 'email', label: $localize`Email` }, 132 { id: 'email', label: $localize`Email` },
128 { id: 'quota', label: $localize`Video quota` }, 133 { id: 'quota', label: $localize`Video quota` },
129 { id: 'createdAt', label: $localize`Created` } 134 { id: 'createdAt', label: $localize`Created` },
135 { id: 'lastLoginDate', label: $localize`Last login` },
136
137 { id: 'quotaDaily', label: $localize`Daily quota` },
138 { id: 'pluginAuth', label: $localize`Auth plugin` }
130 ] 139 ]
131 140
132 this.selectedColumns = this.columns.map(c => c.id) 141 this.loadSelectedColumns()
142 }
143
144 loadSelectedColumns () {
145 const result = this.peertubeLocalStorage.getItem(UserListComponent.LOCAL_STORAGE_SELECTED_COLUMNS_KEY)
146
147 if (result) {
148 try {
149 this.selectedColumns = JSON.parse(result)
150 return
151 } catch (err) {
152 console.error('Cannot load selected columns.', err)
153 }
154 }
155
156 // Default behaviour
157 this.selectedColumns = [ 'username', 'role', 'email', 'quota', 'createdAt', 'lastLoginDate' ]
158 return
159 }
133 160
134 this.columns.push({ id: 'quotaDaily', label: $localize`Daily quota` }) 161 saveSelectedColumns () {
135 this.columns.push({ id: 'pluginAuth', label: $localize`Auth plugin` }) 162 this.peertubeLocalStorage.setItem(UserListComponent.LOCAL_STORAGE_SELECTED_COLUMNS_KEY, JSON.stringify(this.selectedColumns))
136 this.columns.push({ id: 'lastLoginDate', label: $localize`Last login` })
137 } 163 }
138 164
139 getIdentifier () { 165 getIdentifier () {
diff --git a/client/src/app/+my-library/+my-video-channels/my-video-channels.component.html b/client/src/app/+my-library/+my-video-channels/my-video-channels.component.html
index 89327b065..f17f62bba 100644
--- a/client/src/app/+my-library/+my-video-channels/my-video-channels.component.html
+++ b/client/src/app/+my-library/+my-video-channels/my-video-channels.component.html
@@ -31,11 +31,13 @@
31 i18n class="video-channel-followers" 31 i18n class="video-channel-followers"
32 [routerLink]="[ '/my-library', 'followers' ]" [queryParams]="{ search: 'channel:' + videoChannel.name }" 32 [routerLink]="[ '/my-library', 'followers' ]" [queryParams]="{ search: 'channel:' + videoChannel.name }"
33 > 33 >
34 {videoChannel.followersCount, plural, =1 {1 subscriber} other {{{ videoChannel.followersCount }} subscribers}} 34 {videoChannel.followersCount, plural, =0 {No subscribers} =1 {1 subscriber} other {{{ videoChannel.followersCount }} subscribers}}
35 </a> 35 </a>
36 36
37 <div i18n class="video-channel-videos">{videoChannel.videosCount, plural, =0 {No videos} =1 {1 video} other {{{ videoChannel.videosCount }} videos}}</div> 37 <div i18n class="video-channel-videos">{videoChannel.videosCount, plural, =0 {No videos} =1 {1 video} other {{{ videoChannel.videosCount }} videos}}</div>
38 38
39 <div i18n class="video-channel-views">{videoChannel.totalViews, plural, =0 {No views} =1 {1 view} other {{{ videoChannel.totalViews }} views}}</div>
40
39 <div class="video-channel-buttons"> 41 <div class="video-channel-buttons">
40 <my-edit-button label [routerLink]="[ '/manage/update', videoChannel.nameWithHost ]"></my-edit-button> 42 <my-edit-button label [routerLink]="[ '/manage/update', videoChannel.nameWithHost ]"></my-edit-button>
41 <my-delete-button label (click)="deleteVideoChannel(videoChannel)"></my-delete-button> 43 <my-delete-button label (click)="deleteVideoChannel(videoChannel)"></my-delete-button>
diff --git a/client/src/app/core/core.module.ts b/client/src/app/core/core.module.ts
index d80f95ed6..4a4c2321b 100644
--- a/client/src/app/core/core.module.ts
+++ b/client/src/app/core/core.module.ts
@@ -41,7 +41,9 @@ import { LocalStorageService, ScreenService, SessionStorageService } from './wra
41 ToastModule, 41 ToastModule,
42 42
43 HotkeyModule.forRoot({ 43 HotkeyModule.forRoot({
44 cheatSheetCloseEsc: true 44 cheatSheetCloseEsc: true,
45 cheatSheetDescription: $localize`Show/hide this help menu`,
46 cheatSheetCloseEscDescription: $localize`Hide this help menu`
45 }) 47 })
46 ], 48 ],
47 49
diff --git a/client/src/app/core/rest/rest-table.ts b/client/src/app/core/rest/rest-table.ts
index d8b039187..7b765f7fc 100644
--- a/client/src/app/core/rest/rest-table.ts
+++ b/client/src/app/core/rest/rest-table.ts
@@ -39,6 +39,10 @@ export abstract class RestTable {
39 } 39 }
40 } 40 }
41 41
42 saveSort () {
43 peertubeLocalStorage.setItem(this.getSortLocalStorageKey(), JSON.stringify(this.sort))
44 }
45
42 loadLazy (event: LazyLoadEvent) { 46 loadLazy (event: LazyLoadEvent) {
43 logger('Load lazy %o.', event) 47 logger('Load lazy %o.', event)
44 48
@@ -60,10 +64,6 @@ export abstract class RestTable {
60 this.saveSort() 64 this.saveSort()
61 } 65 }
62 66
63 saveSort () {
64 peertubeLocalStorage.setItem(this.getSortLocalStorageKey(), JSON.stringify(this.sort))
65 }
66
67 onSearch (search: string) { 67 onSearch (search: string) {
68 this.search = search 68 this.search = search
69 this.reloadData() 69 this.reloadData()
diff --git a/client/src/app/shared/shared-forms/reactive-file.component.ts b/client/src/app/shared/shared-forms/reactive-file.component.ts
index 50b7d4c3e..48055a51c 100644
--- a/client/src/app/shared/shared-forms/reactive-file.component.ts
+++ b/client/src/app/shared/shared-forms/reactive-file.component.ts
@@ -57,7 +57,7 @@ export class ReactiveFileComponent implements OnInit, ControlValueAccessor {
57 57
58 const extension = '.' + file.name.split('.').pop() 58 const extension = '.' + file.name.split('.').pop()
59 if (this.extensions.includes(extension.toLowerCase()) === false) { 59 if (this.extensions.includes(extension.toLowerCase()) === false) {
60 const message = $localize`PeerTube cannot handle this kind of file. Accepted extensions are ${this.allowedExtensionsMessage}}.` 60 const message = $localize`PeerTube cannot handle this kind of file. Accepted extensions are ${this.allowedExtensionsMessage}.`
61 this.notifier.error(message) 61 this.notifier.error(message)
62 62
63 return 63 return
diff --git a/client/src/app/shared/shared-main/video-channel/video-channel.model.ts b/client/src/app/shared/shared-main/video-channel/video-channel.model.ts
index 32376bf62..62bd94349 100644
--- a/client/src/app/shared/shared-main/video-channel/video-channel.model.ts
+++ b/client/src/app/shared/shared-main/video-channel/video-channel.model.ts
@@ -27,6 +27,7 @@ export class VideoChannel extends Actor implements ServerVideoChannel {
27 videosCount?: number 27 videosCount?: number
28 28
29 viewsPerDay?: ViewsPerDate[] 29 viewsPerDay?: ViewsPerDate[]
30 totalViews?: number
30 31
31 static GET_ACTOR_AVATAR_URL (actor: { avatars: { width: number, url?: string, path: string }[] }, size: number) { 32 static GET_ACTOR_AVATAR_URL (actor: { avatars: { width: number, url?: string, path: string }[] }, size: number) {
32 return Actor.GET_ACTOR_AVATAR_URL(actor, size) 33 return Actor.GET_ACTOR_AVATAR_URL(actor, size)
@@ -74,6 +75,10 @@ export class VideoChannel extends Actor implements ServerVideoChannel {
74 this.viewsPerDay = hash.viewsPerDay.map(v => ({ ...v, date: new Date(v.date) })) 75 this.viewsPerDay = hash.viewsPerDay.map(v => ({ ...v, date: new Date(v.date) }))
75 } 76 }
76 77
78 if (hash.totalViews !== null && hash.totalViews !== undefined) {
79 this.totalViews = hash.totalViews
80 }
81
77 if (hash.ownerAccount) { 82 if (hash.ownerAccount) {
78 this.ownerAccount = hash.ownerAccount 83 this.ownerAccount = hash.ownerAccount
79 this.ownerBy = Actor.CREATE_BY_STRING(hash.ownerAccount.name, hash.ownerAccount.host) 84 this.ownerBy = Actor.CREATE_BY_STRING(hash.ownerAccount.name, hash.ownerAccount.host)
diff --git a/client/src/app/shared/shared-video-miniature/video-filters-header.component.html b/client/src/app/shared/shared-video-miniature/video-filters-header.component.html
index a07b8b5ee..fe7a59bdb 100644
--- a/client/src/app/shared/shared-video-miniature/video-filters-header.component.html
+++ b/client/src/app/shared/shared-video-miniature/video-filters-header.component.html
@@ -44,6 +44,7 @@
44 [searchable]="false" 44 [searchable]="false"
45 > 45 >
46 <ng-option i18n value="-publishedAt">Sort by <strong>"Recently Added"</strong></ng-option> 46 <ng-option i18n value="-publishedAt">Sort by <strong>"Recently Added"</strong></ng-option>
47 <ng-option i18n value="-originallyPublishedAt">Sort by <strong>"Original Publication Date"</strong></ng-option>
47 48
48 <ng-option i18n *ngIf="isTrendingSortEnabled('most-viewed')" value="-trending">Sort by <strong>"Recent Views"</strong></ng-option> 49 <ng-option i18n *ngIf="isTrendingSortEnabled('most-viewed')" value="-trending">Sort by <strong>"Recent Views"</strong></ng-option>
49 <ng-option i18n *ngIf="isTrendingSortEnabled('hot')" value="-hot">Sort by <strong>"Hot"</strong></ng-option> 50 <ng-option i18n *ngIf="isTrendingSortEnabled('hot')" value="-hot">Sort by <strong>"Hot"</strong></ng-option>
diff --git a/client/src/app/shared/shared-video-miniature/video-filters-header.component.scss b/client/src/app/shared/shared-video-miniature/video-filters-header.component.scss
index 8cb1ff5b8..6a968ed5c 100644
--- a/client/src/app/shared/shared-video-miniature/video-filters-header.component.scss
+++ b/client/src/app/shared/shared-video-miniature/video-filters-header.component.scss
@@ -101,7 +101,7 @@
101} 101}
102 102
103.sort { 103.sort {
104 min-width: 200px; 104 min-width: 250px;
105 max-width: 300px; 105 max-width: 300px;
106 height: min-content; 106 height: min-content;
107 107
diff --git a/client/src/assets/player/shared/webtorrent/webtorrent-plugin.ts b/client/src/assets/player/shared/webtorrent/webtorrent-plugin.ts
index b48203148..83b483d87 100644
--- a/client/src/assets/player/shared/webtorrent/webtorrent-plugin.ts
+++ b/client/src/assets/player/shared/webtorrent/webtorrent-plugin.ts
@@ -430,6 +430,11 @@ class WebTorrentPlugin extends Plugin {
430 private initializePlayer () { 430 private initializePlayer () {
431 this.buildQualities() 431 this.buildQualities()
432 432
433 if (this.videoFiles.length === 0) {
434 this.player.addClass('disabled')
435 return
436 }
437
433 if (this.autoplay) { 438 if (this.autoplay) {
434 this.player.posterImage.hide() 439 this.player.posterImage.hide()
435 440
diff --git a/client/src/root-helpers/index.ts b/client/src/root-helpers/index.ts
index 0492924fd..a19855761 100644
--- a/client/src/root-helpers/index.ts
+++ b/client/src/root-helpers/index.ts
@@ -5,5 +5,6 @@ export * from './local-storage-utils'
5export * from './peertube-web-storage' 5export * from './peertube-web-storage'
6export * from './plugins-manager' 6export * from './plugins-manager'
7export * from './string' 7export * from './string'
8export * from './url'
8export * from './utils' 9export * from './utils'
9export * from './video' 10export * from './video'
diff --git a/client/src/root-helpers/url.ts b/client/src/root-helpers/url.ts
new file mode 100644
index 000000000..b2f0c8b85
--- /dev/null
+++ b/client/src/root-helpers/url.ts
@@ -0,0 +1,26 @@
1function getParamToggle (params: URLSearchParams, name: string, defaultValue?: boolean) {
2 return params.has(name)
3 ? (params.get(name) === '1' || params.get(name) === 'true')
4 : defaultValue
5}
6
7function getParamString (params: URLSearchParams, name: string, defaultValue?: string) {
8 return params.has(name)
9 ? params.get(name)
10 : defaultValue
11}
12
13function objectToUrlEncoded (obj: any) {
14 const str: string[] = []
15 for (const key of Object.keys(obj)) {
16 str.push(encodeURIComponent(key) + '=' + encodeURIComponent(obj[key]))
17 }
18
19 return str.join('&')
20}
21
22export {
23 getParamToggle,
24 getParamString,
25 objectToUrlEncoded
26}
diff --git a/client/src/root-helpers/utils.ts b/client/src/root-helpers/utils.ts
index 00bd92411..af94ed6ca 100644
--- a/client/src/root-helpers/utils.ts
+++ b/client/src/root-helpers/utils.ts
@@ -1,12 +1,3 @@
1function objectToUrlEncoded (obj: any) {
2 const str: string[] = []
3 for (const key of Object.keys(obj)) {
4 str.push(encodeURIComponent(key) + '=' + encodeURIComponent(obj[key]))
5 }
6
7 return str.join('&')
8}
9
10function copyToClipboard (text: string) { 1function copyToClipboard (text: string) {
11 const el = document.createElement('textarea') 2 const el = document.createElement('textarea')
12 el.value = text 3 el.value = text
@@ -27,6 +18,5 @@ function wait (ms: number) {
27 18
28export { 19export {
29 copyToClipboard, 20 copyToClipboard,
30 objectToUrlEncoded,
31 wait 21 wait
32} 22}
diff --git a/client/src/sass/player/peertube-skin.scss b/client/src/sass/player/peertube-skin.scss
index c420e825e..43c144624 100644
--- a/client/src/sass/player/peertube-skin.scss
+++ b/client/src/sass/player/peertube-skin.scss
@@ -20,6 +20,15 @@ body {
20 font-size: $font-size; 20 font-size: $font-size;
21 color: pvar(--embedForegroundColor); 21 color: pvar(--embedForegroundColor);
22 22
23 &.disabled {
24 cursor: default;
25 pointer-events: none;
26
27 .vjs-big-play-button {
28 display: none !important;
29 }
30 }
31
23 .vjs-audio-button { 32 .vjs-audio-button {
24 display: none; 33 display: none;
25 } 34 }
diff --git a/client/src/standalone/videos/embed-api.ts b/client/src/standalone/videos/embed-api.ts
index a28aeeaef..84d664654 100644
--- a/client/src/standalone/videos/embed-api.ts
+++ b/client/src/standalone/videos/embed-api.ts
@@ -27,11 +27,11 @@ export class PeerTubeEmbedApi {
27 } 27 }
28 28
29 private get element () { 29 private get element () {
30 return this.embed.playerElement 30 return this.embed.getPlayerElement()
31 } 31 }
32 32
33 private constructChannel () { 33 private constructChannel () {
34 const channel = Channel.build({ window: window.parent, origin: '*', scope: this.embed.scope }) 34 const channel = Channel.build({ window: window.parent, origin: '*', scope: this.embed.getScope() })
35 35
36 channel.bind('play', (txn, params) => this.embed.player.play()) 36 channel.bind('play', (txn, params) => this.embed.player.play())
37 channel.bind('pause', (txn, params) => this.embed.player.pause()) 37 channel.bind('pause', (txn, params) => this.embed.player.pause())
@@ -52,9 +52,9 @@ export class PeerTubeEmbedApi {
52 channel.bind('getPlaybackRate', (txn, params) => this.embed.player.playbackRate()) 52 channel.bind('getPlaybackRate', (txn, params) => this.embed.player.playbackRate())
53 channel.bind('getPlaybackRates', (txn, params) => this.embed.player.options_.playbackRates) 53 channel.bind('getPlaybackRates', (txn, params) => this.embed.player.options_.playbackRates)
54 54
55 channel.bind('playNextVideo', (txn, params) => this.embed.playNextVideo()) 55 channel.bind('playNextVideo', (txn, params) => this.embed.playNextPlaylistVideo())
56 channel.bind('playPreviousVideo', (txn, params) => this.embed.playPreviousVideo()) 56 channel.bind('playPreviousVideo', (txn, params) => this.embed.playPreviousPlaylistVideo())
57 channel.bind('getCurrentPosition', (txn, params) => this.embed.getCurrentPosition()) 57 channel.bind('getCurrentPosition', (txn, params) => this.embed.getCurrentPlaylistPosition())
58 this.channel = channel 58 this.channel = channel
59 } 59 }
60 60
diff --git a/client/src/standalone/videos/embed.scss b/client/src/standalone/videos/embed.scss
index 91ab822c8..8c20bae79 100644
--- a/client/src/standalone/videos/embed.scss
+++ b/client/src/standalone/videos/embed.scss
@@ -92,6 +92,17 @@ body {
92 width: 100%; 92 width: 100%;
93 height: 100%; 93 height: 100%;
94 background-position: 50% 50%; 94 background-position: 50% 50%;
95 background-repeat: no-repeat;
96}
97
98.player-information {
99 width: 100%;
100 color: #fff;
101 background: rgba(0, 0, 0, 0.6);
102 padding: 20px 0;
103 position: absolute;
104 bottom: 0;
105 text-align: center;
95} 106}
96 107
97@media screen and (max-width: 300px) { 108@media screen and (max-width: 300px) {
diff --git a/client/src/standalone/videos/embed.ts b/client/src/standalone/videos/embed.ts
index 1fc8e229b..0a2b0ccbd 100644
--- a/client/src/standalone/videos/embed.ts
+++ b/client/src/standalone/videos/embed.ts
@@ -3,83 +3,42 @@ import '../../assets/player/shared/dock/peertube-dock-component'
3import '../../assets/player/shared/dock/peertube-dock-plugin' 3import '../../assets/player/shared/dock/peertube-dock-plugin'
4import videojs from 'video.js' 4import videojs from 'video.js'
5import { peertubeTranslate } from '../../../../shared/core-utils/i18n' 5import { peertubeTranslate } from '../../../../shared/core-utils/i18n'
6import { 6import { HTMLServerConfig, LiveVideo, ResultList, VideoDetails, VideoPlaylist, VideoPlaylistElement } from '../../../../shared/models'
7 HTMLServerConfig, 7import { PeertubePlayerManager } from '../../assets/player'
8 HttpStatusCode,
9 LiveVideo,
10 OAuth2ErrorCode,
11 PublicServerSetting,
12 ResultList,
13 UserRefreshToken,
14 Video,
15 VideoCaption,
16 VideoDetails,
17 VideoPlaylist,
18 VideoPlaylistElement,
19 VideoStreamingPlaylistType
20} from '../../../../shared/models'
21import { P2PMediaLoaderOptions, PeertubePlayerManagerOptions, PlayerMode, VideoJSCaption } from '../../assets/player'
22import { TranslationsManager } from '../../assets/player/translations-manager' 8import { TranslationsManager } from '../../assets/player/translations-manager'
23import { getBoolOrDefault } from '../../root-helpers/local-storage-utils' 9import { getParamString } from '../../root-helpers'
24import { peertubeLocalStorage } from '../../root-helpers/peertube-web-storage'
25import { PluginInfo, PluginsManager } from '../../root-helpers/plugins-manager'
26import { UserLocalStorageKeys, UserTokens } from '../../root-helpers/users'
27import { objectToUrlEncoded } from '../../root-helpers/utils'
28import { isP2PEnabled } from '../../root-helpers/video'
29import { RegisterClientHelpers } from '../../types/register-client-option.model'
30import { PeerTubeEmbedApi } from './embed-api' 10import { PeerTubeEmbedApi } from './embed-api'
31 11import { AuthHTTP, LiveManager, PeerTubePlugin, PlayerManagerOptions, PlaylistFetcher, PlaylistTracker, VideoFetcher } from './shared'
32type Translations = { [ id: string ]: string } 12import { PlayerHTML } from './shared/player-html'
33 13
34export class PeerTubeEmbed { 14export class PeerTubeEmbed {
35 playerElement: HTMLVideoElement
36 player: videojs.Player 15 player: videojs.Player
37 api: PeerTubeEmbedApi = null 16 api: PeerTubeEmbedApi = null
38 17
39 autoplay: boolean
40
41 controls: boolean
42 controlBar: boolean
43
44 muted: boolean
45 loop: boolean
46 subtitle: string
47 enableApi = false
48 startTime: number | string = 0
49 stopTime: number | string
50
51 title: boolean
52 warningTitle: boolean
53 peertubeLink: boolean
54 p2pEnabled: boolean
55 bigPlayBackgroundColor: string
56 foregroundColor: string
57
58 mode: PlayerMode
59 scope = 'peertube'
60
61 userTokens: UserTokens
62 headers = new Headers()
63 LOCAL_STORAGE_OAUTH_CLIENT_KEYS = {
64 CLIENT_ID: 'client_id',
65 CLIENT_SECRET: 'client_secret'
66 }
67
68 config: HTMLServerConfig 18 config: HTMLServerConfig
69 19
70 private translationsPromise: Promise<{ [id: string]: string }> 20 private translationsPromise: Promise<{ [id: string]: string }>
71 private PeertubePlayerManagerModulePromise: Promise<any> 21 private PeertubePlayerManagerModulePromise: Promise<any>
72 22
73 private playlist: VideoPlaylist 23 private readonly http: AuthHTTP
74 private playlistElements: VideoPlaylistElement[] 24 private readonly videoFetcher: VideoFetcher
75 private currentPlaylistElement: VideoPlaylistElement 25 private readonly playlistFetcher: PlaylistFetcher
26 private readonly peertubePlugin: PeerTubePlugin
27 private readonly playerHTML: PlayerHTML
28 private readonly playerManagerOptions: PlayerManagerOptions
29 private readonly liveManager: LiveManager
76 30
77 private readonly wrapperElement: HTMLElement 31 private playlistTracker: PlaylistTracker
78 32
79 private pluginsManager: PluginsManager 33 constructor (videoWrapperId: string) {
34 this.http = new AuthHTTP()
80 35
81 constructor (private readonly videoWrapperId: string) { 36 this.videoFetcher = new VideoFetcher(this.http)
82 this.wrapperElement = document.getElementById(this.videoWrapperId) 37 this.playlistFetcher = new PlaylistFetcher(this.http)
38 this.peertubePlugin = new PeerTubePlugin(this.http)
39 this.playerHTML = new PlayerHTML(videoWrapperId)
40 this.playerManagerOptions = new PlayerManagerOptions(this.playerHTML, this.videoFetcher, this.peertubePlugin)
41 this.liveManager = new LiveManager(this.playerHTML)
83 42
84 try { 43 try {
85 this.config = JSON.parse(window['PeerTubeServerConfig']) 44 this.config = JSON.parse(window['PeerTubeServerConfig'])
@@ -94,697 +53,279 @@ export class PeerTubeEmbed {
94 await embed.init() 53 await embed.init()
95 } 54 }
96 55
97 getVideoUrl (id: string) { 56 getPlayerElement () {
98 return window.location.origin + '/api/v1/videos/' + id 57 return this.playerHTML.getPlayerElement()
99 }
100
101 getLiveUrl (videoId: string) {
102 return window.location.origin + '/api/v1/videos/live/' + videoId
103 }
104
105 getPluginUrl () {
106 return window.location.origin + '/api/v1/plugins'
107 }
108
109 refreshFetch (url: string, options?: RequestInit) {
110 return fetch(url, options)
111 .then((res: Response) => {
112 if (res.status !== HttpStatusCode.UNAUTHORIZED_401) return res
113
114 const refreshingTokenPromise = new Promise<void>((resolve, reject) => {
115 const clientId: string = peertubeLocalStorage.getItem(this.LOCAL_STORAGE_OAUTH_CLIENT_KEYS.CLIENT_ID)
116 const clientSecret: string = peertubeLocalStorage.getItem(this.LOCAL_STORAGE_OAUTH_CLIENT_KEYS.CLIENT_SECRET)
117
118 const headers = new Headers()
119 headers.set('Content-Type', 'application/x-www-form-urlencoded')
120
121 const data = {
122 refresh_token: this.userTokens.refreshToken,
123 client_id: clientId,
124 client_secret: clientSecret,
125 response_type: 'code',
126 grant_type: 'refresh_token'
127 }
128
129 fetch('/api/v1/users/token', {
130 headers,
131 method: 'POST',
132 body: objectToUrlEncoded(data)
133 }).then(res => {
134 if (res.status === HttpStatusCode.UNAUTHORIZED_401) return undefined
135
136 return res.json()
137 }).then((obj: UserRefreshToken & { code?: OAuth2ErrorCode }) => {
138 if (!obj || obj.code === OAuth2ErrorCode.INVALID_GRANT) {
139 UserTokens.flushLocalStorage(peertubeLocalStorage)
140 this.removeTokensFromHeaders()
141
142 return resolve()
143 }
144
145 this.userTokens.accessToken = obj.access_token
146 this.userTokens.refreshToken = obj.refresh_token
147 UserTokens.saveToLocalStorage(peertubeLocalStorage, this.userTokens)
148
149 this.setHeadersFromTokens()
150
151 resolve()
152 }).catch((refreshTokenError: any) => {
153 reject(refreshTokenError)
154 })
155 })
156
157 return refreshingTokenPromise
158 .catch(() => {
159 UserTokens.flushLocalStorage(peertubeLocalStorage)
160
161 this.removeTokensFromHeaders()
162 }).then(() => fetch(url, {
163 ...options,
164 headers: this.headers
165 }))
166 })
167 }
168
169 getPlaylistUrl (id: string) {
170 return window.location.origin + '/api/v1/video-playlists/' + id
171 } 58 }
172 59
173 loadVideoInfo (videoId: string): Promise<Response> { 60 getScope () {
174 return this.refreshFetch(this.getVideoUrl(videoId), { headers: this.headers }) 61 return this.playerManagerOptions.getScope()
175 } 62 }
176 63
177 loadVideoCaptions (videoId: string): Promise<Response> { 64 // ---------------------------------------------------------------------------
178 return this.refreshFetch(this.getVideoUrl(videoId) + '/captions', { headers: this.headers })
179 }
180 65
181 loadWithLive (video: VideoDetails) { 66 async init () {
182 return this.refreshFetch(this.getLiveUrl(video.uuid), { headers: this.headers }) 67 this.translationsPromise = TranslationsManager.getServerTranslations(window.location.origin, navigator.language)
183 .then(res => res.json()) 68 this.PeertubePlayerManagerModulePromise = import('../../assets/player/peertube-player-manager')
184 .then((live: LiveVideo) => ({ video, live }))
185 }
186 69
187 loadPlaylistInfo (playlistId: string): Promise<Response> { 70 // Issue when we parsed config from HTML, fallback to API
188 return this.refreshFetch(this.getPlaylistUrl(playlistId), { headers: this.headers }) 71 if (!this.config) {
189 } 72 this.config = await this.http.fetch('/api/v1/config', { optionalAuth: false })
73 .then(res => res.json())
74 }
190 75
191 loadPlaylistElements (playlistId: string, start = 0): Promise<Response> { 76 const videoId = this.isPlaylistEmbed()
192 const url = new URL(this.getPlaylistUrl(playlistId) + '/videos') 77 ? await this.initPlaylist()
193 url.search = new URLSearchParams({ start: '' + start, count: '100' }).toString() 78 : this.getResourceId()
194 79
195 return this.refreshFetch(url.toString(), { headers: this.headers }) 80 if (!videoId) return
196 }
197 81
198 removeElement (element: HTMLElement) { 82 return this.loadVideoAndBuildPlayer(videoId)
199 element.parentElement.removeChild(element)
200 } 83 }
201 84
202 displayError (text: string, translations?: Translations) { 85 private async initPlaylist () {
203 // Remove video element 86 const playlistId = this.getResourceId()
204 if (this.playerElement) {
205 this.removeElement(this.playerElement)
206 this.playerElement = undefined
207 }
208
209 const translatedText = peertubeTranslate(text, translations)
210 const translatedSorry = peertubeTranslate('Sorry', translations)
211 87
212 document.title = translatedSorry + ' - ' + translatedText 88 try {
213 89 const res = await this.playlistFetcher.loadPlaylist(playlistId)
214 const errorBlock = document.getElementById('error-block')
215 errorBlock.style.display = 'flex'
216 90
217 const errorTitle = document.getElementById('error-title') 91 const [ playlist, playlistElementResult ] = await Promise.all([
218 errorTitle.innerHTML = peertubeTranslate('Sorry', translations) 92 res.playlistResponse.json() as Promise<VideoPlaylist>,
93 res.videosResponse.json() as Promise<ResultList<VideoPlaylistElement>>
94 ])
219 95
220 const errorText = document.getElementById('error-content') 96 const allPlaylistElements = await this.playlistFetcher.loadAllPlaylistVideos(playlistId, playlistElementResult)
221 errorText.innerHTML = translatedText
222 97
223 this.wrapperElement.style.display = 'none' 98 this.playlistTracker = new PlaylistTracker(playlist, allPlaylistElements)
224 }
225 99
226 videoNotFound (translations?: Translations) { 100 const params = new URL(window.location.toString()).searchParams
227 const text = 'This video does not exist.' 101 const playlistPositionParam = getParamString(params, 'playlistPosition')
228 this.displayError(text, translations)
229 }
230 102
231 videoFetchError (translations?: Translations) { 103 const position = playlistPositionParam
232 const text = 'We cannot fetch the video. Please try again later.' 104 ? parseInt(playlistPositionParam + '', 10)
233 this.displayError(text, translations) 105 : 1
234 }
235 106
236 playlistNotFound (translations?: Translations) { 107 this.playlistTracker.setPosition(position)
237 const text = 'This playlist does not exist.' 108 } catch (err) {
238 this.displayError(text, translations) 109 this.playerHTML.displayError(err.message, await this.translationsPromise)
239 } 110 return undefined
111 }
240 112
241 playlistFetchError (translations?: Translations) { 113 return this.playlistTracker.getCurrentElement().video.uuid
242 const text = 'We cannot fetch the playlist. Please try again later.'
243 this.displayError(text, translations)
244 } 114 }
245 115
246 getParamToggle (params: URLSearchParams, name: string, defaultValue?: boolean) { 116 private initializeApi () {
247 return params.has(name) ? (params.get(name) === '1' || params.get(name) === 'true') : defaultValue 117 if (this.playerManagerOptions.hasAPIEnabled()) {
118 this.api = new PeerTubeEmbedApi(this)
119 this.api.initialize()
120 }
248 } 121 }
249 122
250 getParamString (params: URLSearchParams, name: string, defaultValue?: string) { 123 // ---------------------------------------------------------------------------
251 return params.has(name) ? params.get(name) : defaultValue
252 }
253 124
254 async playNextVideo () { 125 async playNextPlaylistVideo () {
255 const next = this.getNextPlaylistElement() 126 const next = this.playlistTracker.getNextPlaylistElement()
256 if (!next) { 127 if (!next) {
257 console.log('Next element not found in playlist.') 128 console.log('Next element not found in playlist.')
258 return 129 return
259 } 130 }
260 131
261 this.currentPlaylistElement = next 132 this.playlistTracker.setCurrentElement(next)
262 133
263 return this.loadVideoAndBuildPlayer(this.currentPlaylistElement.video.uuid) 134 return this.loadVideoAndBuildPlayer(next.video.uuid)
264 } 135 }
265 136
266 async playPreviousVideo () { 137 async playPreviousPlaylistVideo () {
267 const previous = this.getPreviousPlaylistElement() 138 const previous = this.playlistTracker.getPreviousPlaylistElement()
268 if (!previous) { 139 if (!previous) {
269 console.log('Previous element not found in playlist.') 140 console.log('Previous element not found in playlist.')
270 return 141 return
271 } 142 }
272 143
273 this.currentPlaylistElement = previous 144 this.playlistTracker.setCurrentElement(previous)
274
275 await this.loadVideoAndBuildPlayer(this.currentPlaylistElement.video.uuid)
276 }
277
278 getCurrentPosition () {
279 if (!this.currentPlaylistElement) return -1
280 145
281 return this.currentPlaylistElement.position 146 await this.loadVideoAndBuildPlayer(previous.video.uuid)
282 } 147 }
283 148
284 async init () { 149 getCurrentPlaylistPosition () {
285 this.userTokens = UserTokens.getUserTokens(peertubeLocalStorage) 150 return this.playlistTracker.getCurrentPosition()
286 await this.initCore()
287 } 151 }
288 152
289 private initializeApi () { 153 // ---------------------------------------------------------------------------
290 if (!this.enableApi) return
291
292 this.api = new PeerTubeEmbedApi(this)
293 this.api.initialize()
294 }
295
296 private loadParams (video: VideoDetails) {
297 try {
298 const params = new URL(window.location.toString()).searchParams
299
300 this.autoplay = this.getParamToggle(params, 'autoplay', false)
301
302 this.controls = this.getParamToggle(params, 'controls', true)
303 this.controlBar = this.getParamToggle(params, 'controlBar', true)
304
305 this.muted = this.getParamToggle(params, 'muted', undefined)
306 this.loop = this.getParamToggle(params, 'loop', false)
307 this.title = this.getParamToggle(params, 'title', true)
308 this.enableApi = this.getParamToggle(params, 'api', this.enableApi)
309 this.warningTitle = this.getParamToggle(params, 'warningTitle', true)
310 this.peertubeLink = this.getParamToggle(params, 'peertubeLink', true)
311 this.p2pEnabled = this.getParamToggle(params, 'p2p', this.isP2PEnabled(video))
312
313 this.scope = this.getParamString(params, 'scope', this.scope)
314 this.subtitle = this.getParamString(params, 'subtitle')
315 this.startTime = this.getParamString(params, 'start')
316 this.stopTime = this.getParamString(params, 'stop')
317
318 this.bigPlayBackgroundColor = this.getParamString(params, 'bigPlayBackgroundColor')
319 this.foregroundColor = this.getParamString(params, 'foregroundColor')
320
321 const modeParam = this.getParamString(params, 'mode')
322
323 if (modeParam) {
324 if (modeParam === 'p2p-media-loader') this.mode = 'p2p-media-loader'
325 else this.mode = 'webtorrent'
326 } else {
327 if (Array.isArray(video.streamingPlaylists) && video.streamingPlaylists.length !== 0) this.mode = 'p2p-media-loader'
328 else this.mode = 'webtorrent'
329 }
330 } catch (err) {
331 console.error('Cannot get params from URL.', err)
332 }
333 }
334
335 private async loadAllPlaylistVideos (playlistId: string, baseResult: ResultList<VideoPlaylistElement>) {
336 let elements = baseResult.data
337 let total = baseResult.total
338 let i = 0
339
340 while (total > elements.length && i < 10) {
341 const result = await this.loadPlaylistElements(playlistId, elements.length)
342
343 const json = await result.json()
344 total = json.total
345
346 elements = elements.concat(json.data)
347 i++
348 }
349
350 if (i === 10) {
351 console.error('Cannot fetch all playlists elements, there are too many!')
352 }
353
354 return elements
355 }
356
357 private async loadPlaylist (playlistId: string) {
358 const playlistPromise = this.loadPlaylistInfo(playlistId)
359 const playlistElementsPromise = this.loadPlaylistElements(playlistId)
360
361 let playlistResponse: Response
362 let isResponseOk: boolean
363 154
155 private async loadVideoAndBuildPlayer (uuid: string) {
364 try { 156 try {
365 playlistResponse = await playlistPromise 157 const { videoResponse, captionsPromise } = await this.videoFetcher.loadVideo(uuid)
366 isResponseOk = playlistResponse.status === HttpStatusCode.OK_200
367 } catch (err) {
368 console.error(err)
369 isResponseOk = false
370 }
371
372 if (!isResponseOk) {
373 const serverTranslations = await this.translationsPromise
374
375 if (playlistResponse?.status === HttpStatusCode.NOT_FOUND_404) {
376 this.playlistNotFound(serverTranslations)
377 return undefined
378 }
379 158
380 this.playlistFetchError(serverTranslations) 159 return this.buildVideoPlayer(videoResponse, captionsPromise)
381 return undefined
382 }
383
384 return { playlistResponse, videosResponse: await playlistElementsPromise }
385 }
386
387 private async loadVideo (videoId: string) {
388 const videoPromise = this.loadVideoInfo(videoId)
389
390 let videoResponse: Response
391 let isResponseOk: boolean
392
393 try {
394 videoResponse = await videoPromise
395 isResponseOk = videoResponse.status === HttpStatusCode.OK_200
396 } catch (err) { 160 } catch (err) {
397 console.error(err) 161 this.playerHTML.displayError(err.message, await this.translationsPromise)
398
399 isResponseOk = false
400 }
401
402 if (!isResponseOk) {
403 const serverTranslations = await this.translationsPromise
404
405 if (videoResponse?.status === HttpStatusCode.NOT_FOUND_404) {
406 this.videoNotFound(serverTranslations)
407 return undefined
408 }
409
410 this.videoFetchError(serverTranslations)
411 return undefined
412 }
413
414 const captionsPromise = this.loadVideoCaptions(videoId)
415
416 return { captionsPromise, videoResponse }
417 }
418
419 private async buildPlaylistManager () {
420 const translations = await this.translationsPromise
421
422 this.player.upnext({
423 timeout: 10000, // 10s
424 headText: peertubeTranslate('Up Next', translations),
425 cancelText: peertubeTranslate('Cancel', translations),
426 suspendedText: peertubeTranslate('Autoplay is suspended', translations),
427 getTitle: () => this.nextVideoTitle(),
428 next: () => this.playNextVideo(),
429 condition: () => !!this.getNextPlaylistElement(),
430 suspended: () => false
431 })
432 }
433
434 private async loadVideoAndBuildPlayer (uuid: string) {
435 const res = await this.loadVideo(uuid)
436 if (res === undefined) return
437
438 return this.buildVideoPlayer(res.videoResponse, res.captionsPromise)
439 }
440
441 private nextVideoTitle () {
442 const next = this.getNextPlaylistElement()
443 if (!next) return ''
444
445 return next.video.name
446 }
447
448 private getNextPlaylistElement (position?: number): VideoPlaylistElement {
449 if (!position) position = this.currentPlaylistElement.position + 1
450
451 if (position > this.playlist.videosLength) {
452 return undefined
453 } 162 }
454
455 const next = this.playlistElements.find(e => e.position === position)
456
457 if (!next || !next.video) {
458 return this.getNextPlaylistElement(position + 1)
459 }
460
461 return next
462 }
463
464 private getPreviousPlaylistElement (position?: number): VideoPlaylistElement {
465 if (!position) position = this.currentPlaylistElement.position - 1
466
467 if (position < 1) {
468 return undefined
469 }
470
471 const prev = this.playlistElements.find(e => e.position === position)
472
473 if (!prev || !prev.video) {
474 return this.getNextPlaylistElement(position - 1)
475 }
476
477 return prev
478 } 163 }
479 164
480 private async buildVideoPlayer (videoResponse: Response, captionsPromise: Promise<Response>) { 165 private async buildVideoPlayer (videoResponse: Response, captionsPromise: Promise<Response>) {
481 let alreadyHadPlayer = false 166 const alreadyHadPlayer = this.resetPlayerElement()
482
483 if (this.player) {
484 this.player.dispose()
485 alreadyHadPlayer = true
486 }
487
488 this.playerElement = document.createElement('video')
489 this.playerElement.className = 'video-js vjs-peertube-skin'
490 this.playerElement.setAttribute('playsinline', 'true')
491 this.wrapperElement.appendChild(this.playerElement)
492
493 // Issue when we parsed config from HTML, fallback to API
494 if (!this.config) {
495 this.config = await this.refreshFetch('/api/v1/config')
496 .then(res => res.json())
497 }
498 167
499 const videoInfoPromise: Promise<{ video: VideoDetails, live?: LiveVideo }> = videoResponse.json() 168 const videoInfoPromise: Promise<{ video: VideoDetails, live?: LiveVideo }> = videoResponse.json()
500 .then((videoInfo: VideoDetails) => { 169 .then((videoInfo: VideoDetails) => {
501 this.loadParams(videoInfo) 170 this.playerManagerOptions.loadParams(this.config, videoInfo)
502 171
503 if (!alreadyHadPlayer && !this.autoplay) this.buildPlaceholder(videoInfo) 172 if (!alreadyHadPlayer && !this.playerManagerOptions.hasAutoplay()) {
173 this.playerHTML.buildPlaceholder(videoInfo)
174 }
504 175
505 if (!videoInfo.isLive) return { video: videoInfo } 176 if (!videoInfo.isLive) {
177 return { video: videoInfo }
178 }
506 179
507 return this.loadWithLive(videoInfo) 180 return this.videoFetcher.loadVideoWithLive(videoInfo)
508 }) 181 })
509 182
510 const [ videoInfoTmp, serverTranslations, captionsResponse, PeertubePlayerManagerModule ] = await Promise.all([ 183 const [ { video, live }, translations, captionsResponse, PeertubePlayerManagerModule ] = await Promise.all([
511 videoInfoPromise, 184 videoInfoPromise,
512 this.translationsPromise, 185 this.translationsPromise,
513 captionsPromise, 186 captionsPromise,
514 this.PeertubePlayerManagerModulePromise 187 this.PeertubePlayerManagerModulePromise
515 ]) 188 ])
516 189
517 await this.loadPlugins(serverTranslations) 190 await this.peertubePlugin.loadPlugins(this.config, translations)
518
519 const { video: videoInfo, live } = videoInfoTmp
520
521 const PeertubePlayerManager = PeertubePlayerManagerModule.PeertubePlayerManager
522 const videoCaptions = await this.buildCaptions(serverTranslations, captionsResponse)
523
524 const liveOptions = videoInfo.isLive
525 ? { latencyMode: live.latencyMode }
526 : undefined
527
528 const playlistPlugin = this.currentPlaylistElement
529 ? {
530 elements: this.playlistElements,
531 playlist: this.playlist,
532
533 getCurrentPosition: () => this.currentPlaylistElement.position,
534
535 onItemClicked: (videoPlaylistElement: VideoPlaylistElement) => {
536 this.currentPlaylistElement = videoPlaylistElement
537
538 this.loadVideoAndBuildPlayer(this.currentPlaylistElement.video.uuid)
539 .catch(err => console.error(err))
540 }
541 }
542 : undefined
543
544 const options: PeertubePlayerManagerOptions = {
545 common: {
546 // Autoplay in playlist mode
547 autoplay: alreadyHadPlayer ? true : this.autoplay,
548
549 controls: this.controls,
550 controlBar: this.controlBar,
551
552 muted: this.muted,
553 loop: this.loop,
554
555 p2pEnabled: this.p2pEnabled,
556
557 captions: videoCaptions.length !== 0,
558 subtitle: this.subtitle,
559 191
560 startTime: this.playlist ? this.currentPlaylistElement.startTimestamp : this.startTime, 192 const PlayerManager: typeof PeertubePlayerManager = PeertubePlayerManagerModule.PeertubePlayerManager
561 stopTime: this.playlist ? this.currentPlaylistElement.stopTimestamp : this.stopTime,
562 193
563 nextVideo: this.playlist ? () => this.playNextVideo() : undefined, 194 const options = await this.playerManagerOptions.getPlayerOptions({
564 hasNextVideo: this.playlist ? () => !!this.getNextPlaylistElement() : undefined, 195 video,
196 captionsResponse,
197 alreadyHadPlayer,
198 translations,
199 onVideoUpdate: (uuid: string) => this.loadVideoAndBuildPlayer(uuid),
565 200
566 previousVideo: this.playlist ? () => this.playPreviousVideo() : undefined, 201 playlistTracker: this.playlistTracker,
567 hasPreviousVideo: this.playlist ? () => !!this.getPreviousPlaylistElement() : undefined, 202 playNextPlaylistVideo: () => this.playNextPlaylistVideo(),
203 playPreviousPlaylistVideo: () => this.playPreviousPlaylistVideo(),
568 204
569 playlist: playlistPlugin, 205 live
570 206 })
571 videoCaptions,
572 inactivityTimeout: 2500,
573 videoViewUrl: this.getVideoUrl(videoInfo.uuid) + '/views',
574 videoShortUUID: videoInfo.shortUUID,
575 videoUUID: videoInfo.uuid,
576
577 isLive: videoInfo.isLive,
578 liveOptions,
579
580 playerElement: this.playerElement,
581 onPlayerElementChange: (element: HTMLVideoElement) => {
582 this.playerElement = element
583 },
584
585 videoDuration: videoInfo.duration,
586 enableHotkeys: true,
587 peertubeLink: this.peertubeLink,
588 poster: window.location.origin + videoInfo.previewPath,
589 theaterButton: false,
590
591 serverUrl: window.location.origin,
592 language: navigator.language,
593 embedUrl: window.location.origin + videoInfo.embedPath,
594 embedTitle: videoInfo.name,
595
596 errorNotifier: () => {
597 // Empty, we don't have a notifier in the embed
598 }
599 },
600
601 webtorrent: {
602 videoFiles: videoInfo.files
603 },
604
605 pluginsManager: this.pluginsManager
606 }
607
608 if (this.mode === 'p2p-media-loader') {
609 const hlsPlaylist = videoInfo.streamingPlaylists.find(p => p.type === VideoStreamingPlaylistType.HLS)
610
611 Object.assign(options, {
612 p2pMediaLoader: {
613 playlistUrl: hlsPlaylist.playlistUrl,
614 segmentsSha256Url: hlsPlaylist.segmentsSha256Url,
615 redundancyBaseUrls: hlsPlaylist.redundancies.map(r => r.baseUrl),
616 trackerAnnounce: videoInfo.trackerUrls,
617 videoFiles: hlsPlaylist.files
618 } as P2PMediaLoaderOptions
619 })
620 }
621 207
622 this.player = await PeertubePlayerManager.initialize(this.mode, options, (player: videojs.Player) => { 208 this.player = await PlayerManager.initialize(this.playerManagerOptions.getMode(), options, (player: videojs.Player) => {
623 this.player = player 209 this.player = player
624 }) 210 })
625 211
626 this.player.on('customError', (event: any, data: any) => this.handleError(data.err, serverTranslations)) 212 this.player.on('customError', (event: any, data: any) => {
213 const message = data?.err?.message || ''
214 if (!message.includes('from xs param')) return
215
216 this.player.dispose()
217 this.playerHTML.removePlayerElement()
218 this.playerHTML.displayError('This video is not available because the remote instance is not responding.', translations)
219 })
627 220
628 window['videojsPlayer'] = this.player 221 window['videojsPlayer'] = this.player
629 222
630 this.buildCSS() 223 this.buildCSS()
631 224 this.buildPlayerDock(video)
632 this.buildDock(videoInfo)
633
634 this.initializeApi() 225 this.initializeApi()
635 226
636 this.removePlaceholder() 227 this.playerHTML.removePlaceholder()
637 228
638 if (this.isPlaylistEmbed()) { 229 if (this.isPlaylistEmbed()) {
639 await this.buildPlaylistManager() 230 await this.buildPlayerPlaylistUpnext()
640 231
641 this.player.playlist().updateSelected() 232 this.player.playlist().updateSelected()
642 233
643 this.player.on('stopped', () => { 234 this.player.on('stopped', () => {
644 this.playNextVideo() 235 this.playNextPlaylistVideo()
645 }) 236 })
646 } 237 }
647 238
648 this.pluginsManager.runHook('action:embed.player.loaded', undefined, { player: this.player, videojs, video: videoInfo }) 239 this.peertubePlugin.getPluginsManager().runHook('action:embed.player.loaded', undefined, { player: this.player, videojs, video })
649 }
650
651 private async initCore () {
652 if (this.userTokens) this.setHeadersFromTokens()
653
654 this.translationsPromise = TranslationsManager.getServerTranslations(window.location.origin, navigator.language)
655 this.PeertubePlayerManagerModulePromise = import('../../assets/player/peertube-player-manager')
656 240
657 let videoId: string 241 if (video.isLive) {
242 this.liveManager.displayInfoAndListenForChanges({
243 video,
244 translations,
245 onPublishedVideo: () => {
246 this.liveManager.stopListeningForChanges(video)
247 this.loadVideoAndBuildPlayer(video.uuid)
248 }
249 })
250 }
251 }
658 252
659 if (this.isPlaylistEmbed()) { 253 private resetPlayerElement () {
660 const playlistId = this.getResourceId() 254 let alreadyHadPlayer = false
661 const res = await this.loadPlaylist(playlistId)
662 if (!res) return undefined
663 255
664 this.playlist = await res.playlistResponse.json() 256 if (this.player) {
257 this.player.dispose()
258 alreadyHadPlayer = true
259 }
665 260
666 const playlistElementResult = await res.videosResponse.json() 261 const playerElement = document.createElement('video')
667 this.playlistElements = await this.loadAllPlaylistVideos(playlistId, playlistElementResult) 262 playerElement.className = 'video-js vjs-peertube-skin'
263 playerElement.setAttribute('playsinline', 'true')
668 264
669 const params = new URL(window.location.toString()).searchParams 265 this.playerHTML.setPlayerElement(playerElement)
670 const playlistPositionParam = this.getParamString(params, 'playlistPosition') 266 this.playerHTML.addPlayerElementToDOM()
671
672 let position = 1
673
674 if (playlistPositionParam) {
675 position = parseInt(playlistPositionParam + '', 10)
676 }
677
678 this.currentPlaylistElement = this.playlistElements.find(e => e.position === position)
679 if (!this.currentPlaylistElement || !this.currentPlaylistElement.video) {
680 console.error('Current playlist element is not valid.', this.currentPlaylistElement)
681 this.currentPlaylistElement = this.getNextPlaylistElement()
682 }
683
684 if (!this.currentPlaylistElement) {
685 console.error('This playlist does not have any valid element.')
686 const serverTranslations = await this.translationsPromise
687 this.playlistFetchError(serverTranslations)
688 return
689 }
690
691 videoId = this.currentPlaylistElement.video.uuid
692 } else {
693 videoId = this.getResourceId()
694 }
695 267
696 return this.loadVideoAndBuildPlayer(videoId) 268 return alreadyHadPlayer
697 } 269 }
698 270
699 private handleError (err: Error, translations?: { [ id: string ]: string }) { 271 private async buildPlayerPlaylistUpnext () {
700 if (err.message.includes('from xs param')) { 272 const translations = await this.translationsPromise
701 this.player.dispose() 273
702 this.playerElement = null 274 this.player.upnext({
703 this.displayError('This video is not available because the remote instance is not responding.', translations) 275 timeout: 10000, // 10s
704 } 276 headText: peertubeTranslate('Up Next', translations),
277 cancelText: peertubeTranslate('Cancel', translations),
278 suspendedText: peertubeTranslate('Autoplay is suspended', translations),
279 getTitle: () => this.playlistTracker.nextVideoTitle(),
280 next: () => this.playNextPlaylistVideo(),
281 condition: () => !!this.playlistTracker.getNextPlaylistElement(),
282 suspended: () => false
283 })
705 } 284 }
706 285
707 private buildDock (videoInfo: VideoDetails) { 286 private buildPlayerDock (videoInfo: VideoDetails) {
708 if (!this.controls) return 287 if (!this.playerManagerOptions.hasControls()) return
709 288
710 // On webtorrent fallback, player may have been disposed 289 // On webtorrent fallback, player may have been disposed
711 if (!this.player.player_) return 290 if (!this.player.player_) return
712 291
713 const title = this.title ? videoInfo.name : undefined 292 const title = this.playerManagerOptions.hasTitle()
714 const description = this.warningTitle && this.p2pEnabled 293 ? videoInfo.name
294 : undefined
295
296 const description = this.playerManagerOptions.hasWarningTitle() && this.playerManagerOptions.hasP2PEnabled()
715 ? '<span class="text">' + peertubeTranslate('Watching this video may reveal your IP address to others.') + '</span>' 297 ? '<span class="text">' + peertubeTranslate('Watching this video may reveal your IP address to others.') + '</span>'
716 : undefined 298 : undefined
717 299
300 if (!title && !description) return
301
718 const availableAvatars = videoInfo.channel.avatars.filter(a => a.width < 50) 302 const availableAvatars = videoInfo.channel.avatars.filter(a => a.width < 50)
719 const avatar = availableAvatars.length !== 0 303 const avatar = availableAvatars.length !== 0
720 ? availableAvatars[0] 304 ? availableAvatars[0]
721 : undefined 305 : undefined
722 306
723 if (title || description) { 307 this.player.peertubeDock({
724 this.player.peertubeDock({ 308 title,
725 title, 309 description,
726 description, 310 avatarUrl: title && avatar
727 avatarUrl: title && avatar 311 ? avatar.path
728 ? avatar.path 312 : undefined
729 : undefined 313 })
730 })
731 }
732 } 314 }
733 315
734 private buildCSS () { 316 private buildCSS () {
735 const body = document.getElementById('custom-css') 317 const body = document.getElementById('custom-css')
736 318
737 if (this.bigPlayBackgroundColor) { 319 if (this.playerManagerOptions.hasBigPlayBackgroundColor()) {
738 body.style.setProperty('--embedBigPlayBackgroundColor', this.bigPlayBackgroundColor) 320 body.style.setProperty('--embedBigPlayBackgroundColor', this.playerManagerOptions.getBigPlayBackgroundColor())
739 } 321 }
740 322
741 if (this.foregroundColor) { 323 if (this.playerManagerOptions.hasForegroundColor()) {
742 body.style.setProperty('--embedForegroundColor', this.foregroundColor) 324 body.style.setProperty('--embedForegroundColor', this.playerManagerOptions.getForegroundColor())
743 } 325 }
744 } 326 }
745 327
746 private async buildCaptions (serverTranslations: any, captionsResponse: Response): Promise<VideoJSCaption[]> { 328 // ---------------------------------------------------------------------------
747 if (captionsResponse.ok) {
748 const { data } = await captionsResponse.json()
749
750 return data.map((c: VideoCaption) => ({
751 label: peertubeTranslate(c.language.label, serverTranslations),
752 language: c.language.id,
753 src: window.location.origin + c.captionPath
754 }))
755 }
756
757 return []
758 }
759
760 private buildPlaceholder (video: VideoDetails) {
761 const placeholder = this.getPlaceholderElement()
762
763 const url = window.location.origin + video.previewPath
764 placeholder.style.backgroundImage = `url("${url}")`
765 placeholder.style.display = 'block'
766 }
767
768 private removePlaceholder () {
769 const placeholder = this.getPlaceholderElement()
770 placeholder.style.display = 'none'
771 }
772
773 private getPlaceholderElement () {
774 return document.getElementById('placeholder-preview')
775 }
776
777 private getHeaderTokenValue () {
778 return `${this.userTokens.tokenType} ${this.userTokens.accessToken}`
779 }
780
781 private setHeadersFromTokens () {
782 this.headers.set('Authorization', this.getHeaderTokenValue())
783 }
784
785 private removeTokensFromHeaders () {
786 this.headers.delete('Authorization')
787 }
788 329
789 private getResourceId () { 330 private getResourceId () {
790 const urlParts = window.location.pathname.split('/') 331 const urlParts = window.location.pathname.split('/')
@@ -794,69 +335,6 @@ export class PeerTubeEmbed {
794 private isPlaylistEmbed () { 335 private isPlaylistEmbed () {
795 return window.location.pathname.split('/')[1] === 'video-playlists' 336 return window.location.pathname.split('/')[1] === 'video-playlists'
796 } 337 }
797
798 private loadPlugins (translations?: { [ id: string ]: string }) {
799 this.pluginsManager = new PluginsManager({
800 peertubeHelpersFactory: pluginInfo => this.buildPeerTubeHelpers(pluginInfo, translations)
801 })
802
803 this.pluginsManager.loadPluginsList(this.config)
804
805 return this.pluginsManager.ensurePluginsAreLoaded('embed')
806 }
807
808 private buildPeerTubeHelpers (pluginInfo: PluginInfo, translations?: { [ id: string ]: string }): RegisterClientHelpers {
809 const unimplemented = () => {
810 throw new Error('This helper is not implemented in embed.')
811 }
812
813 return {
814 getBaseStaticRoute: unimplemented,
815 getBaseRouterRoute: unimplemented,
816 getBasePluginClientPath: unimplemented,
817
818 getSettings: () => {
819 const url = this.getPluginUrl() + '/' + pluginInfo.plugin.npmName + '/public-settings'
820
821 return this.refreshFetch(url, { headers: this.headers })
822 .then(res => res.json())
823 .then((obj: PublicServerSetting) => obj.publicSettings)
824 },
825
826 isLoggedIn: () => !!this.userTokens,
827 getAuthHeader: () => {
828 if (!this.userTokens) return undefined
829
830 return { Authorization: this.getHeaderTokenValue() }
831 },
832
833 notifier: {
834 info: unimplemented,
835 error: unimplemented,
836 success: unimplemented
837 },
838
839 showModal: unimplemented,
840
841 getServerConfig: unimplemented,
842
843 markdownRenderer: {
844 textMarkdownToHTML: unimplemented,
845 enhancedMarkdownToHTML: unimplemented
846 },
847
848 translate: (value: string) => Promise.resolve(peertubeTranslate(value, translations))
849 }
850 }
851
852 private isP2PEnabled (video: Video) {
853 const userP2PEnabled = getBoolOrDefault(
854 peertubeLocalStorage.getItem(UserLocalStorageKeys.P2P_ENABLED),
855 this.config.defaults.p2p.embed.enabled
856 )
857
858 return isP2PEnabled(video, this.config, userP2PEnabled)
859 }
860} 338}
861 339
862PeerTubeEmbed.main() 340PeerTubeEmbed.main()
diff --git a/client/src/standalone/videos/shared/auth-http.ts b/client/src/standalone/videos/shared/auth-http.ts
new file mode 100644
index 000000000..0356ab8a6
--- /dev/null
+++ b/client/src/standalone/videos/shared/auth-http.ts
@@ -0,0 +1,105 @@
1import { HttpStatusCode, OAuth2ErrorCode, UserRefreshToken } from '../../../../../shared/models'
2import { objectToUrlEncoded, UserTokens } from '../../../root-helpers'
3import { peertubeLocalStorage } from '../../../root-helpers/peertube-web-storage'
4
5export class AuthHTTP {
6 private readonly LOCAL_STORAGE_OAUTH_CLIENT_KEYS = {
7 CLIENT_ID: 'client_id',
8 CLIENT_SECRET: 'client_secret'
9 }
10
11 private userTokens: UserTokens
12
13 private headers = new Headers()
14
15 constructor () {
16 this.userTokens = UserTokens.getUserTokens(peertubeLocalStorage)
17
18 if (this.userTokens) this.setHeadersFromTokens()
19 }
20
21 fetch (url: string, { optionalAuth }: { optionalAuth: boolean }) {
22 const refreshFetchOptions = optionalAuth
23 ? { headers: this.headers }
24 : {}
25
26 return this.refreshFetch(url.toString(), refreshFetchOptions)
27 }
28
29 getHeaderTokenValue () {
30 return `${this.userTokens.tokenType} ${this.userTokens.accessToken}`
31 }
32
33 isLoggedIn () {
34 return !!this.userTokens
35 }
36
37 private refreshFetch (url: string, options?: RequestInit) {
38 return fetch(url, options)
39 .then((res: Response) => {
40 if (res.status !== HttpStatusCode.UNAUTHORIZED_401) return res
41
42 const refreshingTokenPromise = new Promise<void>((resolve, reject) => {
43 const clientId: string = peertubeLocalStorage.getItem(this.LOCAL_STORAGE_OAUTH_CLIENT_KEYS.CLIENT_ID)
44 const clientSecret: string = peertubeLocalStorage.getItem(this.LOCAL_STORAGE_OAUTH_CLIENT_KEYS.CLIENT_SECRET)
45
46 const headers = new Headers()
47 headers.set('Content-Type', 'application/x-www-form-urlencoded')
48
49 const data = {
50 refresh_token: this.userTokens.refreshToken,
51 client_id: clientId,
52 client_secret: clientSecret,
53 response_type: 'code',
54 grant_type: 'refresh_token'
55 }
56
57 fetch('/api/v1/users/token', {
58 headers,
59 method: 'POST',
60 body: objectToUrlEncoded(data)
61 }).then(res => {
62 if (res.status === HttpStatusCode.UNAUTHORIZED_401) return undefined
63
64 return res.json()
65 }).then((obj: UserRefreshToken & { code?: OAuth2ErrorCode }) => {
66 if (!obj || obj.code === OAuth2ErrorCode.INVALID_GRANT) {
67 UserTokens.flushLocalStorage(peertubeLocalStorage)
68 this.removeTokensFromHeaders()
69
70 return resolve()
71 }
72
73 this.userTokens.accessToken = obj.access_token
74 this.userTokens.refreshToken = obj.refresh_token
75 UserTokens.saveToLocalStorage(peertubeLocalStorage, this.userTokens)
76
77 this.setHeadersFromTokens()
78
79 resolve()
80 }).catch((refreshTokenError: any) => {
81 reject(refreshTokenError)
82 })
83 })
84
85 return refreshingTokenPromise
86 .catch(() => {
87 UserTokens.flushLocalStorage(peertubeLocalStorage)
88
89 this.removeTokensFromHeaders()
90 }).then(() => fetch(url, {
91 ...options,
92
93 headers: this.headers
94 }))
95 })
96 }
97
98 private setHeadersFromTokens () {
99 this.headers.set('Authorization', this.getHeaderTokenValue())
100 }
101
102 private removeTokensFromHeaders () {
103 this.headers.delete('Authorization')
104 }
105}
diff --git a/client/src/standalone/videos/shared/index.ts b/client/src/standalone/videos/shared/index.ts
new file mode 100644
index 000000000..928b8e270
--- /dev/null
+++ b/client/src/standalone/videos/shared/index.ts
@@ -0,0 +1,9 @@
1export * from './auth-http'
2export * from './peertube-plugin'
3export * from './live-manager'
4export * from './player-html'
5export * from './player-manager-options'
6export * from './playlist-fetcher'
7export * from './playlist-tracker'
8export * from './translations'
9export * from './video-fetcher'
diff --git a/client/src/standalone/videos/shared/live-manager.ts b/client/src/standalone/videos/shared/live-manager.ts
new file mode 100644
index 000000000..422d39793
--- /dev/null
+++ b/client/src/standalone/videos/shared/live-manager.ts
@@ -0,0 +1,69 @@
1import { Socket } from 'socket.io-client'
2import { LiveVideoEventPayload, VideoDetails, VideoState } from '../../../../../shared/models'
3import { PlayerHTML } from './player-html'
4import { Translations } from './translations'
5
6export class LiveManager {
7 private liveSocket: Socket
8
9 constructor (
10 private readonly playerHTML: PlayerHTML
11 ) {
12
13 }
14
15 async displayInfoAndListenForChanges (options: {
16 video: VideoDetails
17 translations: Translations
18 onPublishedVideo: () => any
19 }) {
20 const { video, onPublishedVideo } = options
21
22 this.displayAppropriateInfo(options)
23
24 if (!this.liveSocket) {
25 const io = (await import('socket.io-client')).io
26 this.liveSocket = io(window.location.origin + '/live-videos')
27 }
28
29 this.liveSocket.on('state-change', (payload: LiveVideoEventPayload) => {
30 if (payload.state === VideoState.PUBLISHED) {
31 this.playerHTML.removeInformation()
32 onPublishedVideo()
33 return
34 }
35 })
36
37 this.liveSocket.emit('subscribe', { videoId: video.id })
38 }
39
40 stopListeningForChanges (video: VideoDetails) {
41 this.liveSocket.emit('unsubscribe', { videoId: video.id })
42 }
43
44 private displayAppropriateInfo (options: {
45 video: VideoDetails
46 translations: Translations
47 }) {
48 const { video, translations } = options
49
50 if (video.state.id === VideoState.WAITING_FOR_LIVE) {
51 this.displayWaitingForLiveInfo(translations)
52 return
53 }
54
55 if (video.state.id === VideoState.LIVE_ENDED) {
56 this.displayEndedLiveInfo(translations)
57 return
58 }
59 }
60
61 private displayWaitingForLiveInfo (translations: Translations) {
62 this.playerHTML.displayInformation('This live has not started yet.', translations)
63 }
64
65 private displayEndedLiveInfo (translations: Translations) {
66 this.playerHTML.displayInformation('This live has ended.', translations)
67
68 }
69}
diff --git a/client/src/standalone/videos/shared/peertube-plugin.ts b/client/src/standalone/videos/shared/peertube-plugin.ts
new file mode 100644
index 000000000..968854ce8
--- /dev/null
+++ b/client/src/standalone/videos/shared/peertube-plugin.ts
@@ -0,0 +1,85 @@
1import { peertubeTranslate } from '../../../../../shared/core-utils/i18n'
2import { HTMLServerConfig, PublicServerSetting } from '../../../../../shared/models'
3import { PluginInfo, PluginsManager } from '../../../root-helpers'
4import { RegisterClientHelpers } from '../../../types'
5import { AuthHTTP } from './auth-http'
6import { Translations } from './translations'
7
8export class PeerTubePlugin {
9
10 private pluginsManager: PluginsManager
11
12 constructor (private readonly http: AuthHTTP) {
13
14 }
15
16 loadPlugins (config: HTMLServerConfig, translations?: Translations) {
17 this.pluginsManager = new PluginsManager({
18 peertubeHelpersFactory: pluginInfo => this.buildPeerTubeHelpers({
19 pluginInfo,
20 translations
21 })
22 })
23
24 this.pluginsManager.loadPluginsList(config)
25
26 return this.pluginsManager.ensurePluginsAreLoaded('embed')
27 }
28
29 getPluginsManager () {
30 return this.pluginsManager
31 }
32
33 private buildPeerTubeHelpers (options: {
34 pluginInfo: PluginInfo
35 translations?: Translations
36 }): RegisterClientHelpers {
37 const { pluginInfo, translations } = options
38
39 const unimplemented = () => {
40 throw new Error('This helper is not implemented in embed.')
41 }
42
43 return {
44 getBaseStaticRoute: unimplemented,
45 getBaseRouterRoute: unimplemented,
46 getBasePluginClientPath: unimplemented,
47
48 getSettings: () => {
49 const url = this.getPluginUrl() + '/' + pluginInfo.plugin.npmName + '/public-settings'
50
51 return this.http.fetch(url, { optionalAuth: true })
52 .then(res => res.json())
53 .then((obj: PublicServerSetting) => obj.publicSettings)
54 },
55
56 isLoggedIn: () => this.http.isLoggedIn(),
57 getAuthHeader: () => {
58 if (!this.http.isLoggedIn()) return undefined
59
60 return { Authorization: this.http.getHeaderTokenValue() }
61 },
62
63 notifier: {
64 info: unimplemented,
65 error: unimplemented,
66 success: unimplemented
67 },
68
69 showModal: unimplemented,
70
71 getServerConfig: unimplemented,
72
73 markdownRenderer: {
74 textMarkdownToHTML: unimplemented,
75 enhancedMarkdownToHTML: unimplemented
76 },
77
78 translate: (value: string) => Promise.resolve(peertubeTranslate(value, translations))
79 }
80 }
81
82 private getPluginUrl () {
83 return window.location.origin + '/api/v1/plugins'
84 }
85}
diff --git a/client/src/standalone/videos/shared/player-html.ts b/client/src/standalone/videos/shared/player-html.ts
new file mode 100644
index 000000000..eb6324ac7
--- /dev/null
+++ b/client/src/standalone/videos/shared/player-html.ts
@@ -0,0 +1,91 @@
1import { peertubeTranslate } from '../../../../../shared/core-utils/i18n'
2import { VideoDetails } from '../../../../../shared/models'
3import { Translations } from './translations'
4
5export class PlayerHTML {
6 private readonly wrapperElement: HTMLElement
7
8 private playerElement: HTMLVideoElement
9 private informationElement: HTMLDivElement
10
11 constructor (private readonly videoWrapperId: string) {
12 this.wrapperElement = document.getElementById(this.videoWrapperId)
13 }
14
15 getPlayerElement () {
16 return this.playerElement
17 }
18
19 setPlayerElement (playerElement: HTMLVideoElement) {
20 this.playerElement = playerElement
21 }
22
23 removePlayerElement () {
24 this.playerElement = null
25 }
26
27 addPlayerElementToDOM () {
28 this.wrapperElement.appendChild(this.playerElement)
29 }
30
31 displayError (text: string, translations: Translations) {
32 console.error(text)
33
34 // Remove video element
35 if (this.playerElement) {
36 this.removeElement(this.playerElement)
37 this.playerElement = undefined
38 }
39
40 const translatedText = peertubeTranslate(text, translations)
41 const translatedSorry = peertubeTranslate('Sorry', translations)
42
43 document.title = translatedSorry + ' - ' + translatedText
44
45 const errorBlock = document.getElementById('error-block')
46 errorBlock.style.display = 'flex'
47
48 const errorTitle = document.getElementById('error-title')
49 errorTitle.innerHTML = peertubeTranslate('Sorry', translations)
50
51 const errorText = document.getElementById('error-content')
52 errorText.innerHTML = translatedText
53
54 this.wrapperElement.style.display = 'none'
55 }
56
57 buildPlaceholder (video: VideoDetails) {
58 const placeholder = this.getPlaceholderElement()
59
60 const url = window.location.origin + video.previewPath
61 placeholder.style.backgroundImage = `url("${url}")`
62 placeholder.style.display = 'block'
63 }
64
65 removePlaceholder () {
66 const placeholder = this.getPlaceholderElement()
67 placeholder.style.display = 'none'
68 }
69
70 displayInformation (text: string, translations: Translations) {
71 if (this.informationElement) this.removeInformation()
72
73 this.informationElement = document.createElement('div')
74 this.informationElement.className = 'player-information'
75 this.informationElement.innerText = peertubeTranslate(text, translations)
76
77 document.body.appendChild(this.informationElement)
78 }
79
80 removeInformation () {
81 this.removeElement(this.informationElement)
82 }
83
84 private getPlaceholderElement () {
85 return document.getElementById('placeholder-preview')
86 }
87
88 private removeElement (element: HTMLElement) {
89 element.parentElement.removeChild(element)
90 }
91}
diff --git a/client/src/standalone/videos/shared/player-manager-options.ts b/client/src/standalone/videos/shared/player-manager-options.ts
new file mode 100644
index 000000000..144d74319
--- /dev/null
+++ b/client/src/standalone/videos/shared/player-manager-options.ts
@@ -0,0 +1,323 @@
1import { peertubeTranslate } from '../../../../../shared/core-utils/i18n'
2import {
3 HTMLServerConfig,
4 LiveVideo,
5 Video,
6 VideoCaption,
7 VideoDetails,
8 VideoPlaylistElement,
9 VideoStreamingPlaylistType
10} from '../../../../../shared/models'
11import { P2PMediaLoaderOptions, PeertubePlayerManagerOptions, PlayerMode, VideoJSCaption } from '../../../assets/player'
12import {
13 getBoolOrDefault,
14 getParamString,
15 getParamToggle,
16 isP2PEnabled,
17 peertubeLocalStorage,
18 UserLocalStorageKeys
19} from '../../../root-helpers'
20import { PeerTubePlugin } from './peertube-plugin'
21import { PlayerHTML } from './player-html'
22import { PlaylistTracker } from './playlist-tracker'
23import { Translations } from './translations'
24import { VideoFetcher } from './video-fetcher'
25
26export class PlayerManagerOptions {
27 private autoplay: boolean
28
29 private controls: boolean
30 private controlBar: boolean
31
32 private muted: boolean
33 private loop: boolean
34 private subtitle: string
35 private enableApi = false
36 private startTime: number | string = 0
37 private stopTime: number | string
38
39 private title: boolean
40 private warningTitle: boolean
41 private peertubeLink: boolean
42 private p2pEnabled: boolean
43 private bigPlayBackgroundColor: string
44 private foregroundColor: string
45
46 private mode: PlayerMode
47 private scope = 'peertube'
48
49 constructor (
50 private readonly playerHTML: PlayerHTML,
51 private readonly videoFetcher: VideoFetcher,
52 private readonly peertubePlugin: PeerTubePlugin
53 ) {}
54
55 hasAPIEnabled () {
56 return this.enableApi
57 }
58
59 hasAutoplay () {
60 return this.autoplay
61 }
62
63 hasControls () {
64 return this.controls
65 }
66
67 hasTitle () {
68 return this.title
69 }
70
71 hasWarningTitle () {
72 return this.warningTitle
73 }
74
75 hasP2PEnabled () {
76 return !!this.p2pEnabled
77 }
78
79 hasBigPlayBackgroundColor () {
80 return !!this.bigPlayBackgroundColor
81 }
82
83 getBigPlayBackgroundColor () {
84 return this.bigPlayBackgroundColor
85 }
86
87 hasForegroundColor () {
88 return !!this.foregroundColor
89 }
90
91 getForegroundColor () {
92 return this.foregroundColor
93 }
94
95 getMode () {
96 return this.mode
97 }
98
99 getScope () {
100 return this.scope
101 }
102
103 // ---------------------------------------------------------------------------
104
105 loadParams (config: HTMLServerConfig, video: VideoDetails) {
106 try {
107 const params = new URL(window.location.toString()).searchParams
108
109 this.autoplay = getParamToggle(params, 'autoplay', false)
110
111 this.controls = getParamToggle(params, 'controls', true)
112 this.controlBar = getParamToggle(params, 'controlBar', true)
113
114 this.muted = getParamToggle(params, 'muted', undefined)
115 this.loop = getParamToggle(params, 'loop', false)
116 this.title = getParamToggle(params, 'title', true)
117 this.enableApi = getParamToggle(params, 'api', this.enableApi)
118 this.warningTitle = getParamToggle(params, 'warningTitle', true)
119 this.peertubeLink = getParamToggle(params, 'peertubeLink', true)
120 this.p2pEnabled = getParamToggle(params, 'p2p', this.isP2PEnabled(config, video))
121
122 this.scope = getParamString(params, 'scope', this.scope)
123 this.subtitle = getParamString(params, 'subtitle')
124 this.startTime = getParamString(params, 'start')
125 this.stopTime = getParamString(params, 'stop')
126
127 this.bigPlayBackgroundColor = getParamString(params, 'bigPlayBackgroundColor')
128 this.foregroundColor = getParamString(params, 'foregroundColor')
129
130 const modeParam = getParamString(params, 'mode')
131
132 if (modeParam) {
133 if (modeParam === 'p2p-media-loader') this.mode = 'p2p-media-loader'
134 else this.mode = 'webtorrent'
135 } else {
136 if (Array.isArray(video.streamingPlaylists) && video.streamingPlaylists.length !== 0) this.mode = 'p2p-media-loader'
137 else this.mode = 'webtorrent'
138 }
139 } catch (err) {
140 console.error('Cannot get params from URL.', err)
141 }
142 }
143
144 // ---------------------------------------------------------------------------
145
146 async getPlayerOptions (options: {
147 video: VideoDetails
148 captionsResponse: Response
149 live?: LiveVideo
150
151 alreadyHadPlayer: boolean
152
153 translations: Translations
154
155 playlistTracker?: PlaylistTracker
156 playNextPlaylistVideo?: () => any
157 playPreviousPlaylistVideo?: () => any
158 onVideoUpdate?: (uuid: string) => any
159 }) {
160 const {
161 video,
162 captionsResponse,
163 alreadyHadPlayer,
164 translations,
165 playlistTracker,
166 live
167 } = options
168
169 const videoCaptions = await this.buildCaptions(captionsResponse, translations)
170
171 const playerOptions: PeertubePlayerManagerOptions = {
172 common: {
173 // Autoplay in playlist mode
174 autoplay: alreadyHadPlayer ? true : this.autoplay,
175
176 controls: this.controls,
177 controlBar: this.controlBar,
178
179 muted: this.muted,
180 loop: this.loop,
181
182 p2pEnabled: this.p2pEnabled,
183
184 captions: videoCaptions.length !== 0,
185 subtitle: this.subtitle,
186
187 startTime: playlistTracker
188 ? playlistTracker.getCurrentElement().startTimestamp
189 : this.startTime,
190 stopTime: playlistTracker
191 ? playlistTracker.getCurrentElement().stopTimestamp
192 : this.stopTime,
193
194 videoCaptions,
195 inactivityTimeout: 2500,
196 videoViewUrl: this.videoFetcher.getVideoViewsUrl(video.uuid),
197
198 videoShortUUID: video.shortUUID,
199 videoUUID: video.uuid,
200
201 playerElement: this.playerHTML.getPlayerElement(),
202 onPlayerElementChange: (element: HTMLVideoElement) => {
203 this.playerHTML.setPlayerElement(element)
204 },
205
206 videoDuration: video.duration,
207 enableHotkeys: true,
208 peertubeLink: this.peertubeLink,
209 poster: window.location.origin + video.previewPath,
210 theaterButton: false,
211
212 serverUrl: window.location.origin,
213 language: navigator.language,
214 embedUrl: window.location.origin + video.embedPath,
215 embedTitle: video.name,
216
217 errorNotifier: () => {
218 // Empty, we don't have a notifier in the embed
219 },
220
221 ...this.buildLiveOptions(video, live),
222
223 ...this.buildPlaylistOptions(options)
224 },
225
226 webtorrent: {
227 videoFiles: video.files
228 },
229
230 ...this.buildP2PMediaLoaderOptions(video),
231
232 pluginsManager: this.peertubePlugin.getPluginsManager()
233 }
234
235 return playerOptions
236 }
237
238 private buildLiveOptions (video: VideoDetails, live: LiveVideo) {
239 if (!video.isLive) return { isLive: false }
240
241 return {
242 isLive: true,
243 liveOptions: {
244 latencyMode: live.latencyMode
245 }
246 }
247 }
248
249 private buildPlaylistOptions (options: {
250 playlistTracker?: PlaylistTracker
251 playNextPlaylistVideo?: () => any
252 playPreviousPlaylistVideo?: () => any
253 onVideoUpdate?: (uuid: string) => any
254 }) {
255 const { playlistTracker, playNextPlaylistVideo, playPreviousPlaylistVideo, onVideoUpdate } = options
256
257 if (!playlistTracker) return {}
258
259 return {
260 playlist: {
261 elements: playlistTracker.getPlaylistElements(),
262 playlist: playlistTracker.getPlaylist(),
263
264 getCurrentPosition: () => playlistTracker.getCurrentPosition(),
265
266 onItemClicked: (videoPlaylistElement: VideoPlaylistElement) => {
267 playlistTracker.setCurrentElement(videoPlaylistElement)
268
269 onVideoUpdate(videoPlaylistElement.video.uuid)
270 }
271 },
272
273 nextVideo: () => playNextPlaylistVideo(),
274 hasNextVideo: () => playlistTracker.hasNextPlaylistElement(),
275
276 previousVideo: () => playPreviousPlaylistVideo(),
277 hasPreviousVideo: () => playlistTracker.hasPreviousPlaylistElement()
278 }
279 }
280
281 private buildP2PMediaLoaderOptions (video: VideoDetails) {
282 if (this.mode !== 'p2p-media-loader') return {}
283
284 const hlsPlaylist = video.streamingPlaylists.find(p => p.type === VideoStreamingPlaylistType.HLS)
285
286 return {
287 p2pMediaLoader: {
288 playlistUrl: hlsPlaylist.playlistUrl,
289 segmentsSha256Url: hlsPlaylist.segmentsSha256Url,
290 redundancyBaseUrls: hlsPlaylist.redundancies.map(r => r.baseUrl),
291 trackerAnnounce: video.trackerUrls,
292 videoFiles: hlsPlaylist.files
293 } as P2PMediaLoaderOptions
294 }
295 }
296
297 // ---------------------------------------------------------------------------
298
299 private async buildCaptions (captionsResponse: Response, translations: Translations): Promise<VideoJSCaption[]> {
300 if (captionsResponse.ok) {
301 const { data } = await captionsResponse.json()
302
303 return data.map((c: VideoCaption) => ({
304 label: peertubeTranslate(c.language.label, translations),
305 language: c.language.id,
306 src: window.location.origin + c.captionPath
307 }))
308 }
309
310 return []
311 }
312
313 // ---------------------------------------------------------------------------
314
315 private isP2PEnabled (config: HTMLServerConfig, video: Video) {
316 const userP2PEnabled = getBoolOrDefault(
317 peertubeLocalStorage.getItem(UserLocalStorageKeys.P2P_ENABLED),
318 config.defaults.p2p.embed.enabled
319 )
320
321 return isP2PEnabled(video, config, userP2PEnabled)
322 }
323}
diff --git a/client/src/standalone/videos/shared/playlist-fetcher.ts b/client/src/standalone/videos/shared/playlist-fetcher.ts
new file mode 100644
index 000000000..a7e72c177
--- /dev/null
+++ b/client/src/standalone/videos/shared/playlist-fetcher.ts
@@ -0,0 +1,72 @@
1import { HttpStatusCode, ResultList, VideoPlaylistElement } from '../../../../../shared/models'
2import { AuthHTTP } from './auth-http'
3
4export class PlaylistFetcher {
5
6 constructor (private readonly http: AuthHTTP) {
7
8 }
9
10 async loadPlaylist (playlistId: string) {
11 const playlistPromise = this.loadPlaylistInfo(playlistId)
12 const playlistElementsPromise = this.loadPlaylistElements(playlistId)
13
14 let playlistResponse: Response
15 let isResponseOk: boolean
16
17 try {
18 playlistResponse = await playlistPromise
19 isResponseOk = playlistResponse.status === HttpStatusCode.OK_200
20 } catch (err) {
21 console.error(err)
22 isResponseOk = false
23 }
24
25 if (!isResponseOk) {
26 if (playlistResponse?.status === HttpStatusCode.NOT_FOUND_404) {
27 throw new Error('This playlist does not exist.')
28 }
29
30 throw new Error('We cannot fetch the playlist. Please try again later.')
31 }
32
33 return { playlistResponse, videosResponse: await playlistElementsPromise }
34 }
35
36 async loadAllPlaylistVideos (playlistId: string, baseResult: ResultList<VideoPlaylistElement>) {
37 let elements = baseResult.data
38 let total = baseResult.total
39 let i = 0
40
41 while (total > elements.length && i < 10) {
42 const result = await this.loadPlaylistElements(playlistId, elements.length)
43
44 const json = await result.json()
45 total = json.total
46
47 elements = elements.concat(json.data)
48 i++
49 }
50
51 if (i === 10) {
52 console.error('Cannot fetch all playlists elements, there are too many!')
53 }
54
55 return elements
56 }
57
58 private loadPlaylistInfo (playlistId: string): Promise<Response> {
59 return this.http.fetch(this.getPlaylistUrl(playlistId), { optionalAuth: true })
60 }
61
62 private loadPlaylistElements (playlistId: string, start = 0): Promise<Response> {
63 const url = new URL(this.getPlaylistUrl(playlistId) + '/videos')
64 url.search = new URLSearchParams({ start: '' + start, count: '100' }).toString()
65
66 return this.http.fetch(url.toString(), { optionalAuth: true })
67 }
68
69 private getPlaylistUrl (id: string) {
70 return window.location.origin + '/api/v1/video-playlists/' + id
71 }
72}
diff --git a/client/src/standalone/videos/shared/playlist-tracker.ts b/client/src/standalone/videos/shared/playlist-tracker.ts
new file mode 100644
index 000000000..75d10b4e2
--- /dev/null
+++ b/client/src/standalone/videos/shared/playlist-tracker.ts
@@ -0,0 +1,93 @@
1import { VideoPlaylist, VideoPlaylistElement } from '../../../../../shared/models'
2
3export class PlaylistTracker {
4 private currentPlaylistElement: VideoPlaylistElement
5
6 constructor (
7 private readonly playlist: VideoPlaylist,
8 private readonly playlistElements: VideoPlaylistElement[]
9 ) {
10
11 }
12
13 getPlaylist () {
14 return this.playlist
15 }
16
17 getPlaylistElements () {
18 return this.playlistElements
19 }
20
21 hasNextPlaylistElement (position?: number) {
22 return !!this.getNextPlaylistElement(position)
23 }
24
25 getNextPlaylistElement (position?: number): VideoPlaylistElement {
26 if (!position) position = this.currentPlaylistElement.position + 1
27
28 if (position > this.playlist.videosLength) {
29 return undefined
30 }
31
32 const next = this.playlistElements.find(e => e.position === position)
33
34 if (!next || !next.video) {
35 return this.getNextPlaylistElement(position + 1)
36 }
37
38 return next
39 }
40
41 hasPreviousPlaylistElement (position?: number) {
42 return !!this.getPreviousPlaylistElement(position)
43 }
44
45 getPreviousPlaylistElement (position?: number): VideoPlaylistElement {
46 if (!position) position = this.currentPlaylistElement.position - 1
47
48 if (position < 1) {
49 return undefined
50 }
51
52 const prev = this.playlistElements.find(e => e.position === position)
53
54 if (!prev || !prev.video) {
55 return this.getNextPlaylistElement(position - 1)
56 }
57
58 return prev
59 }
60
61 nextVideoTitle () {
62 const next = this.getNextPlaylistElement()
63 if (!next) return ''
64
65 return next.video.name
66 }
67
68 setPosition (position: number) {
69 this.currentPlaylistElement = this.playlistElements.find(e => e.position === position)
70 if (!this.currentPlaylistElement || !this.currentPlaylistElement.video) {
71 console.error('Current playlist element is not valid.', this.currentPlaylistElement)
72 this.currentPlaylistElement = this.getNextPlaylistElement()
73 }
74
75 if (!this.currentPlaylistElement) {
76 throw new Error('This playlist does not have any valid element')
77 }
78 }
79
80 setCurrentElement (playlistElement: VideoPlaylistElement) {
81 this.currentPlaylistElement = playlistElement
82 }
83
84 getCurrentElement () {
85 return this.currentPlaylistElement
86 }
87
88 getCurrentPosition () {
89 if (!this.currentPlaylistElement) return -1
90
91 return this.currentPlaylistElement.position
92 }
93}
diff --git a/client/src/standalone/videos/shared/translations.ts b/client/src/standalone/videos/shared/translations.ts
new file mode 100644
index 000000000..146732495
--- /dev/null
+++ b/client/src/standalone/videos/shared/translations.ts
@@ -0,0 +1,5 @@
1type Translations = { [ id: string ]: string }
2
3export {
4 Translations
5}
diff --git a/client/src/standalone/videos/shared/video-fetcher.ts b/client/src/standalone/videos/shared/video-fetcher.ts
new file mode 100644
index 000000000..e78d38536
--- /dev/null
+++ b/client/src/standalone/videos/shared/video-fetcher.ts
@@ -0,0 +1,63 @@
1import { HttpStatusCode, LiveVideo, VideoDetails } from '../../../../../shared/models'
2import { AuthHTTP } from './auth-http'
3
4export class VideoFetcher {
5
6 constructor (private readonly http: AuthHTTP) {
7
8 }
9
10 async loadVideo (videoId: string) {
11 const videoPromise = this.loadVideoInfo(videoId)
12
13 let videoResponse: Response
14 let isResponseOk: boolean
15
16 try {
17 videoResponse = await videoPromise
18 isResponseOk = videoResponse.status === HttpStatusCode.OK_200
19 } catch (err) {
20 console.error(err)
21
22 isResponseOk = false
23 }
24
25 if (!isResponseOk) {
26 if (videoResponse?.status === HttpStatusCode.NOT_FOUND_404) {
27 throw new Error('This video does not exist.')
28 }
29
30 throw new Error('We cannot fetch the video. Please try again later.')
31 }
32
33 const captionsPromise = this.loadVideoCaptions(videoId)
34
35 return { captionsPromise, videoResponse }
36 }
37
38 loadVideoWithLive (video: VideoDetails) {
39 return this.http.fetch(this.getLiveUrl(video.uuid), { optionalAuth: true })
40 .then(res => res.json())
41 .then((live: LiveVideo) => ({ video, live }))
42 }
43
44 getVideoViewsUrl (videoUUID: string) {
45 return this.getVideoUrl(videoUUID) + '/views'
46 }
47
48 private loadVideoInfo (videoId: string): Promise<Response> {
49 return this.http.fetch(this.getVideoUrl(videoId), { optionalAuth: true })
50 }
51
52 private loadVideoCaptions (videoId: string): Promise<Response> {
53 return this.http.fetch(this.getVideoUrl(videoId) + '/captions', { optionalAuth: true })
54 }
55
56 private getVideoUrl (id: string) {
57 return window.location.origin + '/api/v1/videos/' + id
58 }
59
60 private getLiveUrl (videoId: string) {
61 return window.location.origin + '/api/v1/videos/live/' + videoId
62 }
63}