1
0
Fork 0
mirror of https://github.com/Chocobozzz/PeerTube.git synced 2025-10-03 01:39:37 +02:00

Add ability to customize player settings

This commit is contained in:
Chocobozzz 2025-08-28 14:59:18 +02:00
parent b742dbc0fc
commit 74e97347bb
No known key found for this signature in database
GPG key ID: 583A612D890159BE
133 changed files with 2809 additions and 783 deletions

View file

@ -244,7 +244,7 @@
{ {
"type": "anyComponentStyle", "type": "anyComponentStyle",
"maximumWarning": "6kb", "maximumWarning": "6kb",
"maximumError": "120kb" "maximumError": "140kb"
} }
], ],
"fileReplacements": [ "fileReplacements": [

View file

@ -40,6 +40,7 @@ my-select-videos-sort,
my-select-videos-scope, my-select-videos-scope,
my-select-checkbox, my-select-checkbox,
my-select-options, my-select-options,
my-select-player-theme,
my-select-custom-value { my-select-custom-value {
display: block; display: block;

View file

@ -7,26 +7,32 @@
</div> </div>
<div class="content-col"> <div class="content-col">
<ng-container formGroupName="theme"> <div class="form-group" formGroupName="theme">
<div class="form-group"> <label i18n for="themeDefault">Theme</label>
<label i18n for="themeDefault">Theme</label>
<my-select-options formControlName="default" inputId="themeDefault" [items]="availableThemes"></my-select-options> <my-select-options formControlName="default" inputId="themeDefault" [items]="availableThemes"></my-select-options>
</div> </div>
</ng-container>
<ng-container formGroupName="client"> <ng-container formGroupName="client">
<ng-container formGroupName="videos"> <ng-container formGroupName="videos">
<ng-container formGroupName="miniature"> <div class="form-group" formGroupName="miniature">
<div class="form-group"> <my-peertube-checkbox
<my-peertube-checkbox inputName="clientVideosMiniaturePreferAuthorDisplayName"
inputName="clientVideosMiniaturePreferAuthorDisplayName" formControlName="preferAuthorDisplayName"
formControlName="preferAuthorDisplayName" i18n-labelText
i18n-labelText labelText="Prefer author display name in video miniature"
labelText="Prefer author display name in video miniature" ></my-peertube-checkbox>
></my-peertube-checkbox> </div>
</div> </ng-container>
</ng-container> </ng-container>
<ng-container formGroupName="defaults">
<ng-container formGroupName="player">
<div class="form-group">
<label i18n for="defaultsPlayerTheme">Player Theme</label>
<my-select-player-theme mode="instance" formControlName="theme" inputId="defaultsPlayerTheme"></my-select-player-theme>
</div>
</ng-container> </ng-container>
</ng-container> </ng-container>
</div> </div>

View file

@ -9,7 +9,7 @@ import { PeertubeCheckboxComponent } from '@app/shared/shared-forms/peertube-che
import { SelectCustomValueComponent } from '@app/shared/shared-forms/select/select-custom-value.component' import { SelectCustomValueComponent } from '@app/shared/shared-forms/select/select-custom-value.component'
import { SelectOptionsComponent } from '@app/shared/shared-forms/select/select-options.component' import { SelectOptionsComponent } from '@app/shared/shared-forms/select/select-options.component'
import { objectKeysTyped } from '@peertube/peertube-core-utils' import { objectKeysTyped } from '@peertube/peertube-core-utils'
import { CustomConfig } from '@peertube/peertube-models' import { CustomConfig, PlayerTheme } from '@peertube/peertube-models'
import { capitalizeFirstLetter } from '@root-helpers/string' import { capitalizeFirstLetter } from '@root-helpers/string'
import { ColorPaletteThemeConfig, ThemeCustomizationKey } from '@root-helpers/theme-manager' import { ColorPaletteThemeConfig, ThemeCustomizationKey } from '@root-helpers/theme-manager'
import debug from 'debug' import debug from 'debug'
@ -20,6 +20,7 @@ import { AdminConfigService } from '../../../shared/shared-admin/admin-config.se
import { HelpComponent } from '../../../shared/shared-main/buttons/help.component' import { HelpComponent } from '../../../shared/shared-main/buttons/help.component'
import { AlertComponent } from '../../../shared/shared-main/common/alert.component' import { AlertComponent } from '../../../shared/shared-main/common/alert.component'
import { AdminSaveBarComponent } from '../shared/admin-save-bar.component' import { AdminSaveBarComponent } from '../shared/admin-save-bar.component'
import { SelectPlayerThemeComponent } from '@app/shared/shared-forms/select/select-player-theme.component'
const debugLogger = debug('peertube:config') const debugLogger = debug('peertube:config')
@ -65,6 +66,12 @@ type Form = {
inputBorderRadius: FormControl<string> inputBorderRadius: FormControl<string>
}> }>
}> }>
defaults: FormGroup<{
player: FormGroup<{
theme: FormControl<PlayerTheme>
}>
}>
} }
type FieldType = 'color' | 'radius' type FieldType = 'color' | 'radius'
@ -84,7 +91,8 @@ type FieldType = 'color' | 'radius'
SelectOptionsComponent, SelectOptionsComponent,
HelpComponent, HelpComponent,
PeertubeCheckboxComponent, PeertubeCheckboxComponent,
SelectCustomValueComponent SelectCustomValueComponent,
SelectPlayerThemeComponent
] ]
}) })
export class AdminConfigCustomizationComponent implements OnInit, OnDestroy, CanComponentDeactivate { export class AdminConfigCustomizationComponent implements OnInit, OnDestroy, CanComponentDeactivate {
@ -108,6 +116,7 @@ export class AdminConfigCustomizationComponent implements OnInit, OnDestroy, Can
}[] = [] }[] = []
availableThemes: SelectOptionsItem[] availableThemes: SelectOptionsItem[]
availablePlayerThemes: SelectOptionsItem<PlayerTheme>[] = []
private customizationResetFields = new Set<ThemeCustomizationKey>() private customizationResetFields = new Set<ThemeCustomizationKey>()
private customConfig: CustomConfig private customConfig: CustomConfig
@ -164,6 +173,11 @@ export class AdminConfigCustomizationComponent implements OnInit, OnDestroy, Can
...this.themeService.buildAvailableThemes() ...this.themeService.buildAvailableThemes()
] ]
this.availablePlayerThemes = [
{ id: 'galaxy', label: $localize`Galaxy`, description: $localize`Original theme` },
{ id: 'lucide', label: $localize`Lucide`, description: $localize`A clean and modern theme` }
]
this.buildForm() this.buildForm()
this.subscribeToCustomizationChanges() this.subscribeToCustomizationChanges()
@ -265,6 +279,11 @@ export class AdminConfigCustomizationComponent implements OnInit, OnDestroy, Can
headerBackgroundColor: null, headerBackgroundColor: null,
inputBorderRadius: null inputBorderRadius: null
} }
},
defaults: {
player: {
theme: null
}
} }
} }

View file

@ -7,7 +7,6 @@
</div> </div>
<div class="content-col"> <div class="content-col">
<div class="form-group" formGroupName="instance"> <div class="form-group" formGroupName="instance">
<label i18n id="instanceDefaultClientRouteLabel" for="instanceDefaultClientRoute">Landing page</label> <label i18n id="instanceDefaultClientRouteLabel" for="instanceDefaultClientRoute">Landing page</label>
@ -43,13 +42,14 @@
</div> </div>
<ng-container formGroupName="client"> <ng-container formGroupName="client">
<ng-container formGroupName="menu"> <ng-container formGroupName="menu">
<ng-container formGroupName="login"> <ng-container formGroupName="login">
<div class="form-group"> <div class="form-group">
<my-peertube-checkbox <my-peertube-checkbox
inputName="clientMenuLoginRedirectOnSingleExternalAuth" formControlName="redirectOnSingleExternalAuth" inputName="clientMenuLoginRedirectOnSingleExternalAuth"
i18n-labelText labelText="Redirect users on single external auth when users click on the login button in menu" formControlName="redirectOnSingleExternalAuth"
i18n-labelText
labelText="Redirect users on single external auth when users click on the login button in menu"
> >
<ng-container ngProjectAs="description"> <ng-container ngProjectAs="description">
@if (countExternalAuth() === 0) { @if (countExternalAuth() === 0) {
@ -58,12 +58,11 @@
<span i18n>⚠️ You have multiple external auth plugins enabled</span> <span i18n>⚠️ You have multiple external auth plugins enabled</span>
} }
</ng-container> </ng-container>
</my-peertube-checkbox> </my-peertube-checkbox>
</div> </div>
</ng-container> </ng-container>
</ng-container> </ng-container>
</ng-container> </ng-container>
</div> </div>
</div> </div>
@ -76,20 +75,22 @@
</div> </div>
<div class="content-col"> <div class="content-col">
<ng-container formGroupName="broadcastMessage"> <ng-container formGroupName="broadcastMessage">
<div class="form-group"> <div class="form-group">
<my-peertube-checkbox <my-peertube-checkbox
inputName="broadcastMessageEnabled" formControlName="enabled" inputName="broadcastMessageEnabled"
i18n-labelText labelText="Enable broadcast message" formControlName="enabled"
i18n-labelText
labelText="Enable broadcast message"
></my-peertube-checkbox> ></my-peertube-checkbox>
</div> </div>
<div class="form-group"> <div class="form-group">
<my-peertube-checkbox <my-peertube-checkbox
inputName="broadcastMessageDismissable" formControlName="dismissable" inputName="broadcastMessageDismissable"
i18n-labelText labelText="Allow users to dismiss the broadcast message " formControlName="dismissable"
i18n-labelText
labelText="Allow users to dismiss the broadcast message "
></my-peertube-checkbox> ></my-peertube-checkbox>
</div> </div>
@ -111,31 +112,28 @@
<label i18n for="broadcastMessageMessage">Message</label><my-help helpType="markdownText"></my-help> <label i18n for="broadcastMessageMessage">Message</label><my-help helpType="markdownText"></my-help>
<my-markdown-textarea <my-markdown-textarea
inputId="broadcastMessageMessage" formControlName="message" inputId="broadcastMessageMessage"
[formError]="formErrors.broadcastMessage.message" markdownType="to-unsafe-html" formControlName="message"
[formError]="formErrors.broadcastMessage.message"
markdownType="to-unsafe-html"
></my-markdown-textarea> ></my-markdown-textarea>
<div *ngIf="formErrors.broadcastMessage.message" class="form-error" role="alert">{{ formErrors.broadcastMessage.message }}</div> <div *ngIf="formErrors.broadcastMessage.message" class="form-error" role="alert">{{ formErrors.broadcastMessage.message }}</div>
</div> </div>
</ng-container> </ng-container>
</div> </div>
</div> </div>
<div class="pt-two-cols mt-4"> <!-- new users grid --> <div class="pt-two-cols mt-4">
<!-- new users grid -->
<div class="title-col"> <div class="title-col">
<h2 i18n>NEW USERS</h2> <h2 i18n>NEW USERS</h2>
</div> </div>
<div class="content-col"> <div class="content-col">
<ng-container formGroupName="signup"> <ng-container formGroupName="signup">
<div class="form-group"> <div class="form-group">
<my-peertube-checkbox <my-peertube-checkbox inputName="signupEnabled" formControlName="enabled" i18n-labelText labelText="Enable Signup">
inputName="signupEnabled" formControlName="enabled"
i18n-labelText labelText="Enable Signup"
>
<ng-container ngProjectAs="description"> <ng-container ngProjectAs="description">
<span i18n>⚠️ This functionality requires a lot of attention and extra moderation</span> <span i18n>⚠️ This functionality requires a lot of attention and extra moderation</span>
@ -144,16 +142,22 @@
<ng-container ngProjectAs="extra"> <ng-container ngProjectAs="extra">
<div class="form-group"> <div class="form-group">
<my-peertube-checkbox [ngClass]="getDisabledSignupClass()" <my-peertube-checkbox
inputName="signupRequiresApproval" formControlName="requiresApproval" [ngClass]="getDisabledSignupClass()"
i18n-labelText labelText="Signup requires approval by moderators" inputName="signupRequiresApproval"
formControlName="requiresApproval"
i18n-labelText
labelText="Signup requires approval by moderators"
></my-peertube-checkbox> ></my-peertube-checkbox>
</div> </div>
<div class="form-group"> <div class="form-group">
<my-peertube-checkbox [ngClass]="getDisabledSignupClass()" <my-peertube-checkbox
inputName="signupRequiresEmailVerification" formControlName="requiresEmailVerification" [ngClass]="getDisabledSignupClass()"
i18n-labelText labelText="Signup requires email verification" inputName="signupRequiresEmailVerification"
formControlName="requiresEmailVerification"
i18n-labelText
labelText="Signup requires email verification"
></my-peertube-checkbox> ></my-peertube-checkbox>
</div> </div>
@ -163,8 +167,12 @@
<div class="number-with-unit"> <div class="number-with-unit">
<input <input
type="number" min="-1" id="signupLimit" class="form-control" type="number"
formControlName="limit" [ngClass]="{ 'input-error': formErrors.signup.limit }" min="-1"
id="signupLimit"
class="form-control"
formControlName="limit"
[ngClass]="{ 'input-error': formErrors.signup.limit }"
> >
<span i18n>{form.value.signup.limit, plural, =1 {user} other {users}}</span> <span i18n>{form.value.signup.limit, plural, =1 {user} other {users}}</span>
</div> </div>
@ -179,8 +187,12 @@
<div class="number-with-unit"> <div class="number-with-unit">
<input <input
type="number" min="1" id="signupMinimumAge" class="form-control" type="number"
formControlName="minimumAge" [ngClass]="{ 'input-error': formErrors.signup.minimumAge }" min="1"
id="signupMinimumAge"
class="form-control"
formControlName="minimumAge"
[ngClass]="{ 'input-error': formErrors.signup.minimumAge }"
> >
<span i18n>{form.value.signup.minimumAge, plural, =1 {year old} other {years old}}</span> <span i18n>{form.value.signup.minimumAge, plural, =1 {year old} other {years old}}</span>
</div> </div>
@ -201,7 +213,9 @@
inputId="userVideoQuota" inputId="userVideoQuota"
[items]="getVideoQuotaOptions()" [items]="getVideoQuotaOptions()"
formControlName="videoQuota" formControlName="videoQuota"
i18n-inputSuffix inputSuffix="bytes" inputType="number" i18n-inputSuffix
inputSuffix="bytes"
inputType="number"
[clearable]="false" [clearable]="false"
></my-select-custom-value> ></my-select-custom-value>
@ -218,7 +232,9 @@
inputId="userVideoQuotaDaily" inputId="userVideoQuotaDaily"
[items]="getVideoQuotaDailyOptions()" [items]="getVideoQuotaDailyOptions()"
formControlName="videoQuotaDaily" formControlName="videoQuotaDaily"
i18n-inputSuffix inputSuffix="bytes" inputType="number" i18n-inputSuffix
inputSuffix="bytes"
inputType="number"
[clearable]="false" [clearable]="false"
></my-select-custom-value> ></my-select-custom-value>
@ -228,15 +244,16 @@
<ng-container formGroupName="history"> <ng-container formGroupName="history">
<ng-container formGroupName="videos"> <ng-container formGroupName="videos">
<my-peertube-checkbox <my-peertube-checkbox
inputName="videosHistoryEnabled" formControlName="enabled" inputName="videosHistoryEnabled"
i18n-labelText labelText="Automatically enable video history for a new user" formControlName="enabled"
i18n-labelText
labelText="Automatically enable video history for a new user"
> >
</my-peertube-checkbox> </my-peertube-checkbox>
</ng-container> </ng-container>
</ng-container> </ng-container>
</div> </div>
</ng-container> </ng-container>
</div> </div>
</div> </div>
@ -246,11 +263,8 @@
</div> </div>
<div class="content-col"> <div class="content-col">
<ng-container formGroupName="import"> <ng-container formGroupName="import">
<ng-container formGroupName="videos"> <ng-container formGroupName="videos">
<div class="form-group"> <div class="form-group">
<label i18n for="importConcurrency">Import jobs concurrency</label> <label i18n for="importConcurrency">Import jobs concurrency</label>
<span i18n class="small muted ms-1">allows to import multiple videos in parallel. ⚠️ Requires a PeerTube restart</span> <span i18n class="small muted ms-1">allows to import multiple videos in parallel. ⚠️ Requires a PeerTube restart</span>
@ -265,39 +279,46 @@
<div class="form-group" formGroupName="http"> <div class="form-group" formGroupName="http">
<my-peertube-checkbox <my-peertube-checkbox
inputName="importVideosHttpEnabled" formControlName="enabled" inputName="importVideosHttpEnabled"
i18n-labelText labelText="Allow import with HTTP URL (e.g. YouTube)" formControlName="enabled"
i18n-labelText
labelText="Allow import with HTTP URL (e.g. YouTube)"
> >
<ng-container ngProjectAs="description"> <ng-container ngProjectAs="description">
<span i18n>⚠️ If enabled, we recommend to use <a class="link-primary" href="https://docs.joinpeertube.org/maintain/configuration#security">a HTTP proxy</a> to prevent private URL access from your PeerTube server</span> <span i18n
>⚠️ If enabled, we recommend to use <a class="link-primary" href="https://docs.joinpeertube.org/maintain/configuration#security"
>a HTTP proxy</a> to prevent private URL access from your PeerTube server</span>
</ng-container> </ng-container>
</my-peertube-checkbox> </my-peertube-checkbox>
</div> </div>
<div class="form-group" formGroupName="torrent"> <div class="form-group" formGroupName="torrent">
<my-peertube-checkbox <my-peertube-checkbox
inputName="importVideosTorrentEnabled" formControlName="enabled" inputName="importVideosTorrentEnabled"
i18n-labelText labelText="Allow import with a torrent file or a magnet URI" formControlName="enabled"
i18n-labelText
labelText="Allow import with a torrent file or a magnet URI"
> >
<ng-container ngProjectAs="description"> <ng-container ngProjectAs="description">
<span i18n>⚠️ We don't recommend to enable this feature if you don't trust your users</span> <span i18n>⚠️ We don't recommend to enable this feature if you don't trust your users</span>
</ng-container> </ng-container>
</my-peertube-checkbox> </my-peertube-checkbox>
</div> </div>
</ng-container> </ng-container>
<ng-container formGroupName="videoChannelSynchronization"> <ng-container formGroupName="videoChannelSynchronization">
<div class="form-group"> <div class="form-group">
<my-peertube-checkbox <my-peertube-checkbox
inputName="importSynchronizationEnabled" formControlName="enabled" inputName="importSynchronizationEnabled"
i18n-labelText labelText="Allow channel synchronization with channel of other platforms like YouTube" formControlName="enabled"
i18n-labelText
labelText="Allow channel synchronization with channel of other platforms like YouTube"
> >
<ng-container ngProjectAs="description"> <ng-container ngProjectAs="description">
<span i18n [hidden]="isImportVideosHttpEnabled()"> <span i18n [hidden]="isImportVideosHttpEnabled()">
⛔ You need to allow import with HTTP URL to be able to activate this feature. ⛔ You need to allow import with HTTP URL to be able to activate this feature.
</span> </span>
</ng-container> </ng-container>
</my-peertube-checkbox> </my-peertube-checkbox>
</div> </div>
@ -306,16 +327,21 @@
<div class="number-with-unit"> <div class="number-with-unit">
<input <input
type="number" min="1" id="videoChannelSynchronizationMaxPerUser" class="form-control" type="number"
formControlName="maxPerUser" [ngClass]="{ 'input-error': formErrors['import']['videoChannelSynchronization']['maxPerUser'] }" min="1"
id="videoChannelSynchronizationMaxPerUser"
class="form-control"
formControlName="maxPerUser"
[ngClass]="{ 'input-error': formErrors['import']['videoChannelSynchronization']['maxPerUser'] }"
> >
<span i18n>{form.value.import.videoChannelSynchronization.maxPerUser, plural, =1 {sync} other {syncs}}</span> <span i18n>{form.value.import.videoChannelSynchronization.maxPerUser, plural, =1 {sync} other {syncs}}</span>
</div> </div>
<div *ngIf="formErrors.import.videoChannelSynchronization.maxPerUser" class="form-error" role="alert">{{ formErrors.import.videoChannelSynchronization.maxPerUser }}</div> <div *ngIf="formErrors.import.videoChannelSynchronization.maxPerUser" class="form-error" role="alert">
{{ formErrors.import.videoChannelSynchronization.maxPerUser }}
</div>
</div> </div>
</ng-container> </ng-container>
</ng-container> </ng-container>
</div> </div>
</div> </div>
@ -354,22 +380,21 @@
</div> </div>
<div class="content-col"> <div class="content-col">
<ng-container formGroupName="autoBlacklist"> <ng-container formGroupName="autoBlacklist">
<ng-container formGroupName="videos"> <ng-container formGroupName="videos">
<ng-container formGroupName="ofUsers"> <ng-container formGroupName="ofUsers">
<div class="form-group"> <div class="form-group">
<my-peertube-checkbox <my-peertube-checkbox
inputName="autoBlacklistVideosOfUsersEnabled" formControlName="enabled" inputName="autoBlacklistVideosOfUsersEnabled"
i18n-labelText labelText="Block new videos automatically" formControlName="enabled"
i18n-labelText
labelText="Block new videos automatically"
> >
<ng-container ngProjectAs="description"> <ng-container ngProjectAs="description">
<span i18n>Unless a user is marked as trusted, their videos will stay private until a moderator reviews them.</span> <span i18n>Unless a user is marked as trusted, their videos will stay private until a moderator reviews them.</span>
</ng-container> </ng-container>
</my-peertube-checkbox> </my-peertube-checkbox>
</div> </div>
</ng-container> </ng-container>
</ng-container> </ng-container>
</ng-container> </ng-container>
@ -378,8 +403,10 @@
<ng-container formGroupName="update"> <ng-container formGroupName="update">
<div class="form-group"> <div class="form-group">
<my-peertube-checkbox <my-peertube-checkbox
inputName="videoFileUpdateEnabled" formControlName="enabled" inputName="videoFileUpdateEnabled"
i18n-labelText labelText="Allow users to upload a new version of their video" formControlName="enabled"
i18n-labelText
labelText="Allow users to upload a new version of their video"
> >
</my-peertube-checkbox> </my-peertube-checkbox>
</div> </div>
@ -388,10 +415,7 @@
<ng-container formGroupName="storyboards"> <ng-container formGroupName="storyboards">
<div class="form-group"> <div class="form-group">
<my-peertube-checkbox <my-peertube-checkbox inputName="storyboardsEnabled" formControlName="enabled" i18n-labelText labelText="Enable video storyboards">
inputName="storyboardsEnabled" formControlName="enabled"
i18n-labelText labelText="Enable video storyboards"
>
<ng-container ngProjectAs="description"> <ng-container ngProjectAs="description">
<span i18n>Generate storyboards of local videos using ffmpeg so users can see the video preview in the player while scrubbing the video</span> <span i18n>Generate storyboards of local videos using ffmpeg so users can see the video preview in the player while scrubbing the video</span>
</ng-container> </ng-container>
@ -415,19 +439,19 @@
<ng-container formGroupName="videoTranscription"> <ng-container formGroupName="videoTranscription">
<div class="form-group"> <div class="form-group">
<my-peertube-checkbox <my-peertube-checkbox inputName="videoTranscriptionEnabled" formControlName="enabled" i18n-labelText labelText="Enable video transcription">
inputName="videoTranscriptionEnabled" formControlName="enabled"
i18n-labelText labelText="Enable video transcription"
>
<ng-container ngProjectAs="description"> <ng-container ngProjectAs="description">
<span i18n><a href="https://docs.joinpeertube.org/admin/configuration#automatic-transcription" target="_blank">Automatically create subtitles</a> for uploaded/imported VOD videos</span> <span i18n><a href="https://docs.joinpeertube.org/admin/configuration#automatic-transcription" target="_blank">Automatically create subtitles</a>
for uploaded/imported VOD videos</span>
</ng-container> </ng-container>
<ng-container ngProjectAs="extra"> <ng-container ngProjectAs="extra">
<div class="form-group" formGroupName="remoteRunners" [ngClass]="getTranscriptionRunnerDisabledClass()"> <div class="form-group" formGroupName="remoteRunners" [ngClass]="getTranscriptionRunnerDisabledClass()">
<my-peertube-checkbox <my-peertube-checkbox
inputName="videoTranscriptionRemoteRunnersEnabled" formControlName="enabled" inputName="videoTranscriptionRemoteRunnersEnabled"
i18n-labelText labelText="Enable remote runners for transcription" formControlName="enabled"
i18n-labelText
labelText="Enable remote runners for transcription"
> >
<ng-container ngProjectAs="description"> <ng-container ngProjectAs="description">
<div i18n>Use <a routerLink="/admin/settings/system/runners/runners-list">remote runners</a> to process transcription tasks.</div> <div i18n>Use <a routerLink="/admin/settings/system/runners/runners-list">remote runners</a> to process transcription tasks.</div>
@ -442,7 +466,6 @@
<ng-container formGroupName="defaults"> <ng-container formGroupName="defaults">
<ng-container formGroupName="publish"> <ng-container formGroupName="publish">
<div class="form-group"> <div class="form-group">
<label i18n for="defaultsPublishPrivacy">Default video privacy</label> <label i18n for="defaultsPublishPrivacy">Default video privacy</label>
@ -482,8 +505,12 @@
<div class="number-with-unit"> <div class="number-with-unit">
<input <input
type="number" min="1" id="videoChannelsMaxPerUser" class="form-control" type="number"
formControlName="maxPerUser" [ngClass]="{ 'input-error': formErrors.videoChannels.maxPerUser }" min="1"
id="videoChannelsMaxPerUser"
class="form-control"
formControlName="maxPerUser"
[ngClass]="{ 'input-error': formErrors.videoChannels.maxPerUser }"
> >
<span i18n>{form.value.videoChannels.maxPerUser, plural, =1 {channel} other {channels}}</span> <span i18n>{form.value.videoChannels.maxPerUser, plural, =1 {channel} other {channels}}</span>
</div> </div>
@ -502,12 +529,13 @@
</div> </div>
<div class="content-col"> <div class="content-col">
<ng-container formGroupName="videoComments"> <ng-container formGroupName="videoComments">
<div class="form-group"> <div class="form-group">
<my-peertube-checkbox <my-peertube-checkbox
inputName="videoCommentsAcceptRemoteComments" formControlName="acceptRemoteComments" inputName="videoCommentsAcceptRemoteComments"
i18n-labelText labelText="Accept comments made on remote platforms" formControlName="acceptRemoteComments"
i18n-labelText
labelText="Accept comments made on remote platforms"
> >
<ng-container ngProjectAs="description"> <ng-container ngProjectAs="description">
<span i18n>This setting is not retroactive: current comments from remote platforms will not be deleted</span> <span i18n>This setting is not retroactive: current comments from remote platforms will not be deleted</span>
@ -520,22 +548,25 @@
<ng-container formGroupName="channels"> <ng-container formGroupName="channels">
<div class="form-group"> <div class="form-group">
<my-peertube-checkbox <my-peertube-checkbox
inputName="followersChannelsEnabled" formControlName="enabled" inputName="followersChannelsEnabled"
i18n-labelText labelText="Remote actors can follow channels of your platform" formControlName="enabled"
i18n-labelText
labelText="Remote actors can follow channels of your platform"
> >
<ng-container ngProjectAs="description"> <ng-container ngProjectAs="description">
<span i18n>This setting is not retroactive: current followers of channels of your platform will not be affected</span> <span i18n>This setting is not retroactive: current followers of channels of your platform will not be affected</span>
</ng-container> </ng-container>
</my-peertube-checkbox> </my-peertube-checkbox>
</div> </div>
</ng-container> </ng-container>
<ng-container formGroupName="instance"> <ng-container formGroupName="instance">
<div class="form-group"> <div class="form-group">
<my-peertube-checkbox <my-peertube-checkbox
inputName="followersInstanceEnabled" formControlName="enabled" inputName="followersInstanceEnabled"
i18n-labelText labelText="Remote actors can follow your platform" formControlName="enabled"
i18n-labelText
labelText="Remote actors can follow your platform"
> >
<ng-container ngProjectAs="description"> <ng-container ngProjectAs="description">
<span i18n>This setting is not retroactive: current followers of your platform will not be affected</span> <span i18n>This setting is not retroactive: current followers of your platform will not be affected</span>
@ -545,8 +576,10 @@
<div class="form-group"> <div class="form-group">
<my-peertube-checkbox <my-peertube-checkbox
inputName="followersInstanceManualApproval" formControlName="manualApproval" inputName="followersInstanceManualApproval"
i18n-labelText labelText="Manually approve new followers that follow your platform" formControlName="manualApproval"
i18n-labelText
labelText="Manually approve new followers that follow your platform"
></my-peertube-checkbox> ></my-peertube-checkbox>
</div> </div>
</ng-container> </ng-container>
@ -554,12 +587,13 @@
<ng-container formGroupName="followings"> <ng-container formGroupName="followings">
<ng-container formGroupName="instance"> <ng-container formGroupName="instance">
<ng-container formGroupName="autoFollowBack"> <ng-container formGroupName="autoFollowBack">
<div class="form-group"> <div class="form-group">
<my-peertube-checkbox <my-peertube-checkbox
inputName="followingsInstanceAutoFollowBackEnabled" formControlName="enabled" inputName="followingsInstanceAutoFollowBackEnabled"
i18n-labelText labelText="Automatically follow back followers that follow your platform" formControlName="enabled"
i18n-labelText
labelText="Automatically follow back followers that follow your platform"
> >
<ng-container ngProjectAs="description"> <ng-container ngProjectAs="description">
<span i18n>⚠️ This functionality requires a lot of attention and extra moderation</span> <span i18n>⚠️ This functionality requires a lot of attention and extra moderation</span>
@ -571,14 +605,21 @@
<ng-container formGroupName="autoFollowIndex"> <ng-container formGroupName="autoFollowIndex">
<div class="form-group"> <div class="form-group">
<my-peertube-checkbox <my-peertube-checkbox
inputName="followingsInstanceAutoFollowIndexEnabled" formControlName="enabled" inputName="followingsInstanceAutoFollowIndexEnabled"
i18n-labelText labelText="Automatically follow platforms of a public index" formControlName="enabled"
i18n-labelText
labelText="Automatically follow platforms of a public index"
> >
<ng-container ngProjectAs="description"> <ng-container ngProjectAs="description">
<div i18n>⚠️ This functionality requires a lot of attention and extra moderation.</div> <div i18n>⚠️ This functionality requires a lot of attention and extra moderation.</div>
<span i18n> <span i18n>
See <a class="link-primary" href="https://docs.joinpeertube.org/admin/following-instances#automatically-follow-other-instances" rel="noopener noreferrer" target="_blank">the documentation</a> for more information about the expected URL See <a
class="link-primary"
href="https://docs.joinpeertube.org/admin/following-instances#automatically-follow-other-instances"
rel="noopener noreferrer"
target="_blank"
>the documentation</a> for more information about the expected URL
</span> </span>
</ng-container> </ng-container>
@ -586,19 +627,22 @@
<div [ngClass]="{ 'disabled-checkbox-extra': !isAutoFollowIndexEnabled() }"> <div [ngClass]="{ 'disabled-checkbox-extra': !isAutoFollowIndexEnabled() }">
<label i18n for="followingsInstanceAutoFollowIndexUrl">Index URL</label> <label i18n for="followingsInstanceAutoFollowIndexUrl">Index URL</label>
<input <input
type="text" id="followingsInstanceAutoFollowIndexUrl" class="form-control" type="text"
formControlName="indexUrl" [ngClass]="{ 'input-error': formErrors.followings.instance.autoFollowIndex.indexUrl }" id="followingsInstanceAutoFollowIndexUrl"
class="form-control"
formControlName="indexUrl"
[ngClass]="{ 'input-error': formErrors.followings.instance.autoFollowIndex.indexUrl }"
> >
<div *ngIf="formErrors.followings.instance.autoFollowIndex.indexUrl" class="form-error" role="alert">{{ formErrors.followings.instance.autoFollowIndex.indexUrl }}</div> <div *ngIf="formErrors.followings.instance.autoFollowIndex.indexUrl" class="form-error" role="alert">
{{ formErrors.followings.instance.autoFollowIndex.indexUrl }}
</div>
</div> </div>
</ng-container> </ng-container>
</my-peertube-checkbox> </my-peertube-checkbox>
</div> </div>
</ng-container> </ng-container>
</ng-container> </ng-container>
</ng-container> </ng-container>
</div> </div>
</div> </div>
@ -609,27 +653,34 @@
<div class="content-col"> <div class="content-col">
<ng-container formGroupName="defaults"> <ng-container formGroupName="defaults">
<ng-container formGroupName="player">
<div class="form-group" formGroupName="player"> <div class="form-group">
<my-peertube-checkbox <my-peertube-checkbox
inputName="defaultsPlayerAutoplay" formControlName="autoPlay" inputName="defaultsPlayerAutoplay"
i18n-labelText labelText="Automatically play videos in the player" formControlName="autoPlay"
></my-peertube-checkbox> i18n-labelText
</div> labelText="Automatically play videos in the player"
></my-peertube-checkbox>
</div>
</ng-container>
<ng-container formGroupName="p2p"> <ng-container formGroupName="p2p">
<div class="form-group" formGroupName="webapp"> <div class="form-group" formGroupName="webapp">
<my-peertube-checkbox <my-peertube-checkbox
inputName="defaultsP2PWebappEnabled" formControlName="enabled" inputName="defaultsP2PWebappEnabled"
i18n-labelText labelText="Enable P2P streaming by default on your platform" formControlName="enabled"
i18n-labelText
labelText="Enable P2P streaming by default on your platform"
></my-peertube-checkbox> ></my-peertube-checkbox>
</div> </div>
<div class="form-group" formGroupName="embed"> <div class="form-group" formGroupName="embed">
<my-peertube-checkbox <my-peertube-checkbox
inputName="defaultsP2PEmbedEnabled" formControlName="enabled" inputName="defaultsP2PEmbedEnabled"
i18n-labelText labelText="Enable P2P streaming by default for videos embedded on external websites" formControlName="enabled"
i18n-labelText
labelText="Enable P2P streaming by default for videos embedded on external websites"
></my-peertube-checkbox> ></my-peertube-checkbox>
</div> </div>
</ng-container> </ng-container>
@ -643,14 +694,14 @@
</div> </div>
<div class="content-col"> <div class="content-col">
<ng-container formGroupName="search"> <ng-container formGroupName="search">
<ng-container formGroupName="remoteUri"> <ng-container formGroupName="remoteUri">
<div class="form-group"> <div class="form-group">
<my-peertube-checkbox <my-peertube-checkbox
inputName="searchRemoteUriUsers" formControlName="users" inputName="searchRemoteUriUsers"
i18n-labelText labelText="Allow users to do remote URI/handle search" formControlName="users"
i18n-labelText
labelText="Allow users to do remote URI/handle search"
> >
<ng-container ngProjectAs="description"> <ng-container ngProjectAs="description">
<span i18n>Allow <strong>your users</strong> to look up remote videos/actors that may not be federated with your platform</span> <span i18n>Allow <strong>your users</strong> to look up remote videos/actors that may not be federated with your platform</span>
@ -660,23 +711,21 @@
<div class="form-group"> <div class="form-group">
<my-peertube-checkbox <my-peertube-checkbox
inputName="searchRemoteUriAnonymous" formControlName="anonymous" inputName="searchRemoteUriAnonymous"
i18n-labelText labelText="Allow anonymous to do remote URI/handle search" formControlName="anonymous"
i18n-labelText
labelText="Allow anonymous to do remote URI/handle search"
> >
<ng-container ngProjectAs="description"> <ng-container ngProjectAs="description">
<span i18n>Allow <strong>anonymous users</strong> to look up remote videos/actors that may not be federated with your platform</span> <span i18n>Allow <strong>anonymous users</strong> to look up remote videos/actors that may not be federated with your platform</span>
</ng-container> </ng-container>
</my-peertube-checkbox> </my-peertube-checkbox>
</div> </div>
</ng-container> </ng-container>
<ng-container formGroupName="searchIndex"> <ng-container formGroupName="searchIndex">
<div class="form-group"> <div class="form-group">
<my-peertube-checkbox <my-peertube-checkbox inputName="searchIndexEnabled" formControlName="enabled" i18n-labelText labelText="Enable global search">
inputName="searchIndexEnabled" formControlName="enabled"
i18n-labelText labelText="Enable global search"
>
<ng-container ngProjectAs="description"> <ng-container ngProjectAs="description">
<div i18n>⚠️ This functionality depends heavily on the moderation of platforms followed by the search index you select</div> <div i18n>⚠️ This functionality depends heavily on the moderation of platforms followed by the search index you select</div>
</ng-container> </ng-container>
@ -690,39 +739,44 @@
</div> </div>
<input <input
type="text" id="searchIndexUrl" class="form-control" type="text"
formControlName="url" [ngClass]="{ 'input-error': formErrors.search.searchIndex.url }" id="searchIndexUrl"
class="form-control"
formControlName="url"
[ngClass]="{ 'input-error': formErrors.search.searchIndex.url }"
> >
<div *ngIf="formErrors.search.searchIndex.url" class="form-error" role="alert">{{ formErrors.search.searchIndex.url }}</div> <div *ngIf="formErrors.search.searchIndex.url" class="form-error" role="alert">{{ formErrors.search.searchIndex.url }}</div>
</div> </div>
<div class="mt-3"> <div class="mt-3">
<my-peertube-checkbox [ngClass]="getDisabledSearchIndexClass()" <my-peertube-checkbox
inputName="searchIndexDisableLocalSearch" formControlName="disableLocalSearch" [ngClass]="getDisabledSearchIndexClass()"
i18n-labelText labelText="Disable local search in search bar" inputName="searchIndexDisableLocalSearch"
formControlName="disableLocalSearch"
i18n-labelText
labelText="Disable local search in search bar"
></my-peertube-checkbox> ></my-peertube-checkbox>
</div> </div>
<div class="mt-3"> <div class="mt-3">
<my-peertube-checkbox [ngClass]="getDisabledSearchIndexClass()" <my-peertube-checkbox
inputName="searchIndexIsDefaultSearch" formControlName="isDefaultSearch" [ngClass]="getDisabledSearchIndexClass()"
i18n-labelText labelText="Search bar uses the global search index by default" inputName="searchIndexIsDefaultSearch"
formControlName="isDefaultSearch"
i18n-labelText
labelText="Search bar uses the global search index by default"
> >
<ng-container ngProjectAs="description"> <ng-container ngProjectAs="description">
<span i18n>Otherwise, the local search will be used by default</span> <span i18n>Otherwise, the local search will be used by default</span>
</ng-container> </ng-container>
</my-peertube-checkbox> </my-peertube-checkbox>
</div> </div>
</ng-container> </ng-container>
</my-peertube-checkbox> </my-peertube-checkbox>
</div> </div>
</ng-container> </ng-container>
</ng-container> </ng-container>
</div> </div>
</div> </div>
@ -732,14 +786,10 @@
</div> </div>
<div class="content-col"> <div class="content-col">
<ng-container formGroupName="import"> <ng-container formGroupName="import">
<ng-container formGroupName="users"> <ng-container formGroupName="users">
<div class="form-group"> <div class="form-group">
<my-peertube-checkbox <my-peertube-checkbox inputName="importUsersEnabled" formControlName="enabled" i18n-labelText labelText="Allow your users to import a data archive">
inputName="importUsersEnabled" formControlName="enabled"
i18n-labelText labelText="Allow your users to import a data archive"
>
<ng-container ngProjectAs="description"> <ng-container ngProjectAs="description">
<div i18n>Video quota is checked on import so the user doesn't upload a too big archive file</div> <div i18n>Video quota is checked on import so the user doesn't upload a too big archive file</div>
<div i18n>Video quota (daily quota is not taken into account) is also checked for each video when PeerTube is processing the import</div> <div i18n>Video quota (daily quota is not taken into account) is also checked for each video when PeerTube is processing the import</div>
@ -750,20 +800,14 @@
</ng-container> </ng-container>
<ng-container formGroupName="export"> <ng-container formGroupName="export">
<ng-container formGroupName="users"> <ng-container formGroupName="users">
<div class="form-group"> <div class="form-group">
<my-peertube-checkbox <my-peertube-checkbox inputName="exportUsersEnabled" formControlName="enabled" i18n-labelText labelText="Allow your users to export their data">
inputName="exportUsersEnabled" formControlName="enabled"
i18n-labelText labelText="Allow your users to export their data"
>
<ng-container ngProjectAs="description"> <ng-container ngProjectAs="description">
<span i18n>Users can export their PeerTube data in a .zip for backup or re-import. Only one export at a time is allowed per user</span> <span i18n>Users can export their PeerTube data in a .zip for backup or re-import. Only one export at a time is allowed per user</span>
</ng-container> </ng-container>
<ng-container ngProjectAs="extra"> <ng-container ngProjectAs="extra">
<div class="form-group" [ngClass]="getDisabledExportUsersClass()"> <div class="form-group" [ngClass]="getDisabledExportUsersClass()">
<label i18n id="exportUsersMaxUserVideoQuota" for="exportUsersMaxUserVideoQuota">Max user video quota allowed to generate the export</label> <label i18n id="exportUsersMaxUserVideoQuota" for="exportUsersMaxUserVideoQuota">Max user video quota allowed to generate the export</label>
@ -774,7 +818,9 @@
inputId="exportUsersMaxUserVideoQuota" inputId="exportUsersMaxUserVideoQuota"
[items]="exportMaxUserVideoQuotaOptions" [items]="exportMaxUserVideoQuotaOptions"
formControlName="maxUserVideoQuota" formControlName="maxUserVideoQuota"
i18n-inputSuffix inputSuffix="bytes" inputType="number" i18n-inputSuffix
inputSuffix="bytes"
inputType="number"
[clearable]="false" [clearable]="false"
></my-select-custom-value> ></my-select-custom-value>
@ -784,20 +830,21 @@
<div class="form-group" [ngClass]="getDisabledExportUsersClass()"> <div class="form-group" [ngClass]="getDisabledExportUsersClass()">
<label i18n for="exportUsersExportExpiration">User export expiration</label> <label i18n for="exportUsersExportExpiration">User export expiration</label>
<my-select-options inputId="exportUsersExportExpiration" [items]="exportExpirationOptions" formControlName="exportExpiration"></my-select-options> <my-select-options
inputId="exportUsersExportExpiration"
[items]="exportExpirationOptions"
formControlName="exportExpiration"
></my-select-options>
<div i18n class="mt-1 small muted">The archive file is deleted after this period</div> <div i18n class="mt-1 small muted">The archive file is deleted after this period</div>
<div *ngIf="formErrors.export.users.exportExpiration" class="form-error" role="alert">{{ formErrors.export.users.exportExpiration }}</div> <div *ngIf="formErrors.export.users.exportExpiration" class="form-error" role="alert">{{ formErrors.export.users.exportExpiration }}</div>
</div> </div>
</ng-container> </ng-container>
</my-peertube-checkbox> </my-peertube-checkbox>
</div> </div>
</ng-container> </ng-container>
</ng-container> </ng-container>
</div> </div>
</div> </div>
</form> </form>

View file

@ -24,7 +24,14 @@ import { USER_VIDEO_QUOTA_DAILY_VALIDATOR, USER_VIDEO_QUOTA_VALIDATOR } from '@a
import { FormReactiveService } from '@app/shared/shared-forms/form-reactive.service' import { FormReactiveService } from '@app/shared/shared-forms/form-reactive.service'
import { AlertComponent } from '@app/shared/shared-main/common/alert.component' import { AlertComponent } from '@app/shared/shared-main/common/alert.component'
import { VideoService } from '@app/shared/shared-main/video/video.service' import { VideoService } from '@app/shared/shared-main/video/video.service'
import { BroadcastMessageLevel, CustomConfig, VideoCommentPolicyType, VideoConstant, VideoPrivacyType } from '@peertube/peertube-models' import {
BroadcastMessageLevel,
CustomConfig,
PlayerTheme,
VideoCommentPolicyType,
VideoConstant,
VideoPrivacyType
} from '@peertube/peertube-models'
import { Subscription } from 'rxjs' import { Subscription } from 'rxjs'
import { pairwise } from 'rxjs/operators' import { pairwise } from 'rxjs/operators'
import { SelectOptionsItem } from 'src/types/select-options-item.model' import { SelectOptionsItem } from 'src/types/select-options-item.model'

View file

@ -1,6 +1,7 @@
import { Routes } from '@angular/router' import { Routes } from '@angular/router'
import { AbuseService } from '@app/shared/shared-moderation/abuse.service' import { AbuseService } from '@app/shared/shared-moderation/abuse.service'
import { BlocklistService } from '@app/shared/shared-moderation/blocklist.service' import { BlocklistService } from '@app/shared/shared-moderation/blocklist.service'
import { BulkService } from '@app/shared/shared-moderation/bulk.service'
import { VideoBlockService } from '@app/shared/shared-moderation/video-block.service' import { VideoBlockService } from '@app/shared/shared-moderation/video-block.service'
import { SearchService } from '@app/shared/shared-search/search.service' import { SearchService } from '@app/shared/shared-search/search.service'
import { UserSubscriptionService } from '@app/shared/shared-user-subscription/user-subscription.service' import { UserSubscriptionService } from '@app/shared/shared-user-subscription/user-subscription.service'
@ -8,11 +9,11 @@ import { UserAdminService } from '@app/shared/shared-users/user-admin.service'
import { VideoCommentService } from '@app/shared/shared-video-comment/video-comment.service' import { VideoCommentService } from '@app/shared/shared-video-comment/video-comment.service'
import { LiveVideoService } from '@app/shared/shared-video-live/live-video.service' import { LiveVideoService } from '@app/shared/shared-video-live/live-video.service'
import { VideoPlaylistService } from '@app/shared/shared-video-playlist/video-playlist.service' import { VideoPlaylistService } from '@app/shared/shared-video-playlist/video-playlist.service'
import { PlayerSettingsService } from '@app/shared/shared-video/player-settings.service'
import { VideoStateMessageService } from '@app/shared/shared-video/video-state-message.service'
import { OverviewService } from '../+video-list' import { OverviewService } from '../+video-list'
import { VideoRecommendationService } from './shared' import { VideoRecommendationService } from './shared'
import { VideoWatchComponent } from './video-watch.component' import { VideoWatchComponent } from './video-watch.component'
import { BulkService } from '@app/shared/shared-moderation/bulk.service'
import { VideoStateMessageService } from '@app/shared/shared-video/video-state-message.service'
export default [ export default [
{ {
@ -30,7 +31,8 @@ export default [
AbuseService, AbuseService,
UserAdminService, UserAdminService,
BulkService, BulkService,
VideoStateMessageService VideoStateMessageService,
PlayerSettingsService
], ],
children: [ children: [
{ {

View file

@ -29,12 +29,16 @@ import { SubscribeButtonComponent } from '@app/shared/shared-user-subscription/s
import { LiveVideoService } from '@app/shared/shared-video-live/live-video.service' import { LiveVideoService } from '@app/shared/shared-video-live/live-video.service'
import { VideoPlaylist } from '@app/shared/shared-video-playlist/video-playlist.model' import { VideoPlaylist } from '@app/shared/shared-video-playlist/video-playlist.model'
import { VideoPlaylistService } from '@app/shared/shared-video-playlist/video-playlist.service' import { VideoPlaylistService } from '@app/shared/shared-video-playlist/video-playlist.service'
import { PlayerSettingsService } from '@app/shared/shared-video/player-settings.service'
import { getVideoWatchRSSFeeds, timeToInt } from '@peertube/peertube-core-utils' import { getVideoWatchRSSFeeds, timeToInt } from '@peertube/peertube-core-utils'
import { import {
HTMLServerConfig, HTMLServerConfig,
HttpStatusCode, HttpStatusCode,
LiveVideo, LiveVideo,
PeerTubeProblemDocument, PeerTubeProblemDocument,
PlayerMode,
PlayerTheme,
PlayerVideoSettings,
ServerErrorCode, ServerErrorCode,
Storyboard, Storyboard,
VideoCaption, VideoCaption,
@ -51,8 +55,6 @@ import {
PeerTubePlayer, PeerTubePlayer,
PeerTubePlayerConstructorOptions, PeerTubePlayerConstructorOptions,
PeerTubePlayerLoadOptions, PeerTubePlayerLoadOptions,
PeerTubePlayerTheme,
PlayerMode,
videojs, videojs,
VideojsPlayer VideojsPlayer
} from '@peertube/player' } from '@peertube/player'
@ -80,7 +82,7 @@ const debugLogger = debug('peertube:watch:VideoWatchComponent')
type URLOptions = { type URLOptions = {
playerMode: PlayerMode playerMode: PlayerMode
playerTheme?: PeerTubePlayerTheme playerTheme?: PlayerTheme
startTime: number | string startTime: number | string
stopTime: number | string stopTime: number | string
@ -140,6 +142,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
private zone = inject(NgZone) private zone = inject(NgZone)
private videoCaptionService = inject(VideoCaptionService) private videoCaptionService = inject(VideoCaptionService)
private videoChapterService = inject(VideoChapterService) private videoChapterService = inject(VideoChapterService)
private playerSettingsService = inject(PlayerSettingsService)
private hotkeysService = inject(HotkeysService) private hotkeysService = inject(HotkeysService)
private hooks = inject(HooksService) private hooks = inject(HooksService)
private pluginService = inject(PluginService) private pluginService = inject(PluginService)
@ -163,6 +166,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
liveVideo: LiveVideo liveVideo: LiveVideo
videoPassword: string videoPassword: string
storyboards: Storyboard[] = [] storyboards: Storyboard[] = []
playerSettings: PlayerVideoSettings
playlistPosition: number playlistPosition: number
playlist: VideoPlaylist = null playlist: VideoPlaylist = null
@ -374,9 +378,10 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
this.videoCaptionService.listCaptions(videoId, videoPassword), this.videoCaptionService.listCaptions(videoId, videoPassword),
this.videoChapterService.getChapters({ videoId, videoPassword }), this.videoChapterService.getChapters({ videoId, videoPassword }),
this.videoService.getStoryboards(videoId, videoPassword), this.videoService.getStoryboards(videoId, videoPassword),
this.playerSettingsService.getVideoSettings({ videoId, videoPassword, raw: false }),
this.userService.getAnonymousOrLoggedUser() this.userService.getAnonymousOrLoggedUser()
]).subscribe({ ]).subscribe({
next: ([ { video, live, videoFileToken }, captionsResult, chaptersResult, storyboards, loggedInOrAnonymousUser ]) => { next: ([ { video, live, videoFileToken }, captionsResult, chaptersResult, storyboards, playerSettings, loggedInOrAnonymousUser ]) => {
this.onVideoFetched({ this.onVideoFetched({
video, video,
live, live,
@ -385,6 +390,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
storyboards, storyboards,
videoFileToken, videoFileToken,
videoPassword, videoPassword,
playerSettings,
loggedInOrAnonymousUser, loggedInOrAnonymousUser,
forceAutoplay forceAutoplay
}).catch(err => { }).catch(err => {
@ -491,6 +497,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
storyboards: Storyboard[] storyboards: Storyboard[]
videoFileToken: string videoFileToken: string
videoPassword: string videoPassword: string
playerSettings: PlayerVideoSettings
loggedInOrAnonymousUser: User loggedInOrAnonymousUser: User
forceAutoplay: boolean forceAutoplay: boolean
@ -503,6 +510,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
storyboards, storyboards,
videoFileToken, videoFileToken,
videoPassword, videoPassword,
playerSettings,
loggedInOrAnonymousUser, loggedInOrAnonymousUser,
forceAutoplay forceAutoplay
} = options } = options
@ -516,6 +524,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
this.videoFileToken = videoFileToken this.videoFileToken = videoFileToken
this.videoPassword = videoPassword this.videoPassword = videoPassword
this.storyboards = storyboards this.storyboards = storyboards
this.playerSettings = playerSettings
// Re init attributes // Re init attributes
this.remoteServerDown = false this.remoteServerDown = false
@ -579,6 +588,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
liveVideo: this.liveVideo, liveVideo: this.liveVideo,
videoFileToken: this.videoFileToken, videoFileToken: this.videoFileToken,
videoPassword: this.videoPassword, videoPassword: this.videoPassword,
playerSettings: this.playerSettings,
urlOptions: this.getUrlOptions(), urlOptions: this.getUrlOptions(),
loggedInOrAnonymousUser, loggedInOrAnonymousUser,
forceAutoplay, forceAutoplay,
@ -727,6 +737,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
videoCaptions: VideoCaption[] videoCaptions: VideoCaption[]
videoChapters: VideoChapter[] videoChapters: VideoChapter[]
storyboards: Storyboard[] storyboards: Storyboard[]
playerSettings: PlayerVideoSettings
videoFileToken: string videoFileToken: string
videoPassword: string videoPassword: string
@ -747,7 +758,8 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
videoPassword, videoPassword,
urlOptions, urlOptions,
loggedInOrAnonymousUser, loggedInOrAnonymousUser,
forceAutoplay forceAutoplay,
playerSettings
} = options } = options
let mode: PlayerMode let mode: PlayerMode
@ -816,7 +828,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
return { return {
mode, mode,
theme: urlOptions.playerTheme || 'default', theme: urlOptions.playerTheme || playerSettings.theme as PlayerTheme,
autoplay: this.isAutoplay(video, loggedInOrAnonymousUser), autoplay: this.isAutoplay(video, loggedInOrAnonymousUser),
forceAutoplay, forceAutoplay,

View file

@ -8,6 +8,8 @@ import { manageRoutes } from '../shared-manage/routes'
import { VideoStudioService } from '../shared-manage/studio/video-studio.service' import { VideoStudioService } from '../shared-manage/studio/video-studio.service'
import { VideoManageComponent } from './video-manage.component' import { VideoManageComponent } from './video-manage.component'
import { VideoManageResolver } from './video-manage.resolver' import { VideoManageResolver } from './video-manage.resolver'
import { VideoManageController } from '../shared-manage/video-manage-controller.service'
import { PlayerSettingsService } from '@app/shared/shared-video/player-settings.service'
export default [ export default [
{ {
@ -16,12 +18,14 @@ export default [
canActivate: [ LoginGuard ], canActivate: [ LoginGuard ],
canDeactivate: [ CanDeactivateGuard ], canDeactivate: [ CanDeactivateGuard ],
providers: [ providers: [
VideoManageController,
VideoManageResolver, VideoManageResolver,
LiveVideoService, LiveVideoService,
I18nPrimengCalendarService, I18nPrimengCalendarService,
VideoUploadService, VideoUploadService,
VideoStudioService, VideoStudioService,
VideoStateMessageService VideoStateMessageService,
PlayerSettingsService
], ],
resolve: { resolve: {
resolverData: VideoManageResolver resolverData: VideoManageResolver

View file

@ -1,6 +1,5 @@
<div class="margin-content"> <div class="margin-content">
<my-video-manage-container <my-video-manage-container
*ngIf="loaded"
canUpdate="true" canWatch="true" cancelLink="/my-library/videos" (videoUpdated)="onVideoUpdated()" canUpdate="true" canWatch="true" cancelLink="/my-library/videos" (videoUpdated)="onVideoUpdated()"
></my-video-manage-container> ></my-video-manage-container>
</div> </div>

View file

@ -3,7 +3,6 @@ import { Component, HostListener, inject, OnDestroy, OnInit } from '@angular/cor
import { FormsModule, ReactiveFormsModule } from '@angular/forms' import { FormsModule, ReactiveFormsModule } from '@angular/forms'
import { ActivatedRoute } from '@angular/router' import { ActivatedRoute } from '@angular/router'
import { CanComponentDeactivate, Notifier, ServerService } from '@app/core' import { CanComponentDeactivate, Notifier, ServerService } from '@app/core'
import { VideoEdit } from '../shared-manage/common/video-edit.model'
import { VideoManageContainerComponent } from '../shared-manage/video-manage-container.component' import { VideoManageContainerComponent } from '../shared-manage/video-manage-container.component'
import { VideoManageController } from '../shared-manage/video-manage-controller.service' import { VideoManageController } from '../shared-manage/video-manage-controller.service'
import { VideoManageResolverData } from './video-manage.resolver' import { VideoManageResolverData } from './video-manage.resolver'
@ -16,8 +15,7 @@ import { VideoManageResolverData } from './video-manage.resolver'
FormsModule, FormsModule,
ReactiveFormsModule, ReactiveFormsModule,
VideoManageContainerComponent VideoManageContainerComponent
], ]
providers: [ VideoManageController ]
}) })
export class VideoManageComponent implements OnInit, OnDestroy, CanComponentDeactivate { export class VideoManageComponent implements OnInit, OnDestroy, CanComponentDeactivate {
private route = inject(ActivatedRoute) private route = inject(ActivatedRoute)
@ -29,18 +27,9 @@ export class VideoManageComponent implements OnInit, OnDestroy, CanComponentDeac
isUpdatingVideo = false isUpdatingVideo = false
loaded = false loaded = false
async ngOnInit () { ngOnInit () {
const data = this.route.snapshot.data.resolverData as VideoManageResolverData const data = this.route.snapshot.data.resolverData as VideoManageResolverData
const { video, userChannels, captions, chapters, videoSource, live, videoPasswords, userQuota, privacies } = data const { userChannels, userQuota, privacies, videoEdit } = data
const videoEdit = await VideoEdit.createFromAPI(this.serverService.getHTMLConfig(), {
video,
captions,
chapters,
live,
videoSource,
videoPasswords: videoPasswords.map(p => p.password)
})
this.manageController.setStore({ this.manageController.setStore({
videoEdit, videoEdit,
@ -50,8 +39,6 @@ export class VideoManageComponent implements OnInit, OnDestroy, CanComponentDeac
}) })
this.manageController.setConfig({ manageType: 'update', serverConfig: this.serverService.getHTMLConfig() }) this.manageController.setConfig({ manageType: 'update', serverConfig: this.serverService.getHTMLConfig() })
this.loaded = true
} }
ngOnDestroy () { ngOnDestroy () {

View file

@ -10,6 +10,7 @@ import { VideoService } from '@app/shared/shared-main/video/video.service'
import { LiveVideoService } from '@app/shared/shared-video-live/live-video.service' import { LiveVideoService } from '@app/shared/shared-video-live/live-video.service'
import { import {
LiveVideo, LiveVideo,
PlayerVideoSettings,
UserVideoQuota, UserVideoQuota,
VideoCaption, VideoCaption,
VideoChapter, VideoChapter,
@ -22,6 +23,8 @@ import {
import { forkJoin, of } from 'rxjs' import { forkJoin, of } from 'rxjs'
import { map, switchMap } from 'rxjs/operators' import { map, switchMap } from 'rxjs/operators'
import { SelectChannelItem } from '../../../types' import { SelectChannelItem } from '../../../types'
import { VideoEdit } from '../shared-manage/common/video-edit.model'
import { PlayerSettingsService } from '@app/shared/shared-video/player-settings.service'
export type VideoManageResolverData = { export type VideoManageResolverData = {
video: VideoDetails video: VideoDetails
@ -33,6 +36,8 @@ export type VideoManageResolverData = {
videoPasswords: VideoPassword[] videoPasswords: VideoPassword[]
userQuota: UserVideoQuota userQuota: UserVideoQuota
privacies: VideoConstant<VideoPrivacyType>[] privacies: VideoConstant<VideoPrivacyType>[]
videoEdit: VideoEdit
playerSettings: PlayerVideoSettings
} }
@Injectable() @Injectable()
@ -45,6 +50,7 @@ export class VideoManageResolver {
private videoPasswordService = inject(VideoPasswordService) private videoPasswordService = inject(VideoPasswordService)
private userService = inject(UserService) private userService = inject(UserService)
private serverService = inject(ServerService) private serverService = inject(ServerService)
private playerSettingsService = inject(PlayerSettingsService)
resolve (route: ActivatedRouteSnapshot) { resolve (route: ActivatedRouteSnapshot) {
const uuid: string = route.params['uuid'] const uuid: string = route.params['uuid']
@ -52,18 +58,32 @@ export class VideoManageResolver {
return this.videoService.getVideo({ videoId: uuid }) return this.videoService.getVideo({ videoId: uuid })
.pipe( .pipe(
switchMap(video => forkJoin(this.buildObservables(video))), switchMap(video => forkJoin(this.buildObservables(video))),
map(([ video, videoSource, userChannels, captions, chapters, live, videoPasswords, userQuota, privacies ]) => switchMap(
({ async ([ video, videoSource, userChannels, captions, chapters, live, videoPasswords, userQuota, privacies, playerSettings ]) => {
video, const videoEdit = await VideoEdit.createFromAPI(this.serverService.getHTMLConfig(), {
userChannels, video,
captions, captions,
chapters, chapters,
videoSource, live,
live, videoSource,
videoPasswords, playerSettings,
userQuota, videoPasswords: videoPasswords.map(p => p.password)
privacies })
}) as VideoManageResolverData
return {
video,
userChannels,
captions,
chapters,
videoSource,
live,
videoPasswords,
userQuota,
privacies,
videoEdit,
playerSettings
} satisfies VideoManageResolverData
}
) )
) )
} }
@ -94,11 +114,13 @@ export class VideoManageResolver {
video.privacy.id === VideoPrivacy.PASSWORD_PROTECTED video.privacy.id === VideoPrivacy.PASSWORD_PROTECTED
? this.videoPasswordService.getVideoPasswords({ videoUUID: video.uuid }) ? this.videoPasswordService.getVideoPasswords({ videoUUID: video.uuid })
: of([]), : of([] as VideoPassword[]),
this.userService.getMyVideoQuotaUsed(), this.userService.getMyVideoQuotaUsed(),
this.serverService.getVideoPrivacies() this.serverService.getVideoPrivacies(),
]
this.playerSettingsService.getVideoSettings({ videoId: video.uuid, raw: true })
] as const
} }
} }

View file

@ -1,6 +1,10 @@
import { inject } from '@angular/core'
import { RedirectCommand, Router, Routes } from '@angular/router' import { RedirectCommand, Router, Routes } from '@angular/router'
import { CanDeactivateGuard, LoginGuard } from '@app/core' import { CanDeactivateGuard, LoginGuard } from '@app/core'
import { LiveVideoService } from '@app/shared/shared-video-live/live-video.service' import { LiveVideoService } from '@app/shared/shared-video-live/live-video.service'
import { PlayerSettingsService } from '@app/shared/shared-video/player-settings.service'
import { VideoStateMessageService } from '@app/shared/shared-video/video-state-message.service'
import debug from 'debug'
import { I18nPrimengCalendarService } from '../shared-manage/common/i18n-primeng-calendar.service' import { I18nPrimengCalendarService } from '../shared-manage/common/i18n-primeng-calendar.service'
import { VideoUploadService } from '../shared-manage/common/video-upload.service' import { VideoUploadService } from '../shared-manage/common/video-upload.service'
import { manageRoutes } from '../shared-manage/routes' import { manageRoutes } from '../shared-manage/routes'
@ -8,9 +12,6 @@ import { VideoStudioService } from '../shared-manage/studio/video-studio.service
import { VideoManageController } from '../shared-manage/video-manage-controller.service' import { VideoManageController } from '../shared-manage/video-manage-controller.service'
import { VideoPublishComponent } from './video-publish.component' import { VideoPublishComponent } from './video-publish.component'
import { VideoPublishResolver } from './video-publish.resolver' import { VideoPublishResolver } from './video-publish.resolver'
import { inject } from '@angular/core'
import debug from 'debug'
import { VideoStateMessageService } from '@app/shared/shared-video/video-state-message.service'
const debugLogger = debug('peertube:video-publish') const debugLogger = debug('peertube:video-publish')
@ -43,6 +44,7 @@ export default [
providers: [ providers: [
VideoPublishResolver, VideoPublishResolver,
VideoManageController, VideoManageController,
PlayerSettingsService,
VideoStateMessageService, VideoStateMessageService,
LiveVideoService, LiveVideoService,
I18nPrimengCalendarService, I18nPrimengCalendarService,

View file

@ -41,8 +41,7 @@ import { VideoPublishResolverData } from './video-publish.resolver'
VideoImportUrlComponent, VideoImportUrlComponent,
VideoUploadComponent, VideoUploadComponent,
HelpComponent HelpComponent
], ]
providers: [ VideoManageController ]
}) })
export class VideoPublishComponent implements OnInit, CanComponentDeactivate { export class VideoPublishComponent implements OnInit, CanComponentDeactivate {
private auth = inject(AuthService) private auth = inject(AuthService)

View file

@ -6,6 +6,8 @@ import {
LiveVideoCreate, LiveVideoCreate,
LiveVideoUpdate, LiveVideoUpdate,
NSFWFlag, NSFWFlag,
PlayerVideoSettings,
PlayerVideoSettingsUpdate,
VideoCaption, VideoCaption,
VideoChapter, VideoChapter,
VideoCreate, VideoCreate,
@ -65,6 +67,8 @@ type StudioForm = {
'add-watermark'?: { file?: File } 'add-watermark'?: { file?: File }
} }
type PlayerSettingsForm = PlayerVideoSettingsUpdate
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
type LoadFromPublishOptions = Required<Pick<VideoCreate, 'channelId' | 'support'>> & Partial<Pick<VideoCreate, 'name'>> type LoadFromPublishOptions = Required<Pick<VideoCreate, 'channelId' | 'support'>> & Partial<Pick<VideoCreate, 'name'>>
@ -115,6 +119,7 @@ type UpdateFromAPIOptions = {
captions?: VideoCaption[] captions?: VideoCaption[]
videoPasswords?: string[] videoPasswords?: string[]
videoSource?: VideoSource videoSource?: VideoSource
playerSettings?: PlayerVideoSettings
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -143,6 +148,7 @@ export class VideoEdit {
private live: LiveUpdate private live: LiveUpdate
private replaceFile: File private replaceFile: File
private studioTasks: VideoStudioTask[] = [] private studioTasks: VideoStudioTask[] = []
private playerSettings: PlayerVideoSettings
private videoImport: Pick<VideoImportCreate, 'magnetUri' | 'torrentfile' | 'targetUrl'> private videoImport: Pick<VideoImportCreate, 'magnetUri' | 'torrentfile' | 'targetUrl'>
@ -185,6 +191,7 @@ export class VideoEdit {
previewfile?: { size: number } previewfile?: { size: number }
live?: LiveUpdate live?: LiveUpdate
playerSettings?: PlayerVideoSettings
pluginData?: any pluginData?: any
pluginDefaults?: Record<string, string | boolean> pluginDefaults?: Record<string, string | boolean>
@ -294,12 +301,13 @@ export class VideoEdit {
} }
async loadFromAPI (options: UpdateFromAPIOptions & { loadPrivacy?: boolean }) { async loadFromAPI (options: UpdateFromAPIOptions & { loadPrivacy?: boolean }) {
const { video, videoPasswords, live, chapters, captions, videoSource, loadPrivacy = true } = options const { video, videoPasswords, live, chapters, captions, videoSource, playerSettings, loadPrivacy = true } = options
debugLogger('Load from API', options) debugLogger('Load from API', options)
this.loadVideo({ video, videoPasswords, saveInStore: true, loadPrivacy }) this.loadVideo({ video, videoPasswords, saveInStore: true, loadPrivacy })
this.loadLive(live) this.loadLive(live)
this.loadPlayerSettings(playerSettings)
if (captions !== undefined) { if (captions !== undefined) {
this.captions = captions this.captions = captions
@ -449,6 +457,17 @@ export class VideoEdit {
this.metadata.live = pick(live, [ 'rtmpUrl', 'rtmpsUrl', 'streamKey' ]) this.metadata.live = pick(live, [ 'rtmpUrl', 'rtmpsUrl', 'streamKey' ])
} }
private loadPlayerSettings (playerSettings: UpdateFromAPIOptions['playerSettings']) {
const buildObj = () => {
return {
theme: playerSettings.theme
}
}
this.playerSettings = buildObj()
this.saveStore.playerSettings = buildObj()
}
loadAfterPublish (options: { loadAfterPublish (options: {
video: Pick<VideoDetails, 'id' | 'uuid' | 'shortUUID'> video: Pick<VideoDetails, 'id' | 'uuid' | 'shortUUID'>
}) { }) {
@ -797,6 +816,26 @@ export class VideoEdit {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
loadFromPlayerSettingsForm (values: PlayerSettingsForm) {
this.playerSettings = values
}
toPlayerSettingsFormPatch (): Required<PlayerSettingsForm> {
return {
theme: this.playerSettings?.theme ?? 'channel-default'
}
}
toPlayerSettingsUpdate (): PlayerVideoSettingsUpdate {
if (!this.playerSettings) return undefined
return {
theme: this.playerSettings.theme
}
}
// ---------------------------------------------------------------------------
getVideoSource () { getVideoSource () {
return this.metadata.videoSource return this.metadata.videoSource
} }
@ -825,6 +864,10 @@ export class VideoEdit {
return this.studioTasks return this.studioTasks
} }
getPlayerSettings () {
return this.playerSettings
}
getStudioTasksSummary () { getStudioTasksSummary () {
return this.getStudioTasks().map(t => { return this.getStudioTasks().map(t => {
if (t.name === 'add-intro') { if (t.name === 'add-intro') {
@ -941,6 +984,21 @@ export class VideoEdit {
return changes return changes
} }
hasPlayerSettingsChanges () {
if (!this.playerSettings) return false
if (!this.saveStore.playerSettings) return true
const changes = !this.areSameObjects(this.playerSettings, this.saveStore.playerSettings)
debugLogger('Check if player settings has changes', {
playerSettings: this.playerSettings,
savePlayerSettings: this.saveStore.playerSettings,
changes
})
return changes
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
hasPendingChanges () { hasPendingChanges () {
@ -950,7 +1008,8 @@ export class VideoEdit {
this.hasStudioTasks() || this.hasStudioTasks() ||
this.hasChaptersChanges() || this.hasChaptersChanges() ||
this.hasCommonChanges() || this.hasCommonChanges() ||
this.hasPluginDataChanges() this.hasPluginDataChanges() ||
this.hasPlayerSettingsChanges()
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------

View file

@ -31,8 +31,15 @@
</div> </div>
<p-datepicker <p-datepicker
inputId="originallyPublishedAt" formControlName="originallyPublishedAt" [dateFormat]="calendarDateFormat" [firstDayOfWeek]="0" inputId="originallyPublishedAt"
[showTime]="true" [hideOnDateTimeSelect]="true" [monthNavigator]="true" [yearNavigator]="true" [yearRange]="myYearRange" formControlName="originallyPublishedAt"
[dateFormat]="calendarDateFormat"
[firstDayOfWeek]="0"
[showTime]="true"
[hideOnDateTimeSelect]="true"
[monthNavigator]="true"
[yearNavigator]="true"
[yearRange]="myYearRange"
baseZIndex="20000" baseZIndex="20000"
> >
</p-datepicker> </p-datepicker>
@ -42,10 +49,15 @@
</div> </div>
</div> </div>
<my-peertube-checkbox <my-peertube-checkbox inputName="downloadEnabled" formControlName="downloadEnabled" i18n-labelText labelText="Enable download"></my-peertube-checkbox>
inputName="downloadEnabled" formControlName="downloadEnabled"
i18n-labelText labelText="Enable download" <div class="form-group" formGroupName="playerSettings">
></my-peertube-checkbox> <label i18n for="playerSettingsTheme">Player Theme</label>
<my-select-player-theme formControlName="theme" inputId="playerSettingsTheme" mode="video" [channel]="videoChannel">
</my-select-player-theme>
</div>
</div> </div>
</div> </div>
</div> </div>

View file

@ -1,11 +1,12 @@
import { NgIf } from '@angular/common' import { CommonModule } from '@angular/common'
import { Component, OnDestroy, OnInit, inject } from '@angular/core' import { Component, OnDestroy, OnInit, inject } from '@angular/core'
import { FormControl, FormGroup, FormsModule, ReactiveFormsModule } from '@angular/forms' import { FormControl, FormGroup, FormsModule, ReactiveFormsModule } from '@angular/forms'
import { ServerService } from '@app/core' import { ServerService } from '@app/core'
import { BuildFormArgument } from '@app/shared/form-validators/form-validator.model' import { BuildFormArgumentTyped } from '@app/shared/form-validators/form-validator.model'
import { VIDEO_ORIGINALLY_PUBLISHED_AT_VALIDATOR } from '@app/shared/form-validators/video-validators' import { VIDEO_ORIGINALLY_PUBLISHED_AT_VALIDATOR } from '@app/shared/form-validators/video-validators'
import { FormReactiveErrors, FormReactiveService, FormReactiveMessages } from '@app/shared/shared-forms/form-reactive.service' import { FormReactiveErrors, FormReactiveMessages, FormReactiveService } from '@app/shared/shared-forms/form-reactive.service'
import { HTMLServerConfig } from '@peertube/peertube-models' import { SelectPlayerThemeComponent } from '@app/shared/shared-forms/select/select-player-theme.component'
import { HTMLServerConfig, PlayerVideoSettings, VideoChannel } from '@peertube/peertube-models'
import debug from 'debug' import debug from 'debug'
import { DatePickerModule } from 'primeng/datepicker' import { DatePickerModule } from 'primeng/datepicker'
import { Subscription } from 'rxjs' import { Subscription } from 'rxjs'
@ -19,6 +20,10 @@ const debugLogger = debug('peertube:video-manage')
type Form = { type Form = {
downloadEnabled: FormControl<boolean> downloadEnabled: FormControl<boolean>
originallyPublishedAt: FormControl<Date> originallyPublishedAt: FormControl<Date>
playerSettings: FormGroup<{
theme: FormControl<PlayerVideoSettings['theme']>
}>
} }
@Component({ @Component({
@ -28,12 +33,13 @@ type Form = {
], ],
templateUrl: './video-customization.component.html', templateUrl: './video-customization.component.html',
imports: [ imports: [
CommonModule,
FormsModule, FormsModule,
ReactiveFormsModule, ReactiveFormsModule,
NgIf,
DatePickerModule, DatePickerModule,
PeertubeCheckboxComponent, PeertubeCheckboxComponent,
GlobalIconComponent GlobalIconComponent,
SelectPlayerThemeComponent
] ]
}) })
export class VideoCustomizationComponent implements OnInit, OnDestroy { export class VideoCustomizationComponent implements OnInit, OnDestroy {
@ -47,6 +53,7 @@ export class VideoCustomizationComponent implements OnInit, OnDestroy {
validationMessages: FormReactiveMessages = {} validationMessages: FormReactiveMessages = {}
videoEdit: VideoEdit videoEdit: VideoEdit
videoChannel: Pick<VideoChannel, 'name' | 'displayName'>
calendarDateFormat: string calendarDateFormat: string
myYearRange: string myYearRange: string
@ -63,17 +70,24 @@ export class VideoCustomizationComponent implements OnInit, OnDestroy {
ngOnInit () { ngOnInit () {
this.serverConfig = this.serverService.getHTMLConfig() this.serverConfig = this.serverService.getHTMLConfig()
const { videoEdit } = this.manageController.getStore() const { videoEdit, userChannels } = this.manageController.getStore()
this.videoEdit = videoEdit this.videoEdit = videoEdit
const channelItem = userChannels.find(c => c.id === videoEdit.toCommonFormPatch().channelId)
this.videoChannel = { name: channelItem.name, displayName: channelItem.label }
this.buildForm() this.buildForm()
} }
private buildForm () { private buildForm () {
const defaultValues = this.videoEdit.toCommonFormPatch() const defaultValues = { ...this.videoEdit.toCommonFormPatch(), playerSettings: this.videoEdit.toPlayerSettingsFormPatch() }
const obj: BuildFormArgument = {
const obj: BuildFormArgumentTyped<Form> = {
downloadEnabled: null, downloadEnabled: null,
originallyPublishedAt: VIDEO_ORIGINALLY_PUBLISHED_AT_VALIDATOR originallyPublishedAt: VIDEO_ORIGINALLY_PUBLISHED_AT_VALIDATOR,
playerSettings: {
theme: null
}
} }
const { const {
@ -93,12 +107,18 @@ export class VideoCustomizationComponent implements OnInit, OnDestroy {
debugLogger('Updating form values', formValues) debugLogger('Updating form values', formValues)
this.videoEdit.loadFromCommonForm(formValues) this.videoEdit.loadFromCommonForm(formValues)
this.videoEdit.loadFromPlayerSettingsForm({
theme: formValues.playerSettings.theme
})
}) })
this.formReactiveService.markAllAsDirty(this.form.controls) this.formReactiveService.markAllAsDirty(this.form.controls)
this.updatedSub = this.manageController.getUpdatedObs().subscribe(() => { this.updatedSub = this.manageController.getUpdatedObs().subscribe(() => {
this.form.patchValue(this.videoEdit.toCommonFormPatch()) this.form.patchValue({
...this.videoEdit.toCommonFormPatch(),
...this.videoEdit.toPlayerSettingsFormPatch()
})
}) })
} }

View file

@ -9,6 +9,7 @@ import { VideoChapterService } from '@app/shared/shared-main/video/video-chapter
import { VideoPasswordService } from '@app/shared/shared-main/video/video-password.service' import { VideoPasswordService } from '@app/shared/shared-main/video/video-password.service'
import { VideoService } from '@app/shared/shared-main/video/video.service' import { VideoService } from '@app/shared/shared-main/video/video.service'
import { LiveVideoService } from '@app/shared/shared-video-live/live-video.service' import { LiveVideoService } from '@app/shared/shared-video-live/live-video.service'
import { PlayerSettingsService } from '@app/shared/shared-video/player-settings.service'
import { LoadingBarService } from '@ngx-loading-bar/core' import { LoadingBarService } from '@ngx-loading-bar/core'
import { import {
HTMLServerConfig, HTMLServerConfig,
@ -47,6 +48,7 @@ export class VideoManageController implements OnDestroy {
private formReactiveService = inject(FormReactiveService) private formReactiveService = inject(FormReactiveService)
private videoStudio = inject(VideoStudioService) private videoStudio = inject(VideoStudioService)
private peertubeRouter = inject(PeerTubeRouterService) private peertubeRouter = inject(PeerTubeRouterService)
private playerSettingsService = inject(PlayerSettingsService)
private videoEdit: VideoEdit private videoEdit: VideoEdit
private userChannels: SelectChannelItem[] private userChannels: SelectChannelItem[]
@ -245,6 +247,16 @@ export class VideoManageController implements OnDestroy {
return this.videoChapterService.updateChapters(videoAttributes.uuid, this.videoEdit.getChaptersEdit()) return this.videoChapterService.updateChapters(videoAttributes.uuid, this.videoEdit.getChaptersEdit())
}), }),
switchMap(() => {
if (!this.videoEdit.hasPlayerSettingsChanges()) return of(true)
debugLogger('Update player settings')
return this.playerSettingsService.updateVideoSettings({
videoId: videoAttributes.uuid,
settings: this.videoEdit.getPlayerSettings()
})
}),
switchMap(() => { switchMap(() => {
if (!isLive || !this.videoEdit.hasLiveChanges()) return of(true) if (!isLive || !this.videoEdit.hasLiveChanges()) return of(true)
@ -283,16 +295,19 @@ export class VideoManageController implements OnDestroy {
!isLive !isLive
? this.videoCaptionService.listCaptions(videoAttributes.uuid) ? this.videoCaptionService.listCaptions(videoAttributes.uuid)
: of(undefined) : of(undefined),
this.playerSettingsService.getVideoSettings({ videoId: videoAttributes.uuid, raw: true })
]) ])
}), }),
switchMap(([ video, videoPasswords, live, chaptersRes, captionsRes ]) => { switchMap(([ video, videoPasswords, live, chaptersRes, captionsRes, playerSettings ]) => {
return this.videoEdit.loadFromAPI({ return this.videoEdit.loadFromAPI({
video, video,
videoPasswords: videoPasswords.map(p => p.password), videoPasswords: videoPasswords.map(p => p.password),
live, live,
chapters: chaptersRes?.chapters, chapters: chaptersRes?.chapters,
captions: captionsRes?.data captions: captionsRes?.data,
playerSettings
}) })
}), }),
first(), // To complete first(), // To complete

View file

@ -1,6 +1,6 @@
<div class="actor" *ngIf="actor"> <div class="actor" *ngIf="actor">
<div class="position-relative me-3"> <div class="position-relative me-3">
<my-actor-avatar [actor]="actor" [actorType]="actorType()" [previewImage]="preview" size="100"></my-actor-avatar> <my-actor-avatar [actor]="actor" [actorType]="actorType()" [previewImage]="previewUrl" size="100"></my-actor-avatar>
@if (editable()) { @if (editable()) {
@if (hasAvatar()) { @if (hasAvatar()) {

View file

@ -41,7 +41,7 @@ export class ActorAvatarEditComponent implements OnInit, OnChanges {
maxAvatarSize = 0 maxAvatarSize = 0
avatarExtensions = '' avatarExtensions = ''
preview: string previewUrl: string
actor: ActorAvatarInput actor: ActorAvatarInput
@ -55,6 +55,8 @@ export class ActorAvatarEditComponent implements OnInit, OnChanges {
} }
ngOnChanges () { ngOnChanges () {
this.previewUrl = undefined
this.actor = { this.actor = {
avatars: this.avatars(), avatars: this.avatars(),
name: this.username() name: this.username()
@ -73,16 +75,23 @@ export class ActorAvatarEditComponent implements OnInit, OnChanges {
this.avatarChange.emit(formData) this.avatarChange.emit(formData)
if (this.previewImage()) { if (this.previewImage()) {
imageToDataURL(avatarfile).then(result => this.preview = result) imageToDataURL(avatarfile).then(result => this.previewUrl = result)
} }
} }
deleteAvatar () { deleteAvatar () {
this.preview = undefined if (this.previewImage()) {
this.previewUrl = null
this.actor.avatars = []
}
this.avatarDelete.emit() this.avatarDelete.emit()
} }
hasAvatar () { hasAvatar () {
return !!this.preview || this.avatars().length !== 0 // User deleted the avatar
if (this.previewUrl === null) return false
return !!this.previewUrl || this.avatars().length !== 0
} }
} }

View file

@ -1,30 +1,32 @@
<div class="actor"> <div class="actor">
<div class="actor-img-edit-container"> <div class="actor-img-edit-container">
<div class="banner-placeholder"> <div class="banner-placeholder">
<img *ngIf="hasBanner()" [src]="preview || bannerUrl()" alt="Banner" /> <img *ngIf="hasBanner()" [src]="getBannerUrl()" alt="Banner" />
</div> </div>
<div *ngIf="!hasBanner()" class="actor-img-edit-button button-file primary-button button-focus-within" [ngbTooltip]="bannerFormat" placement="right" container="body"> @if (!hasBanner()) {
<ng-container *ngTemplateOutlet="uploadNewBanner"></ng-container> <div class="actor-img-edit-button button-file primary-button button-focus-within" [ngbTooltip]="bannerFormat" placement="right" container="body">
</div> <ng-container *ngTemplateOutlet="uploadNewBanner"></ng-container>
<div *ngIf="hasBanner()" ngbDropdown placement="right">
<button type="button" class="actor-img-edit-button button-file primary-button" ngbDropdownToggle>
<my-global-icon iconName="edit"></my-global-icon>
<span i18n>Change your banner</span>
</button>
<div ngbDropdownMenu>
<div class="dropdown-item dropdown-file button-focus-within" [ngbTooltip]="bannerFormat">
<ng-container *ngTemplateOutlet="uploadNewBanner"></ng-container>
</div>
<button type="button" class="dropdown-item" (click)="deleteBanner()">
<my-global-icon iconName="delete"></my-global-icon>
<span i18n>Remove banner</span>
</button>
</div> </div>
</div> } @else {
<div ngbDropdown placement="right">
<button type="button" class="actor-img-edit-button button-file primary-button" ngbDropdownToggle>
<my-global-icon iconName="edit"></my-global-icon>
<span i18n>Change your banner</span>
</button>
<div ngbDropdownMenu>
<div class="dropdown-item dropdown-file button-focus-within" [ngbTooltip]="bannerFormat">
<ng-container *ngTemplateOutlet="uploadNewBanner"></ng-container>
</div>
<button type="button" class="dropdown-item" (click)="deleteBanner()">
<my-global-icon iconName="delete"></my-global-icon>
<span i18n>Remove banner</span>
</button>
</div>
</div>
}
</div> </div>
</div> </div>

View file

@ -1,8 +1,8 @@
import { NgIf, NgTemplateOutlet } from '@angular/common' import { CommonModule, NgTemplateOutlet } from '@angular/common'
import { Component, ElementRef, OnInit, inject, input, output, viewChild } from '@angular/core' import { Component, ElementRef, OnInit, booleanAttribute, inject, input, output, viewChild } from '@angular/core'
import { SafeResourceUrl } from '@angular/platform-browser' import { SafeResourceUrl } from '@angular/platform-browser'
import { Notifier, ServerService } from '@app/core' import { Notifier, ServerService } from '@app/core'
import { NgbDropdown, NgbDropdownMenu, NgbDropdownToggle, NgbPopover, NgbTooltip } from '@ng-bootstrap/ng-bootstrap' import { NgbDropdownModule, NgbPopover, NgbTooltipModule } from '@ng-bootstrap/ng-bootstrap'
import { getBytes } from '@root-helpers/bytes' import { getBytes } from '@root-helpers/bytes'
import { imageToDataURL } from '@root-helpers/images' import { imageToDataURL } from '@root-helpers/images'
import { GlobalIconComponent } from '../shared-icons/global-icon.component' import { GlobalIconComponent } from '../shared-icons/global-icon.component'
@ -14,7 +14,7 @@ import { GlobalIconComponent } from '../shared-icons/global-icon.component'
'./actor-image-edit.scss', './actor-image-edit.scss',
'./actor-banner-edit.component.scss' './actor-banner-edit.component.scss'
], ],
imports: [ NgIf, NgbTooltip, NgTemplateOutlet, NgbDropdown, NgbDropdownToggle, GlobalIconComponent, NgbDropdownMenu ] imports: [ CommonModule, NgbTooltipModule, NgTemplateOutlet, NgbDropdownModule, GlobalIconComponent ]
}) })
export class ActorBannerEditComponent implements OnInit { export class ActorBannerEditComponent implements OnInit {
private serverService = inject(ServerService) private serverService = inject(ServerService)
@ -23,8 +23,8 @@ export class ActorBannerEditComponent implements OnInit {
readonly bannerfileInput = viewChild<ElementRef<HTMLInputElement>>('bannerfileInput') readonly bannerfileInput = viewChild<ElementRef<HTMLInputElement>>('bannerfileInput')
readonly bannerPopover = viewChild<NgbPopover>('bannerPopover') readonly bannerPopover = viewChild<NgbPopover>('bannerPopover')
readonly bannerUrl = input<string>(undefined) readonly bannerUrl = input<string>()
readonly previewImage = input(false) readonly previewImage = input(false, { transform: booleanAttribute })
readonly bannerChange = output<FormData>() readonly bannerChange = output<FormData>()
readonly bannerDelete = output() readonly bannerDelete = output()
@ -63,11 +63,23 @@ export class ActorBannerEditComponent implements OnInit {
} }
deleteBanner () { deleteBanner () {
this.preview = undefined if (this.previewImage()) {
this.preview = null
}
this.bannerDelete.emit() this.bannerDelete.emit()
} }
hasBanner () { hasBanner () {
// User deleted the avatar
if (this.preview === null) return false
return !!this.preview || !!this.bannerUrl() return !!this.preview || !!this.bannerUrl()
} }
getBannerUrl () {
if (this.preview === null) return ''
return this.preview || this.bannerUrl()
}
} }

View file

@ -0,0 +1,129 @@
import { CommonModule } from '@angular/common'
import { Component, forwardRef, inject, input, OnInit } from '@angular/core'
import { ControlValueAccessor, FormsModule, NG_VALUE_ACCESSOR } from '@angular/forms'
import { ServerService } from '@app/core'
import { PlayerSettingsService } from '@app/shared/shared-video/player-settings.service'
import { PlayerChannelSettings, PlayerTheme, PlayerVideoSettings, VideoChannel } from '@peertube/peertube-models'
import { of } from 'rxjs'
import { SelectOptionsItem } from '../../../../types/select-options-item.model'
import { SelectOptionsComponent } from './select-options.component'
@Component({
selector: 'my-select-player-theme',
template: `
<my-select-options
[inputId]="inputId()"
[items]="themes"
[(ngModel)]="selectedId"
(ngModelChange)="onModelChange()"
filter="false"
></my-select-options>
`,
providers: [
{
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => SelectPlayerThemeComponent),
multi: true
}
],
imports: [ FormsModule, CommonModule, SelectOptionsComponent ]
})
export class SelectPlayerThemeComponent implements ControlValueAccessor, OnInit {
private serverService = inject(ServerService)
private playerSettingsService = inject(PlayerSettingsService)
readonly inputId = input.required<string>()
readonly mode = input.required<'instance' | 'video' | 'channel'>()
readonly channel = input<Pick<VideoChannel, 'name' | 'displayName'>>()
themes: SelectOptionsItem<PlayerVideoSettings['theme']>[]
selectedId: PlayerTheme
ngOnInit () {
if (this.mode() === 'video' && !this.channel()) {
throw new Error('Channel must be specified in video mode')
}
this.buildOptions()
}
propagateChange = (_: any) => {
// empty
}
writeValue (id: PlayerTheme) {
this.selectedId = id
}
registerOnChange (fn: (_: any) => void) {
this.propagateChange = fn
}
registerOnTouched () {
// Unused
}
onModelChange () {
this.propagateChange(this.selectedId)
}
private buildOptions () {
const config = this.serverService.getHTMLConfig()
const instanceName = config.instance.name
const instancePlayerTheme = this.getLabelOf(config.defaults.player.theme)
this.themes = []
if (this.mode() === 'channel' || this.mode() === 'video') {
this.themes.push(
{ id: 'instance-default', label: $localize`${instanceName} setting (${instancePlayerTheme})` }
)
}
if (this.mode() === 'video') {
this.themes.push(
{ id: 'channel-default', label: $localize`${this.channel().displayName} setting` }
)
this.scheduleChannelUpdate()
}
this.themes = this.themes.concat(this.getPlayerThemes())
}
private scheduleChannelUpdate () {
this.playerSettingsService.getChannelSettings({ channelHandle: this.channel().name, raw: true }).subscribe({
next: settings => {
this.themes.find(t => t.id === 'channel-default').label = this.buildChannelLabel(settings)
}
})
}
private buildChannelLabel (channelRawPlayerSettings: PlayerChannelSettings) {
const config = this.serverService.getHTMLConfig()
const instanceName = config.instance.name
const instancePlayerTheme = this.getLabelOf(config.defaults.player.theme)
const channelRawTheme = channelRawPlayerSettings.theme
const channelPlayerTheme = channelRawTheme === 'instance-default'
? $localize`from ${instanceName} setting\: ${instancePlayerTheme}`
: this.getLabelOf(channelRawTheme)
return $localize`${this.channel().displayName} channel setting (${channelPlayerTheme})`
}
private getLabelOf (playerTheme: PlayerTheme) {
return this.getPlayerThemes().find(t => t.id === playerTheme)?.label
}
private getPlayerThemes (): SelectOptionsItem<PlayerVideoSettings['theme']>[] {
return [
{ id: 'galaxy', label: $localize`Galaxy`, description: $localize`Original theme` },
{ id: 'lucide', label: $localize`Lucide`, description: $localize`A clean and modern theme` }
]
}
}

View file

@ -1,5 +1,4 @@
import { AfterViewChecked, booleanAttribute, Directive, ElementRef, inject, input, OnDestroy, OnInit, output } from '@angular/core' import { AfterViewChecked, booleanAttribute, Directive, ElementRef, inject, input, OnDestroy, OnInit, output } from '@angular/core'
import { PeerTubeRouterService } from '@app/core'
import { fromEvent, Observable, Subscription } from 'rxjs' import { fromEvent, Observable, Subscription } from 'rxjs'
import { distinctUntilChanged, filter, map, share, startWith, throttleTime } from 'rxjs/operators' import { distinctUntilChanged, filter, map, share, startWith, throttleTime } from 'rxjs/operators'
@ -8,7 +7,6 @@ import { distinctUntilChanged, filter, map, share, startWith, throttleTime } fro
standalone: true standalone: true
}) })
export class InfiniteScrollerDirective implements OnInit, OnDestroy, AfterViewChecked { export class InfiniteScrollerDirective implements OnInit, OnDestroy, AfterViewChecked {
private peertubeRouter = inject(PeerTubeRouterService)
private el = inject(ElementRef) private el = inject(ElementRef)
readonly percentLimit = input(70) readonly percentLimit = input(70)
@ -18,7 +16,7 @@ export class InfiniteScrollerDirective implements OnInit, OnDestroy, AfterViewCh
readonly nearOfBottom = output() readonly nearOfBottom = output()
private decimalLimit = 0 private decimalLimit = 0
private lastCurrentBottom = -1 private lastCurrentBottom: number
private scrollDownSub: Subscription private scrollDownSub: Subscription
private container: HTMLElement private container: HTMLElement
@ -98,6 +96,11 @@ export class InfiniteScrollerDirective implements OnInit, OnDestroy, AfterViewCh
} }
private isScrollingDown (current: number) { private isScrollingDown (current: number) {
if (this.lastCurrentBottom === undefined) {
this.lastCurrentBottom = current
return false
}
const result = this.lastCurrentBottom < current const result = this.lastCurrentBottom < current
this.lastCurrentBottom = current this.lastCurrentBottom = current

View file

@ -0,0 +1,71 @@
import { HttpClient, HttpParams } from '@angular/common/http'
import { inject, Injectable } from '@angular/core'
import { RestExtractor } from '@app/core'
import {
PlayerChannelSettings,
PlayerChannelSettingsUpdate,
PlayerVideoSettings,
PlayerVideoSettingsUpdate
} from '@peertube/peertube-models'
import { catchError } from 'rxjs'
import { environment } from 'src/environments/environment'
import { VideoPasswordService } from '../shared-main/video/video-password.service'
@Injectable()
export class PlayerSettingsService {
static BASE_PLAYER_SETTINGS_URL = environment.apiUrl + '/api/v1/player-settings/'
private authHttp = inject(HttpClient)
private restExtractor = inject(RestExtractor)
getVideoSettings (options: {
videoId: string
videoPassword?: string
raw: boolean
}) {
const headers = VideoPasswordService.buildVideoPasswordHeader(options.videoPassword)
const path = PlayerSettingsService.BASE_PLAYER_SETTINGS_URL + 'videos/' + options.videoId
let params = new HttpParams()
if (options.raw) params = params.set('raw', 'true')
return this.authHttp.get<PlayerVideoSettings>(path, { params, headers })
.pipe(catchError(err => this.restExtractor.handleError(err)))
}
updateVideoSettings (options: {
videoId: string
settings: PlayerVideoSettingsUpdate
}) {
const path = PlayerSettingsService.BASE_PLAYER_SETTINGS_URL + 'videos/' + options.videoId
return this.authHttp.put<PlayerVideoSettings>(path, options.settings)
.pipe(catchError(err => this.restExtractor.handleError(err)))
}
// ---------------------------------------------------------------------------
getChannelSettings (options: {
channelHandle: string
raw: boolean
}) {
const path = PlayerSettingsService.BASE_PLAYER_SETTINGS_URL + 'video-channels/' + options.channelHandle
let params = new HttpParams()
if (options.raw) params = params.set('raw', 'true')
return this.authHttp.get<PlayerChannelSettings>(path, { params })
.pipe(catchError(err => this.restExtractor.handleError(err)))
}
updateChannelSettings (options: {
channelHandle: string
settings: PlayerChannelSettingsUpdate
}) {
const path = PlayerSettingsService.BASE_PLAYER_SETTINGS_URL + 'video-channels/' + options.channelHandle
return this.authHttp.put<PlayerChannelSettings>(path, options.settings)
.pipe(catchError(err => this.restExtractor.handleError(err)))
}
}

View file

@ -1,144 +1,98 @@
import { NgClass, NgIf } from '@angular/common' import { AfterViewInit, Component, inject } from '@angular/core'
import { AfterViewInit, Component, OnInit, inject } from '@angular/core'
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
import { Router } from '@angular/router' import { Router } from '@angular/router'
import { AuthService, HooksService, Notifier } from '@app/core' import { AuthService, HooksService, Notifier } from '@app/core'
import {
VIDEO_CHANNEL_DESCRIPTION_VALIDATOR,
VIDEO_CHANNEL_DISPLAY_NAME_VALIDATOR,
VIDEO_CHANNEL_NAME_VALIDATOR,
VIDEO_CHANNEL_SUPPORT_VALIDATOR
} from '@app/shared/form-validators/video-channel-validators'
import { FormReactiveService } from '@app/shared/shared-forms/form-reactive.service'
import { VideoChannel } from '@app/shared/shared-main/channel/video-channel.model' import { VideoChannel } from '@app/shared/shared-main/channel/video-channel.model'
import { VideoChannelService } from '@app/shared/shared-main/channel/video-channel.service' import { VideoChannelService } from '@app/shared/shared-main/channel/video-channel.service'
import { AlertComponent } from '@app/shared/shared-main/common/alert.component' import { HttpStatusCode, PlayerChannelSettings, VideoChannelCreate } from '@peertube/peertube-models'
import { HttpStatusCode, VideoChannelCreate } from '@peertube/peertube-models'
import { of } from 'rxjs' import { of } from 'rxjs'
import { switchMap } from 'rxjs/operators' import { switchMap } from 'rxjs/operators'
import { ActorAvatarEditComponent } from '../shared-actor-image-edit/actor-avatar-edit.component' import { PlayerSettingsService } from '../shared-video/player-settings.service'
import { ActorBannerEditComponent } from '../shared-actor-image-edit/actor-banner-edit.component' import { FormValidatedOutput, VideoChannelEditComponent } from './video-channel-edit.component'
import { MarkdownTextareaComponent } from '../shared-forms/markdown-textarea.component'
import { PeertubeCheckboxComponent } from '../shared-forms/peertube-checkbox.component'
import { HelpComponent } from '../shared-main/buttons/help.component'
import { MarkdownHintComponent } from '../shared-main/text/markdown-hint.component'
import { VideoChannelEdit } from './video-channel-edit'
@Component({ @Component({
templateUrl: './video-channel-edit.component.html', template: `
styleUrls: [ './video-channel-edit.component.scss' ], <my-video-channel-edit
mode="create" [channel]="channel" [rawPlayerSettings]="rawPlayerSettings" [error]="error"
(formValidated)="onFormValidated($event)"
>
</my-video-channel-edit>
`,
imports: [ imports: [
NgIf, VideoChannelEditComponent
FormsModule, ],
ReactiveFormsModule, providers: [
ActorBannerEditComponent, PlayerSettingsService
ActorAvatarEditComponent,
NgClass,
HelpComponent,
MarkdownTextareaComponent,
PeertubeCheckboxComponent,
AlertComponent,
MarkdownHintComponent
] ]
}) })
export class VideoChannelCreateComponent extends VideoChannelEdit implements OnInit, AfterViewInit { export class VideoChannelCreateComponent implements AfterViewInit {
protected formReactiveService = inject(FormReactiveService)
private authService = inject(AuthService) private authService = inject(AuthService)
private notifier = inject(Notifier) private notifier = inject(Notifier)
private router = inject(Router) private router = inject(Router)
private videoChannelService = inject(VideoChannelService) private videoChannelService = inject(VideoChannelService)
private hooks = inject(HooksService) private hooks = inject(HooksService)
private playerSettingsService = inject(PlayerSettingsService)
error: string error: string
videoChannel = new VideoChannel({}) channel = new VideoChannel({})
rawPlayerSettings: PlayerChannelSettings = {
private avatar: FormData theme: 'instance-default'
private banner: FormData
ngOnInit () {
this.buildForm({
'name': VIDEO_CHANNEL_NAME_VALIDATOR,
'display-name': VIDEO_CHANNEL_DISPLAY_NAME_VALIDATOR,
'description': VIDEO_CHANNEL_DESCRIPTION_VALIDATOR,
'support': VIDEO_CHANNEL_SUPPORT_VALIDATOR
})
} }
ngAfterViewInit () { ngAfterViewInit () {
this.hooks.runAction('action:video-channel-create.init', 'video-channel') this.hooks.runAction('action:video-channel-create.init', 'video-channel')
} }
formValidated () { onFormValidated (output: FormValidatedOutput) {
this.error = undefined this.error = undefined
const body = this.form.value const channelCreate: VideoChannelCreate = {
const videoChannelCreate: VideoChannelCreate = { name: output.channel.name,
name: body.name, displayName: output.channel.displayName,
displayName: body['display-name'], description: output.channel.description,
description: body.description || null, support: output.channel.support
support: body.support || null
} }
this.videoChannelService.createVideoChannel(videoChannelCreate) this.videoChannelService.createVideoChannel(channelCreate)
.pipe( .pipe(
switchMap(() => this.uploadAvatar()), switchMap(() => {
switchMap(() => this.uploadBanner()) return this.playerSettingsService.updateChannelSettings({
channelHandle: output.channel.name,
settings: {
theme: output.playerSettings.theme
}
})
}),
switchMap(() => this.uploadAvatar(output.channel.name, output.avatar)),
switchMap(() => this.uploadBanner(output.channel.name, output.banner))
).subscribe({ ).subscribe({
next: () => { next: () => {
this.authService.refreshUserInformation() this.authService.refreshUserInformation()
this.notifier.success($localize`Video channel ${videoChannelCreate.displayName} created.`) this.notifier.success($localize`Video channel ${channelCreate.displayName} created.`)
this.router.navigate([ '/my-library', 'video-channels' ]) this.router.navigate([ '/my-library', 'video-channels' ])
}, },
error: err => { error: err => {
let message = err.message
if (err.status === HttpStatusCode.CONFLICT_409) { if (err.status === HttpStatusCode.CONFLICT_409) {
this.error = $localize`This name already exists on this platform.` message = $localize`Channel name "${channelCreate.name}" already exists on this platform.`
return
} }
this.error = err.message this.notifier.error(message)
} }
}) })
} }
onAvatarChange (formData: FormData) { private uploadAvatar (username: string, avatar?: FormData) {
this.avatar = formData if (!avatar) return of(undefined)
return this.videoChannelService.changeVideoChannelImage(username, avatar, 'avatar')
} }
onAvatarDelete () { private uploadBanner (username: string, banner?: FormData) {
this.avatar = null if (!banner) return of(undefined)
}
onBannerChange (formData: FormData) { return this.videoChannelService.changeVideoChannelImage(username, banner, 'banner')
this.banner = formData
}
onBannerDelete () {
this.banner = null
}
isCreation () {
return true
}
getFormButtonTitle () {
return $localize`Create your channel`
}
getUsername () {
return this.form.value.name
}
private uploadAvatar () {
if (!this.avatar) return of(undefined)
return this.videoChannelService.changeVideoChannelImage(this.getUsername(), this.avatar, 'avatar')
}
private uploadBanner () {
if (!this.banner) return of(undefined)
return this.videoChannelService.changeVideoChannelImage(this.getUsername(), this.banner, 'banner')
} }
} }

View file

@ -1,11 +1,11 @@
<my-alert *ngIf="error" type="danger">{{ error }}</my-alert> <my-alert *ngIf="error()" type="danger">{{ error() }}</my-alert>
<div class="pt-4"> <div class="pt-4">
<form (ngSubmit)="formValidated()" [formGroup]="form"> <form (ngSubmit)="onFormValidated()" [formGroup]="form">
<div class="pt-two-cols"> <!-- channel grid --> <div class="pt-two-cols"> <!-- channel grid -->
<div class="title-col"> <div class="title-col">
@if (isCreation()) { @if (mode() === 'create') {
<h2 i18n>NEW CHANNEL</h2> <h2 i18n>NEW CHANNEL</h2>
} @else { } @else {
<h2 i18n>UPDATE CHANNEL</h2> <h2 i18n>UPDATE CHANNEL</h2>
@ -14,40 +14,40 @@
<div class="content-col"> <div class="content-col">
<my-actor-banner-edit <my-actor-banner-edit
*ngIf="videoChannel" [previewImage]="isCreation()" class="d-block mb-4" *ngIf="channel()" previewImage="true" class="d-block mb-4"
[bannerUrl]="videoChannel?.bannerUrl" (bannerChange)="onBannerChange($event)" (bannerDelete)="onBannerDelete()" [bannerUrl]="channel()?.bannerUrl" (bannerChange)="onBannerChange($event)" (bannerDelete)="onBannerDelete()"
></my-actor-banner-edit> ></my-actor-banner-edit>
<my-actor-avatar-edit <my-actor-avatar-edit
*ngIf="videoChannel" class="d-block mb-4" actorType="channel" *ngIf="channel()" class="d-block mb-4" actorType="channel"
[displayName]="videoChannel.displayName" [previewImage]="isCreation()" [avatars]="videoChannel.avatars" [displayName]="channel().displayName" previewImage="true" [avatars]="channel().avatars"
[username]="!isCreation() && videoChannel.name" [subscribers]="!isCreation() && videoChannel.followersCount" [username]="mode() === 'update' && channel().name" [subscribers]="mode() === 'update' && channel().followersCount"
(avatarChange)="onAvatarChange($event)" (avatarDelete)="onAvatarDelete()" (avatarChange)="onAvatarChange($event)" (avatarDelete)="onAvatarDelete()"
></my-actor-avatar-edit> ></my-actor-avatar-edit>
<div class="form-group" *ngIf="isCreation()"> <div class="form-group" *ngIf="mode() === 'create'">
<label i18n for="name">Name</label> <label i18n for="name">Name</label>
<div class="input-group"> <div class="input-group">
<input <input
type="text" id="name" i18n-placeholder placeholder="Example: my_channel" type="text" id="name" i18n-placeholder placeholder="Example: my_channel"
formControlName="name" [ngClass]="{ 'input-error': formErrors['name'] }" class="form-control w-auto flex-grow-1 d-block" formControlName="name" [ngClass]="{ 'input-error': formErrors.name }" class="form-control w-auto flex-grow-1 d-block"
> >
<div class="input-group-text">&#64;{{ instanceHost }}</div> <div class="input-group-text">&#64;{{ instanceHost }}</div>
</div> </div>
<div *ngIf="formErrors['name']" class="form-error" role="alert"> <div *ngIf="formErrors.name" class="form-error" role="alert">
{{ formErrors['name'] }} {{ formErrors.name }}
</div> </div>
</div> </div>
<div class="form-group"> <div class="form-group">
<label i18n for="display-name">Display name</label> <label i18n for="displayName">Display name</label>
<input <input
type="text" id="display-name" class="form-control" type="text" id="displayName" class="form-control"
formControlName="display-name" [ngClass]="{ 'input-error': formErrors['display-name'] }" formControlName="displayName" [ngClass]="{ 'input-error': formErrors.displayName }"
> >
<div *ngIf="formErrors['display-name']" class="form-error" role="alert"> <div *ngIf="formErrors.displayName" class="form-error" role="alert">
{{ formErrors['display-name'] }} {{ formErrors.displayName }}
</div> </div>
</div> </div>
@ -58,7 +58,7 @@
<my-markdown-textarea <my-markdown-textarea
inputId="description" formControlName="description" inputId="description" formControlName="description"
markdownType="enhanced" [formError]="formErrors['description']" withEmoji="true" withHtml="true" markdownType="enhanced" [formError]="formErrors.description" withEmoji="true" withHtml="true"
></my-markdown-textarea> ></my-markdown-textarea>
<div *ngIf="formErrors.description" class="form-error" role="alert"> <div *ngIf="formErrors.description" class="form-error" role="alert">
@ -75,7 +75,7 @@
<my-markdown-textarea <my-markdown-textarea
inputId="support" formControlName="support" inputId="support" formControlName="support"
markdownType="enhanced" [formError]="formErrors['support']" withEmoji="true" withHtml="true" markdownType="enhanced" [formError]="formErrors.support" withEmoji="true" withHtml="true"
></my-markdown-textarea> ></my-markdown-textarea>
</div> </div>
@ -86,6 +86,13 @@
></my-peertube-checkbox> ></my-peertube-checkbox>
</div> </div>
<div class="form-group">
<label i18n for="playerTheme">Player Theme</label>
<my-select-player-theme formControlName="playerTheme" inputId="playerTheme" mode="channel">
</my-select-player-theme>
</div>
<input type="submit" class="peertube-button primary-button mt-4" value="{{ getFormButtonTitle() }}" [disabled]="!form.valid"> <input type="submit" class="peertube-button primary-button mt-4" value="{{ getFormButtonTitle() }}" [disabled]="!form.valid">
</div> </div>
</div> </div>

View file

@ -1,16 +1,16 @@
@use '_variables' as *; @use "_variables" as *;
@use '_mixins' as *; @use "_mixins" as *;
@use '_form-mixins' as *; @use "_form-mixins" as *;
my-actor-banner-edit { my-actor-banner-edit {
max-width: 500px; max-width: 500px;
} }
input[type=text] { input[type="text"] {
@include peertube-input-text(340px); @include peertube-input-text(340px);
} }
input[type=submit] { input[type="submit"] {
@include margin-left(auto); @include margin-left(auto);
} }
@ -18,6 +18,8 @@ input[type=submit] {
max-width: 500px; max-width: 500px;
} }
.peertube-select-container { my-select-player-theme {
@include peertube-select-container(340px); display: block;
@include responsive-width(340px);
} }

View file

@ -0,0 +1,178 @@
import { CommonModule } from '@angular/common'
import { Component, inject, input, OnInit, output } from '@angular/core'
import { FormControl, FormGroup, FormsModule, ReactiveFormsModule } from '@angular/forms'
import { VideoChannel } from '@app/shared/shared-main/channel/video-channel.model'
import { PlayerChannelSettings } from '@peertube/peertube-models'
import { BuildFormArgumentTyped, FormReactiveErrorsTyped, FormReactiveMessagesTyped } from '../form-validators/form-validator.model'
import {
VIDEO_CHANNEL_DESCRIPTION_VALIDATOR,
VIDEO_CHANNEL_DISPLAY_NAME_VALIDATOR,
VIDEO_CHANNEL_NAME_VALIDATOR,
VIDEO_CHANNEL_SUPPORT_VALIDATOR
} from '../form-validators/video-channel-validators'
import { ActorAvatarEditComponent } from '../shared-actor-image-edit/actor-avatar-edit.component'
import { ActorBannerEditComponent } from '../shared-actor-image-edit/actor-banner-edit.component'
import { FormReactiveService } from '../shared-forms/form-reactive.service'
import { MarkdownTextareaComponent } from '../shared-forms/markdown-textarea.component'
import { PeertubeCheckboxComponent } from '../shared-forms/peertube-checkbox.component'
import { SelectPlayerThemeComponent } from '../shared-forms/select/select-player-theme.component'
import { HelpComponent } from '../shared-main/buttons/help.component'
import { AlertComponent } from '../shared-main/common/alert.component'
import { MarkdownHintComponent } from '../shared-main/text/markdown-hint.component'
type Form = {
name: FormControl<string>
displayName: FormControl<string>
description: FormControl<string>
support: FormControl<string>
playerTheme: FormControl<PlayerChannelSettings['theme']>
bulkVideosSupportUpdate: FormControl<boolean>
}
export type FormValidatedOutput = {
avatar: FormData
banner: FormData
playerSettings: {
theme: PlayerChannelSettings['theme']
}
channel: {
name: string
displayName: string
description: string
support: string
bulkVideosSupportUpdate: boolean
}
}
@Component({
selector: 'my-video-channel-edit',
templateUrl: './video-channel-edit.component.html',
styleUrls: [ './video-channel-edit.component.scss' ],
imports: [
CommonModule,
FormsModule,
ReactiveFormsModule,
ActorBannerEditComponent,
ActorAvatarEditComponent,
HelpComponent,
MarkdownTextareaComponent,
PeertubeCheckboxComponent,
AlertComponent,
MarkdownHintComponent,
SelectPlayerThemeComponent
]
})
export class VideoChannelEditComponent implements OnInit {
private formReactiveService = inject(FormReactiveService)
readonly mode = input.required<'create' | 'update'>()
readonly channel = input.required<VideoChannel>()
readonly rawPlayerSettings = input.required<PlayerChannelSettings>()
readonly error = input<string>()
readonly formValidated = output<FormValidatedOutput>()
form: FormGroup<Form>
formErrors: FormReactiveErrorsTyped<Form> = {}
validationMessages: FormReactiveMessagesTyped<Form> = {}
private avatar: FormData
private banner: FormData
private oldSupportField: string
ngOnInit () {
this.buildForm()
this.oldSupportField = this.channel().support
}
private buildForm () {
const obj: BuildFormArgumentTyped<Form> = {
name: this.mode() === 'create'
? VIDEO_CHANNEL_NAME_VALIDATOR
: null,
displayName: VIDEO_CHANNEL_DISPLAY_NAME_VALIDATOR,
description: VIDEO_CHANNEL_DESCRIPTION_VALIDATOR,
support: VIDEO_CHANNEL_SUPPORT_VALIDATOR,
bulkVideosSupportUpdate: null,
playerTheme: null
}
const defaultValues = {
displayName: this.channel().displayName,
description: this.channel().description,
support: this.channel().support,
playerTheme: this.rawPlayerSettings().theme
}
const {
form,
formErrors,
validationMessages
} = this.formReactiveService.buildForm<Form>(obj, defaultValues)
this.form = form
this.formErrors = formErrors
this.validationMessages = validationMessages
}
getFormButtonTitle () {
if (this.mode() === 'update') {
return $localize`Update ${this.channel().name}`
}
return $localize`Create your channel`
}
onAvatarChange (formData: FormData) {
this.avatar = formData
}
onAvatarDelete () {
this.avatar = null
}
onBannerChange (formData: FormData) {
this.banner = formData
}
onBannerDelete () {
this.banner = null
}
get instanceHost () {
return window.location.host
}
isBulkUpdateVideosDisplayed () {
if (this.mode() === 'create') return false
if (this.oldSupportField === undefined) return false
return this.oldSupportField !== this.form.value.support
}
onFormValidated () {
const body = this.form.value
this.formValidated.emit({
avatar: this.avatar,
banner: this.banner,
playerSettings: {
theme: body.playerTheme
},
channel: {
name: body.name,
displayName: body.displayName,
description: body.description || null,
support: body.support || null,
bulkVideosSupportUpdate: this.mode() === 'update'
? body.bulkVideosSupportUpdate || false
: undefined
}
})
}
}

View file

@ -1,18 +0,0 @@
import { FormReactive } from '@app/shared/shared-forms/form-reactive'
import { VideoChannel } from '@app/shared/shared-main/channel/video-channel.model'
export abstract class VideoChannelEdit extends FormReactive {
videoChannel: VideoChannel
abstract isCreation (): boolean
abstract getFormButtonTitle (): string
get instanceHost () {
return window.location.host
}
// Should be implemented by the child
isBulkUpdateVideosDisplayed () {
return false
}
}

View file

@ -1,92 +1,65 @@
import { NgClass, NgIf } from '@angular/common' import { AfterViewInit, Component, inject, OnDestroy, OnInit } from '@angular/core'
import { HttpErrorResponse } from '@angular/common/http'
import { AfterViewInit, Component, OnDestroy, OnInit, inject } from '@angular/core'
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
import { ActivatedRoute } from '@angular/router' import { ActivatedRoute } from '@angular/router'
import { AuthService, HooksService, Notifier, RedirectService } from '@app/core' import { AuthService, HooksService, Notifier, RedirectService } from '@app/core'
import { genericUploadErrorHandler } from '@app/helpers' import { genericUploadErrorHandler } from '@app/helpers'
import {
VIDEO_CHANNEL_DESCRIPTION_VALIDATOR,
VIDEO_CHANNEL_DISPLAY_NAME_VALIDATOR,
VIDEO_CHANNEL_SUPPORT_VALIDATOR
} from '@app/shared/form-validators/video-channel-validators'
import { FormReactiveService } from '@app/shared/shared-forms/form-reactive.service'
import { VideoChannelService } from '@app/shared/shared-main/channel/video-channel.service' import { VideoChannelService } from '@app/shared/shared-main/channel/video-channel.service'
import { AlertComponent } from '@app/shared/shared-main/common/alert.component'
import { shallowCopy } from '@peertube/peertube-core-utils' import { shallowCopy } from '@peertube/peertube-core-utils'
import { VideoChannelUpdate } from '@peertube/peertube-models' import { PlayerChannelSettings, VideoChannelUpdate } from '@peertube/peertube-models'
import { Subscription } from 'rxjs' import { catchError, forkJoin, Subscription, switchMap, tap, throwError } from 'rxjs'
import { ActorAvatarEditComponent } from '../shared-actor-image-edit/actor-avatar-edit.component' import { VideoChannel } from '../shared-main/channel/video-channel.model'
import { ActorBannerEditComponent } from '../shared-actor-image-edit/actor-banner-edit.component' import { PlayerSettingsService } from '../shared-video/player-settings.service'
import { MarkdownTextareaComponent } from '../shared-forms/markdown-textarea.component' import { FormValidatedOutput, VideoChannelEditComponent } from './video-channel-edit.component'
import { PeertubeCheckboxComponent } from '../shared-forms/peertube-checkbox.component'
import { HelpComponent } from '../shared-main/buttons/help.component'
import { MarkdownHintComponent } from '../shared-main/text/markdown-hint.component'
import { VideoChannelEdit } from './video-channel-edit'
@Component({ @Component({
selector: 'my-video-channel-update', selector: 'my-video-channel-update',
templateUrl: './video-channel-edit.component.html', template: `
styleUrls: [ './video-channel-edit.component.scss' ], @if (channel && rawPlayerSettings) {
<my-video-channel-edit
mode="update" [channel]="channel" [rawPlayerSettings]="rawPlayerSettings" [error]="error"
(formValidated)="onFormValidated($event)"
>
</my-video-channel-edit>
}
`,
imports: [ imports: [
NgIf, VideoChannelEditComponent
FormsModule, ],
ReactiveFormsModule, providers: [
ActorBannerEditComponent, PlayerSettingsService
ActorAvatarEditComponent,
NgClass,
HelpComponent,
MarkdownTextareaComponent,
PeertubeCheckboxComponent,
AlertComponent,
MarkdownHintComponent
] ]
}) })
export class VideoChannelUpdateComponent extends VideoChannelEdit implements OnInit, AfterViewInit, OnDestroy { export class VideoChannelUpdateComponent implements OnInit, AfterViewInit, OnDestroy {
protected formReactiveService = inject(FormReactiveService)
private authService = inject(AuthService) private authService = inject(AuthService)
private notifier = inject(Notifier) private notifier = inject(Notifier)
private route = inject(ActivatedRoute) private route = inject(ActivatedRoute)
private videoChannelService = inject(VideoChannelService) private videoChannelService = inject(VideoChannelService)
private playerSettingsService = inject(PlayerSettingsService)
private redirectService = inject(RedirectService) private redirectService = inject(RedirectService)
private hooks = inject(HooksService) private hooks = inject(HooksService)
channel: VideoChannel
rawPlayerSettings: PlayerChannelSettings
error: string error: string
private paramsSub: Subscription private paramsSub: Subscription
private oldSupportField: string
ngOnInit () { ngOnInit () {
this.buildForm({
'display-name': VIDEO_CHANNEL_DISPLAY_NAME_VALIDATOR,
'description': VIDEO_CHANNEL_DESCRIPTION_VALIDATOR,
'support': VIDEO_CHANNEL_SUPPORT_VALIDATOR,
'bulkVideosSupportUpdate': null
})
this.paramsSub = this.route.params.subscribe(routeParams => { this.paramsSub = this.route.params.subscribe(routeParams => {
const videoChannelName = routeParams['videoChannelName'] const videoChannelName = routeParams['videoChannelName']
this.videoChannelService.getVideoChannel(videoChannelName) forkJoin([
.subscribe({ this.videoChannelService.getVideoChannel(videoChannelName),
next: videoChannelToUpdate => { this.playerSettingsService.getChannelSettings({ channelHandle: videoChannelName, raw: true })
this.videoChannel = videoChannelToUpdate ]).subscribe({
next: ([ channel, rawPlayerSettings ]) => {
this.channel = channel
this.rawPlayerSettings = rawPlayerSettings
this.hooks.runAction('action:video-channel-update.video-channel.loaded', 'video-channel', { videoChannel: this.videoChannel }) this.hooks.runAction('action:video-channel-update.video-channel.loaded', 'video-channel', { videoChannel: this.channel })
},
this.oldSupportField = videoChannelToUpdate.support error: err => this.notifier.error(err.message)
})
this.form.patchValue({
'display-name': videoChannelToUpdate.displayName,
'description': videoChannelToUpdate.description,
'support': videoChannelToUpdate.support
})
},
error: err => {
this.error = err.message
}
})
}) })
} }
@ -98,112 +71,84 @@ export class VideoChannelUpdateComponent extends VideoChannelEdit implements OnI
if (this.paramsSub) this.paramsSub.unsubscribe() if (this.paramsSub) this.paramsSub.unsubscribe()
} }
formValidated () { onFormValidated (output: FormValidatedOutput) {
this.error = undefined this.error = undefined
const body = this.form.value
const videoChannelUpdate: VideoChannelUpdate = { const videoChannelUpdate: VideoChannelUpdate = {
displayName: body['display-name'], displayName: output.channel.displayName,
description: body.description || null, description: output.channel.description,
support: body.support || null, support: output.channel.support,
bulkVideosSupportUpdate: body.bulkVideosSupportUpdate || false bulkVideosSupportUpdate: output.channel.bulkVideosSupportUpdate
} }
this.videoChannelService.updateVideoChannel(this.videoChannel.name, videoChannelUpdate) this.videoChannelService.updateVideoChannel(this.channel.name, videoChannelUpdate)
.pipe(
switchMap(() => {
return this.playerSettingsService.updateChannelSettings({
channelHandle: this.channel.name,
settings: {
theme: output.playerSettings.theme
}
})
}),
switchMap(() => this.updateOrDeleteAvatar(output.avatar)),
switchMap(() => this.updateOrDeleteBanner(output.banner))
)
.subscribe({ .subscribe({
next: () => { next: () => {
// So my-actor-avatar component detects changes
this.channel = shallowCopy(this.channel)
this.authService.refreshUserInformation() this.authService.refreshUserInformation()
this.notifier.success($localize`Video channel ${videoChannelUpdate.displayName} updated.`) this.notifier.success($localize`Video channel ${videoChannelUpdate.displayName} updated.`)
this.redirectService.redirectToPreviousRoute('/c/' + this.videoChannel.name) this.redirectService.redirectToPreviousRoute('/c/' + this.channel.name)
},
error: err => {
this.error = err.message
}
})
}
onAvatarChange (formData: FormData) {
this.videoChannelService.changeVideoChannelImage(this.videoChannel.name, formData, 'avatar')
.subscribe({
next: data => {
this.notifier.success($localize`Avatar changed.`)
this.videoChannel.updateAvatar(data.avatars)
// So my-actor-avatar component detects changes
this.videoChannel = shallowCopy(this.videoChannel)
},
error: (err: HttpErrorResponse) =>
genericUploadErrorHandler({
err,
name: $localize`avatar`,
notifier: this.notifier
})
})
}
onAvatarDelete () {
this.videoChannelService.deleteVideoChannelImage(this.videoChannel.name, 'avatar')
.subscribe({
next: () => {
this.notifier.success($localize`Avatar deleted.`)
this.videoChannel.resetAvatar()
// So my-actor-avatar component detects changes
this.videoChannel = shallowCopy(this.videoChannel)
}, },
error: err => this.notifier.error(err.message) error: err => this.notifier.error(err.message)
}) })
} }
onBannerChange (formData: FormData) { private updateOrDeleteAvatar (avatar: FormData) {
this.videoChannelService.changeVideoChannelImage(this.videoChannel.name, formData, 'banner') if (!avatar) {
.subscribe({ return this.videoChannelService.deleteVideoChannelImage(this.channel.name, 'avatar')
next: data => { .pipe(tap(() => this.channel.resetAvatar()))
this.notifier.success($localize`Banner changed.`) }
this.videoChannel.updateBanner(data.banners) return this.videoChannelService.changeVideoChannelImage(this.channel.name, avatar, 'avatar')
}, .pipe(
tap(data => this.channel.updateAvatar(data.avatars)),
error: (err: HttpErrorResponse) => catchError(err =>
genericUploadErrorHandler({ throwError(() => {
err, return new Error(genericUploadErrorHandler({
name: $localize`banner`, err,
notifier: this.notifier name: $localize`avatar`,
notifier: this.notifier
}))
}) })
}) )
)
} }
onBannerDelete () { private updateOrDeleteBanner (banner: FormData) {
this.videoChannelService.deleteVideoChannelImage(this.videoChannel.name, 'banner') if (!banner) {
.subscribe({ return this.videoChannelService.deleteVideoChannelImage(this.channel.name, 'banner')
next: () => { .pipe(tap(() => this.channel.resetBanner()))
this.notifier.success($localize`Banner deleted.`) }
this.videoChannel.resetBanner() return this.videoChannelService.changeVideoChannelImage(this.channel.name, banner, 'banner')
}, .pipe(
tap(data => this.channel.updateBanner(data.banners)),
error: err => this.notifier.error(err.message) catchError(err =>
}) throwError(() => {
} return new Error(genericUploadErrorHandler({
err,
isCreation () { name: $localize`banner`,
return false notifier: this.notifier
} }))
})
getFormButtonTitle () { )
return $localize`Update ${this.videoChannel?.name}` )
}
isBulkUpdateVideosDisplayed () {
if (this.oldSupportField === undefined) return false
return this.oldSupportField !== this.form.value['support']
} }
} }

View file

@ -103,7 +103,7 @@ export class PeerTubePlayer {
await this.buildPlayerIfNeeded() await this.buildPlayerIfNeeded()
for (const theme of [ 'default', 'lucide' ]) { for (const theme of [ 'galaxy', 'lucide' ]) {
this.player.removeClass('vjs-peertube-theme-' + theme) this.player.removeClass('vjs-peertube-theme-' + theme)
} }

View file

@ -313,11 +313,11 @@ $chapter-marker-size: 9px;
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// PeerTube Default Theme // PeerTube Galaxy (original) Theme
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Play/pause animations // Play/pause animations
.video-js.vjs-peertube-theme-default.vjs-has-started .vjs-play-control { .video-js.vjs-peertube-theme-galaxy.vjs-has-started .vjs-play-control {
&.vjs-playing { &.vjs-playing {
animation: remove-pause-button 0.25s ease; animation: remove-pause-button 0.25s ease;
} }
@ -345,7 +345,7 @@ $chapter-marker-size: 9px;
} }
} }
.video-js.vjs-peertube-theme-default .vjs-control-bar { .video-js.vjs-peertube-theme-galaxy .vjs-control-bar {
background: linear-gradient(rgba(0, 0, 0, 0.2), rgba(0, 0, 0, 0.6)); background: linear-gradient(rgba(0, 0, 0, 0.2), rgba(0, 0, 0, 0.6));
box-shadow: 0 -15px 40px 10px rgba(0, 0, 0, 0.2); box-shadow: 0 -15px 40px 10px rgba(0, 0, 0, 0.2);

View file

@ -166,16 +166,16 @@ body {
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Default theme // Galaxy (original) theme
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
.video-js.vjs-peertube-theme-default { .video-js.vjs-peertube-theme-galaxy {
.vjs-big-play-button { .vjs-big-play-button {
border: 2px solid #fff; border: 2px solid #fff;
} }
} }
.video-js.vjs-peertube-theme-default.vjs-size-570 { .video-js.vjs-peertube-theme-galaxy.vjs-size-570 {
.vjs-big-play-button { .vjs-big-play-button {
--big-play-button-size: 78px; --big-play-button-size: 78px;
--big-play-button-icon-size: 32px; --big-play-button-icon-size: 32px;
@ -184,7 +184,7 @@ body {
} }
} }
.video-js.vjs-peertube-theme-default.vjs-size-350 { .video-js.vjs-peertube-theme-galaxy.vjs-size-350 {
.vjs-big-play-button { .vjs-big-play-button {
--big-play-button-size: 46px; --big-play-button-size: 46px;
--big-play-button-icon-size: 20px; --big-play-button-icon-size: 20px;

View file

@ -1,13 +1,12 @@
import debug from 'debug' import debug from 'debug'
import videojs from 'video.js' import videojs from 'video.js'
import MenuButton from 'video.js/dist/types/menu/menu-button'
import { VideojsComponent, VideojsMenu, VideojsMenuItem, VideojsMenuItemOptions, VideojsPlayer } from '../../types' import { VideojsComponent, VideojsMenu, VideojsMenuItem, VideojsMenuItemOptions, VideojsPlayer } from '../../types'
import { toTitleCase } from '../common' import { toTitleCase } from '../common'
import { SettingsDialog } from './settings-dialog' import { SettingsDialog } from './settings-dialog'
import { SettingsButton } from './settings-menu-button' import { SettingsButton } from './settings-menu-button'
import { SettingsPanel } from './settings-panel' import { SettingsPanel } from './settings-panel'
import { SettingsPanelChild } from './settings-panel-child' import { SettingsPanelChild } from './settings-panel-child'
import Button from 'video.js/dist/types/button'
import MenuButton from 'video.js/dist/types/menu/menu-button'
const debugLogger = debug('peertube:player:settings') const debugLogger = debug('peertube:player:settings')

View file

@ -1,11 +1,8 @@
import { LiveVideoLatencyModeType, VideoChapter, VideoFile } from '@peertube/peertube-models' import { LiveVideoLatencyModeType, PlayerMode, PlayerTheme, VideoChapter, VideoFile } from '@peertube/peertube-models'
import { PluginsManager } from '@root-helpers/plugins-manager' import { PluginsManager } from '@root-helpers/plugins-manager'
import { PeerTubeDockPluginOptions } from '../shared/dock/peertube-dock-plugin' import { PeerTubeDockPluginOptions } from '../shared/dock/peertube-dock-plugin'
import { PlaylistPluginOptions, VideoJSCaption, VideojsPlayer, VideoJSStoryboard } from './peertube-videojs-typings' import { PlaylistPluginOptions, VideoJSCaption, VideojsPlayer, VideoJSStoryboard } from './peertube-videojs-typings'
export type PlayerMode = 'web-video' | 'p2p-media-loader'
export type PeerTubePlayerTheme = 'default' | 'lucide'
export type PeerTubePlayerConstructorOptions = { export type PeerTubePlayerConstructorOptions = {
playerElement: () => HTMLVideoElement playerElement: () => HTMLVideoElement
@ -53,7 +50,7 @@ export type PeerTubePlayerConstructorOptions = {
export type PeerTubePlayerLoadOptions = { export type PeerTubePlayerLoadOptions = {
mode: PlayerMode mode: PlayerMode
theme: PeerTubePlayerTheme theme: PlayerTheme
startTime?: number | string startTime?: number | string
stopTime?: number | string stopTime?: number | string

View file

@ -1,4 +1,4 @@
import { VideoChapter, VideoFile, VideoPlaylist, VideoPlaylistElement } from '@peertube/peertube-models' import { PlayerMode, VideoChapter, VideoFile, VideoPlaylist, VideoPlaylistElement } from '@peertube/peertube-models'
import type { HlsConfig, Level, Loader, LoaderContext } from 'hls.js' import type { HlsConfig, Level, Loader, LoaderContext } from 'hls.js'
import type { CoreConfig } from 'p2p-media-loader-core' import type { CoreConfig } from 'p2p-media-loader-core'
import type { HlsJsP2PEngine } from 'p2p-media-loader-hlsjs' import type { HlsJsP2PEngine } from 'p2p-media-loader-hlsjs'
@ -34,12 +34,11 @@ import { SegmentValidator } from '../shared/p2p-media-loader/segment-validator'
import { PeerTubePlugin } from '../shared/peertube/peertube-plugin' import { PeerTubePlugin } from '../shared/peertube/peertube-plugin'
import { PlaylistPlugin } from '../shared/playlist/playlist-plugin' import { PlaylistPlugin } from '../shared/playlist/playlist-plugin'
import { PeerTubeResolutionsPlugin } from '../shared/resolutions/peertube-resolutions-plugin' import { PeerTubeResolutionsPlugin } from '../shared/resolutions/peertube-resolutions-plugin'
import { SettingsButton } from '../shared/settings/settings-menu-button'
import { StatsCardOptions } from '../shared/stats/stats-card' import { StatsCardOptions } from '../shared/stats/stats-card'
import { StatsForNerdsPlugin } from '../shared/stats/stats-plugin' import { StatsForNerdsPlugin } from '../shared/stats/stats-plugin'
import { UpNextPlugin } from '../shared/upnext/upnext-plugin' import { UpNextPlugin } from '../shared/upnext/upnext-plugin'
import { WebVideoPlugin } from '../shared/web-video/web-video-plugin' import { WebVideoPlugin } from '../shared/web-video/web-video-plugin'
import { PlayerMode } from './peertube-player-options'
import { SettingsButton } from '../shared/settings/settings-menu-button'
declare module 'video.js' { declare module 'video.js' {
export interface VideoJsPlayer { export interface VideoJsPlayer {

View file

@ -69,7 +69,7 @@ export class PeerTubeEmbed {
this.peertubePlugin = new PeerTubePlugin(this.http) this.peertubePlugin = new PeerTubePlugin(this.http)
this.peertubeTheme = new PeerTubeTheme(this.peertubePlugin) this.peertubeTheme = new PeerTubeTheme(this.peertubePlugin)
this.playerHTML = new PlayerHTML(videoWrapperId) this.playerHTML = new PlayerHTML(videoWrapperId)
this.playerOptionsBuilder = new PlayerOptionsBuilder(this.playerHTML, this.videoFetcher, this.peertubePlugin) this.playerOptionsBuilder = new PlayerOptionsBuilder(this.playerHTML, this.videoFetcher, this.peertubePlugin, this.config)
this.liveManager = new LiveManager(this.playerHTML) this.liveManager = new LiveManager(this.playerHTML)
this.requiresPassword = false this.requiresPassword = false
@ -220,10 +220,18 @@ export class PeerTubeEmbed {
videoResponse, videoResponse,
captionsPromise, captionsPromise,
chaptersPromise, chaptersPromise,
storyboardsPromise storyboardsPromise,
playerSettingsPromise
} = await this.videoFetcher.loadVideo({ videoId: uuid, videoPassword: this.videoPassword }) } = await this.videoFetcher.loadVideo({ videoId: uuid, videoPassword: this.videoPassword })
return this.buildVideoPlayer({ videoResponse, captionsPromise, chaptersPromise, storyboardsPromise, forceAutoplay }) return this.buildVideoPlayer({
videoResponse,
captionsPromise,
chaptersPromise,
storyboardsPromise,
playerSettingsPromise,
forceAutoplay
})
} catch (err) { } catch (err) {
if (await this.handlePasswordError(err)) this.loadVideoAndBuildPlayer({ ...options }) if (await this.handlePasswordError(err)) this.loadVideoAndBuildPlayer({ ...options })
else this.playerHTML.displayError(err.message, await this.translationsPromise) else this.playerHTML.displayError(err.message, await this.translationsPromise)
@ -235,9 +243,10 @@ export class PeerTubeEmbed {
storyboardsPromise: Promise<Response> storyboardsPromise: Promise<Response>
captionsPromise: Promise<Response> captionsPromise: Promise<Response>
chaptersPromise: Promise<Response> chaptersPromise: Promise<Response>
playerSettingsPromise: Promise<Response>
forceAutoplay: boolean forceAutoplay: boolean
}) { }) {
const { videoResponse, captionsPromise, chaptersPromise, storyboardsPromise, forceAutoplay } = options const { videoResponse, captionsPromise, chaptersPromise, storyboardsPromise, playerSettingsPromise, forceAutoplay } = options
const videoInfoPromise = videoResponse.json() const videoInfoPromise = videoResponse.json()
.then(async (videoInfo: VideoDetails) => { .then(async (videoInfo: VideoDetails) => {
@ -259,13 +268,15 @@ export class PeerTubeEmbed {
translations, translations,
captionsResponse, captionsResponse,
chaptersResponse, chaptersResponse,
storyboardsResponse storyboardsResponse,
playerSettingsResponse
] = await Promise.all([ ] = await Promise.all([
videoInfoPromise, videoInfoPromise,
this.translationsPromise, this.translationsPromise,
captionsPromise, captionsPromise,
chaptersPromise, chaptersPromise,
storyboardsPromise, storyboardsPromise,
playerSettingsPromise,
this.buildPlayerIfNeeded() this.buildPlayerIfNeeded()
]) ])
@ -283,6 +294,7 @@ export class PeerTubeEmbed {
video, video,
captionsResponse, captionsResponse,
chaptersResponse, chaptersResponse,
playerSettingsResponse,
config: this.config, config: this.config,
translations, translations,

View file

@ -2,6 +2,9 @@ import { peertubeTranslate } from '@peertube/peertube-core-utils'
import { import {
HTMLServerConfig, HTMLServerConfig,
LiveVideo, LiveVideo,
PlayerMode,
PlayerTheme,
PlayerVideoSettings,
Storyboard, Storyboard,
Video, Video,
VideoCaption, VideoCaption,
@ -24,14 +27,7 @@ import {
UserLocalStorageKeys, UserLocalStorageKeys,
videoRequiresUserAuth videoRequiresUserAuth
} from '../../../root-helpers' } from '../../../root-helpers'
import { import { HLSOptions, PeerTubePlayerConstructorOptions, PeerTubePlayerLoadOptions, VideoJSCaption } from '../../player'
HLSOptions,
PeerTubePlayerConstructorOptions,
PeerTubePlayerLoadOptions,
PeerTubePlayerTheme,
PlayerMode,
VideoJSCaption
} from '../../player'
import { PeerTubePlugin } from './peertube-plugin' import { PeerTubePlugin } from './peertube-plugin'
import { PlayerHTML } from './player-html' import { PlayerHTML } from './player-html'
import { PlaylistTracker } from './playlist-tracker' import { PlaylistTracker } from './playlist-tracker'
@ -59,7 +55,7 @@ export class PlayerOptionsBuilder {
private p2pEnabled: boolean private p2pEnabled: boolean
private bigPlayBackgroundColor: string private bigPlayBackgroundColor: string
private foregroundColor: string private foregroundColor: string
private playerTheme: PeerTubePlayerTheme private playerTheme: PlayerTheme
private waitPasswordFromEmbedAPI = false private waitPasswordFromEmbedAPI = false
@ -69,7 +65,8 @@ export class PlayerOptionsBuilder {
constructor ( constructor (
private readonly playerHTML: PlayerHTML, private readonly playerHTML: PlayerHTML,
private readonly videoFetcher: VideoFetcher, private readonly videoFetcher: VideoFetcher,
private readonly peertubePlugin: PeerTubePlugin private readonly peertubePlugin: PeerTubePlugin,
private readonly serverConfig: HTMLServerConfig
) {} ) {}
hasAPIEnabled () { hasAPIEnabled () {
@ -150,7 +147,7 @@ export class PlayerOptionsBuilder {
this.bigPlayBackgroundColor = getParamString(params, 'bigPlayBackgroundColor') this.bigPlayBackgroundColor = getParamString(params, 'bigPlayBackgroundColor')
this.foregroundColor = getParamString(params, 'foregroundColor') this.foregroundColor = getParamString(params, 'foregroundColor')
this.playerTheme = getParamString(params, 'playerTheme', 'default') as PeerTubePlayerTheme this.playerTheme = getParamString(params, 'playerTheme') as PlayerTheme
} catch (err) { } catch (err) {
logger.error('Cannot get params from URL.', err) logger.error('Cannot get params from URL.', err)
} }
@ -238,6 +235,8 @@ export class PlayerOptionsBuilder {
chaptersResponse: Response chaptersResponse: Response
playerSettingsResponse: Response
live?: LiveVideo live?: LiveVideo
alreadyPlayed: boolean alreadyPlayed: boolean
@ -271,13 +270,15 @@ export class PlayerOptionsBuilder {
live, live,
storyboardsResponse, storyboardsResponse,
chaptersResponse, chaptersResponse,
config config,
playerSettingsResponse
} = options } = options
const [ videoCaptions, storyboard, chapters ] = await Promise.all([ const [ videoCaptions, storyboard, chapters, playerSettings ] = await Promise.all([
this.buildCaptions(captionsResponse, translations), this.buildCaptions(captionsResponse, translations),
this.buildStoryboard(storyboardsResponse), this.buildStoryboard(storyboardsResponse),
this.buildChapters(chaptersResponse) this.buildChapters(chaptersResponse),
playerSettingsResponse.json() as Promise<PlayerVideoSettings>
]) ])
const nsfwWarn = isVideoNSFWWarnedForUser(video, config, null) || isVideoNSFWHiddenForUser(video, config, null) const nsfwWarn = isVideoNSFWWarnedForUser(video, config, null) || isVideoNSFWHiddenForUser(video, config, null)
@ -285,7 +286,7 @@ export class PlayerOptionsBuilder {
return { return {
mode: this.mode, mode: this.mode,
theme: this.playerTheme, theme: this.playerTheme || playerSettings.theme as PlayerTheme,
autoplay: !nsfwWarn && (forceAutoplay || alreadyPlayed || this.autoplay), autoplay: !nsfwWarn && (forceAutoplay || alreadyPlayed || this.autoplay),
forceAutoplay, forceAutoplay,

View file

@ -5,9 +5,7 @@ import { AuthHTTP } from './auth-http'
import { getBackendUrl } from './url' import { getBackendUrl } from './url'
export class VideoFetcher { export class VideoFetcher {
constructor (private readonly http: AuthHTTP) { constructor (private readonly http: AuthHTTP) {
} }
async loadVideo ({ videoId, videoPassword }: { videoId: string, videoPassword?: string }) { async loadVideo ({ videoId, videoPassword }: { videoId: string, videoPassword?: string }) {
@ -39,8 +37,9 @@ export class VideoFetcher {
const captionsPromise = this.loadVideoCaptions({ videoId, videoPassword }) const captionsPromise = this.loadVideoCaptions({ videoId, videoPassword })
const chaptersPromise = this.loadVideoChapters({ videoId, videoPassword }) const chaptersPromise = this.loadVideoChapters({ videoId, videoPassword })
const storyboardsPromise = this.loadStoryboards(videoId) const storyboardsPromise = this.loadStoryboards(videoId)
const playerSettingsPromise = this.loadPlayerSettings({ videoId, videoPassword })
return { captionsPromise, chaptersPromise, storyboardsPromise, videoResponse } return { captionsPromise, chaptersPromise, storyboardsPromise, videoResponse, playerSettingsPromise }
} }
loadLive (video: VideoDetails) { loadLive (video: VideoDetails) {
@ -70,10 +69,18 @@ export class VideoFetcher {
return this.http.fetch(this.getVideoUrl(videoId) + '/chapters', { optionalAuth: true }, videoPassword) return this.http.fetch(this.getVideoUrl(videoId) + '/chapters', { optionalAuth: true }, videoPassword)
} }
private loadPlayerSettings ({ videoId, videoPassword }: { videoId: string, videoPassword?: string }): Promise<Response> {
return this.http.fetch(this.getPlayerSettingsUrl(videoId), { optionalAuth: true }, videoPassword)
}
private getVideoUrl (id: string) { private getVideoUrl (id: string) {
return getBackendUrl() + '/api/v1/videos/' + id return getBackendUrl() + '/api/v1/videos/' + id
} }
private getPlayerSettingsUrl (id: string) {
return getBackendUrl() + '/api/v1/player-settings/videos/' + id
}
private getLiveUrl (videoId: string) { private getLiveUrl (videoId: string) {
return getBackendUrl() + '/api/v1/videos/live/' + videoId return getBackendUrl() + '/api/v1/videos/live/' + videoId
} }

View file

@ -1176,6 +1176,8 @@ defaults:
enabled: true enabled: true
player: player:
theme: 'galaxy' # 'galaxy' | 'lucide'
# By default, playback starts automatically when opening a video # By default, playback starts automatically when opening a video
auto_play: true auto_play: true

View file

@ -1186,6 +1186,8 @@ defaults:
enabled: true enabled: true
player: player:
theme: 'galaxy' # 'galaxy' | 'lucide'
# By default, playback starts automatically when opening a video # By default, playback starts automatically when opening a video
auto_play: true auto_play: true

View file

@ -5,6 +5,7 @@ import {
ActivityObject, ActivityObject,
APObjectId, APObjectId,
CacheFileObject, CacheFileObject,
PlayerSettingsObject,
PlaylistObject, PlaylistObject,
VideoCommentObject, VideoCommentObject,
VideoObject, VideoObject,
@ -12,7 +13,7 @@ import {
} from './objects/index.js' } from './objects/index.js'
export type ActivityUpdateObject = export type ActivityUpdateObject =
| Extract<ActivityObject, VideoObject | CacheFileObject | PlaylistObject | ActivityPubActor | string> | Extract<ActivityObject, VideoObject | CacheFileObject | PlaylistObject | ActivityPubActor | PlayerSettingsObject | string>
| ActivityPubActor | ActivityPubActor
// Cannot Extract from Activity because of circular reference // Cannot Extract from Activity because of circular reference

View file

@ -38,4 +38,7 @@ export interface ActivityPubActor {
// Used by the user export feature // Used by the user export feature
likes?: string likes?: string
dislikes?: string dislikes?: string
// On channels only
playerSettings?: string
} }

View file

@ -1,19 +1,20 @@
export type ContextType = export type ContextType =
'Video' | | 'Video'
'Comment' | | 'Comment'
'Playlist' | | 'Playlist'
'Follow' | | 'Follow'
'Reject' | | 'Reject'
'Accept' | | 'Accept'
'View' | | 'View'
'Announce' | | 'Announce'
'CacheFile' | | 'CacheFile'
'Delete' | | 'Delete'
'Rate' | | 'Rate'
'Flag' | | 'Flag'
'Actor' | | 'Actor'
'Collection' | | 'Collection'
'WatchAction' | | 'WatchAction'
'Chapters' | | 'Chapters'
'ApproveReply' | | 'ApproveReply'
'RejectReply' | 'RejectReply'
| 'PlayerSettings'

View file

@ -1,17 +1,19 @@
import { AbuseObject } from './abuse-object.js' import { AbuseObject } from './abuse-object.js'
import { CacheFileObject } from './cache-file-object.js' import { CacheFileObject } from './cache-file-object.js'
import { PlayerSettingsObject } from './player-settings-object.js'
import { PlaylistObject } from './playlist-object.js' import { PlaylistObject } from './playlist-object.js'
import { VideoCommentObject } from './video-comment-object.js' import { VideoCommentObject } from './video-comment-object.js'
import { VideoObject } from './video-object.js' import { VideoObject } from './video-object.js'
import { WatchActionObject } from './watch-action-object.js' import { WatchActionObject } from './watch-action-object.js'
export type ActivityObject = export type ActivityObject =
VideoObject | | VideoObject
AbuseObject | | AbuseObject
VideoCommentObject | | VideoCommentObject
CacheFileObject | | CacheFileObject
PlaylistObject | | PlaylistObject
WatchActionObject | | WatchActionObject
string | PlayerSettingsObject
| string
export type APObjectId = string | { id: string } export type APObjectId = string | { id: string }

View file

@ -8,4 +8,5 @@ export * from './video-caption-object.js'
export * from './video-chapters-object.js' export * from './video-chapters-object.js'
export * from './video-comment-object.js' export * from './video-comment-object.js'
export * from './video-object.js' export * from './video-object.js'
export * from './player-settings-object.js'
export * from './watch-action-object.js' export * from './watch-action-object.js'

View file

@ -0,0 +1,8 @@
import { PlayerThemeChannelSetting, PlayerThemeVideoSetting } from '../../player/player-theme.type.js'
export interface PlayerSettingsObject {
type: 'PlayerSettings'
id: string
object: string
theme: PlayerThemeVideoSetting | PlayerThemeChannelSetting
}

View file

@ -64,6 +64,7 @@ export interface VideoObject {
shares: string shares: string
comments: string comments: string
hasParts: string | VideoChapterObject[] hasParts: string | VideoChapterObject[]
playerSettings: string
attributedTo: ActivityPubAttributedTo[] attributedTo: ActivityPubAttributedTo[]

View file

@ -1,3 +1,4 @@
import { PlayerThemeChannelSetting } from '../../player/player-theme.type.js'
import { UserActorImageJSON } from './actor-export.model.js' import { UserActorImageJSON } from './actor-export.model.js'
export interface ChannelExportJSON { export interface ChannelExportJSON {
@ -15,6 +16,10 @@ export interface ChannelExportJSON {
avatars: UserActorImageJSON[] avatars: UserActorImageJSON[]
banners: UserActorImageJSON[] banners: UserActorImageJSON[]
playerSettings?: {
theme: PlayerThemeChannelSetting
}
archiveFiles: { archiveFiles: {
avatar: string | null avatar: string | null
banner: string | null banner: string | null

View file

@ -1,3 +1,4 @@
import { PlayerThemeVideoSetting } from '../../player/player-theme.type.js'
import { import {
LiveVideoLatencyModeType, LiveVideoLatencyModeType,
VideoCommentPolicyType, VideoCommentPolicyType,
@ -108,6 +109,10 @@ export interface VideoExportJSON {
metadata: VideoFileMetadata metadata: VideoFileMetadata
} }
playerSettings?: {
theme: PlayerThemeVideoSetting
}
archiveFiles: { archiveFiles: {
videoFile: string | null videoFile: string | null
thumbnail: string | null thumbnail: string | null

View file

@ -11,6 +11,7 @@ export * from './metrics/index.js'
export * from './moderation/index.js' export * from './moderation/index.js'
export * from './nodeinfo/index.js' export * from './nodeinfo/index.js'
export * from './overviews/index.js' export * from './overviews/index.js'
export * from './player/index.js'
export * from './plugins/index.js' export * from './plugins/index.js'
export * from './redundancy/index.js' export * from './redundancy/index.js'
export * from './runners/index.js' export * from './runners/index.js'

View file

@ -0,0 +1,2 @@
export * from './player-mode.type.js'
export * from './player-theme.type.js'

View file

@ -0,0 +1 @@
export type PlayerMode = 'web-video' | 'p2p-media-loader'

View file

@ -0,0 +1,3 @@
export type PlayerTheme = 'galaxy' | 'lucide'
export type PlayerThemeChannelSetting = 'instance-default' | PlayerTheme
export type PlayerThemeVideoSetting = 'channel-default' | PlayerThemeChannelSetting

View file

@ -1,3 +1,4 @@
import { PlayerTheme } from '../player/player-theme.type.js'
import { VideoCommentPolicyType, VideoPrivacyType } from '../videos/index.js' import { VideoCommentPolicyType, VideoPrivacyType } from '../videos/index.js'
import { NSFWPolicyType } from '../videos/nsfw-policy.type.js' import { NSFWPolicyType } from '../videos/nsfw-policy.type.js'
import { BroadcastMessageLevel } from './broadcast-message-level.type.js' import { BroadcastMessageLevel } from './broadcast-message-level.type.js'
@ -370,6 +371,7 @@ export interface CustomConfig {
} }
player: { player: {
theme: PlayerTheme
autoPlay: boolean autoPlay: boolean
} }
} }

View file

@ -1,4 +1,4 @@
import { ActorImage, LogoType, VideoCommentPolicyType } from '../index.js' import { ActorImage, LogoType, PlayerTheme, VideoCommentPolicyType } from '../index.js'
import { ClientScriptJSON } from '../plugins/plugin-package-json.model.js' import { ClientScriptJSON } from '../plugins/plugin-package-json.model.js'
import { NSFWPolicyType } from '../videos/nsfw-policy.type.js' import { NSFWPolicyType } from '../videos/nsfw-policy.type.js'
import { VideoPrivacyType } from '../videos/video-privacy.enum.js' import { VideoPrivacyType } from '../videos/video-privacy.enum.js'
@ -103,6 +103,7 @@ export interface ServerConfig {
} }
player: { player: {
theme: PlayerTheme
autoPlay: boolean autoPlay: boolean
} }
} }

View file

@ -16,6 +16,8 @@ export * from './chapter/index.js'
export * from './nsfw-flag.enum.js' export * from './nsfw-flag.enum.js'
export * from './nsfw-policy.type.js' export * from './nsfw-policy.type.js'
export * from './player-settings.js'
export * from './player-settings-update.js'
export * from './storyboard.model.js' export * from './storyboard.model.js'
export * from './thumbnail.type.js' export * from './thumbnail.type.js'

View file

@ -0,0 +1,9 @@
import { PlayerThemeChannelSetting, PlayerThemeVideoSetting } from '../player/player-theme.type.js'
export interface PlayerVideoSettingsUpdate {
theme: PlayerThemeVideoSetting
}
export interface PlayerChannelSettingsUpdate {
theme: PlayerThemeChannelSetting
}

View file

@ -0,0 +1,9 @@
import { PlayerThemeChannelSetting, PlayerThemeVideoSetting } from '../player/player-theme.type.js'
export interface PlayerVideoSettings {
theme: PlayerThemeVideoSetting
}
export interface PlayerChannelSettings {
theme: PlayerThemeChannelSetting
}

View file

@ -3,6 +3,7 @@ import { Video, VideoChannel, VideoChannelSync, VideoCreateResult, VideoDetails
import { parallelTests, root } from '@peertube/peertube-node-utils' import { parallelTests, root } from '@peertube/peertube-node-utils'
import { ChildProcess, fork } from 'child_process' import { ChildProcess, fork } from 'child_process'
import { copy } from 'fs-extra/esm' import { copy } from 'fs-extra/esm'
import merge from 'lodash-es/merge.js'
import { join } from 'path' import { join } from 'path'
import { BulkCommand } from '../bulk/index.js' import { BulkCommand } from '../bulk/index.js'
import { CLICommand } from '../cli/index.js' import { CLICommand } from '../cli/index.js'
@ -36,6 +37,7 @@ import {
CommentsCommand, CommentsCommand,
HistoryCommand, HistoryCommand,
LiveCommand, LiveCommand,
PlayerSettingsCommand,
PlaylistsCommand, PlaylistsCommand,
ServicesCommand, ServicesCommand,
StoryboardCommand, StoryboardCommand,
@ -58,7 +60,6 @@ import { PluginsCommand } from './plugins-command.js'
import { RedundancyCommand } from './redundancy-command.js' import { RedundancyCommand } from './redundancy-command.js'
import { ServersCommand } from './servers-command.js' import { ServersCommand } from './servers-command.js'
import { StatsCommand } from './stats-command.js' import { StatsCommand } from './stats-command.js'
import merge from 'lodash-es/merge.js'
export type RunServerOptions = { export type RunServerOptions = {
autoEnableImportProxy?: boolean autoEnableImportProxy?: boolean
@ -154,6 +155,7 @@ export class PeerTubeServer {
videoToken?: VideoTokenCommand videoToken?: VideoTokenCommand
registrations?: RegistrationsCommand registrations?: RegistrationsCommand
videoPasswords?: VideoPasswordsCommand videoPasswords?: VideoPasswordsCommand
playerSettings?: PlayerSettingsCommand
storyboard?: StoryboardCommand storyboard?: StoryboardCommand
chapters?: ChaptersCommand chapters?: ChaptersCommand
@ -460,6 +462,8 @@ export class PeerTubeServer {
this.videoToken = new VideoTokenCommand(this) this.videoToken = new VideoTokenCommand(this)
this.registrations = new RegistrationsCommand(this) this.registrations = new RegistrationsCommand(this)
this.playerSettings = new PlayerSettingsCommand(this)
this.storyboard = new StoryboardCommand(this) this.storyboard = new StoryboardCommand(this)
this.chapters = new ChaptersCommand(this) this.chapters = new ChaptersCommand(this)

View file

@ -6,6 +6,7 @@ export * from './channels-command.js'
export * from './chapters-command.js' export * from './chapters-command.js'
export * from './channel-syncs-command.js' export * from './channel-syncs-command.js'
export * from './comments-command.js' export * from './comments-command.js'
export * from './player-settings-command.js'
export * from './history-command.js' export * from './history-command.js'
export * from './video-imports-command.js' export * from './video-imports-command.js'
export * from './live-command.js' export * from './live-command.js'

View file

@ -0,0 +1,89 @@
import { pick } from '@peertube/peertube-core-utils'
import { HttpStatusCode, PlayerChannelSettings, PlayerVideoSettings, PlayerVideoSettingsUpdate } from '@peertube/peertube-models'
import { unwrapBody } from '../requests/requests.js'
import { AbstractCommand, OverrideCommandOptions } from '../shared/index.js'
export class PlayerSettingsCommand extends AbstractCommand {
getForVideo (
options: OverrideCommandOptions & {
videoId: number | string
raw?: boolean
videoPassword?: string
}
) {
const path = '/api/v1/player-settings/videos/' + options.videoId
return this.get<PlayerVideoSettings>({ ...options, path })
}
getForChannel (
options: OverrideCommandOptions & {
channelHandle: string
raw?: boolean
}
) {
const path = '/api/v1/player-settings/video-channels/' + options.channelHandle
return this.get<PlayerChannelSettings>({ ...options, path })
}
private get<T = (PlayerVideoSettings | PlayerChannelSettings)> (
options: OverrideCommandOptions & {
path: string
videoPassword?: string
raw?: boolean
}
) {
const headers = this.buildVideoPasswordHeader(options.videoPassword)
return this.getRequestBody<T>({
...options,
headers,
query: options.raw
? { raw: options.raw }
: undefined,
implicitToken: false,
defaultExpectedStatus: HttpStatusCode.OK_200
})
}
// ---------------------------------------------------------------------------
updateForVideo (
options: OverrideCommandOptions & PlayerVideoSettingsUpdate & {
videoId: number | string
}
) {
const path = '/api/v1/player-settings/videos/' + options.videoId
return this.update<PlayerVideoSettings>({ ...options, path })
}
updateForChannel (
options: OverrideCommandOptions & PlayerVideoSettingsUpdate & {
channelHandle: string
}
) {
const path = '/api/v1/player-settings/video-channels/' + options.channelHandle
return this.update<PlayerChannelSettings>({ ...options, path })
}
private update<T = (PlayerVideoSettings | PlayerChannelSettings)> (
options: OverrideCommandOptions & PlayerVideoSettingsUpdate & {
path: string
}
) {
return unwrapBody<T>(this.putBodyRequest({
...options,
fields: pick(options, [ 'theme' ]),
implicitToken: true,
defaultExpectedStatus: HttpStatusCode.OK_200
}))
}
}

View file

@ -15,6 +15,7 @@ import './live.js'
import './logs.js' import './logs.js'
import './metrics.js' import './metrics.js'
import './my-user.js' import './my-user.js'
import './player-settings.js'
import './plugins.js' import './plugins.js'
import './redundancy.js' import './redundancy.js'
import './registrations.js' import './registrations.js'

View file

@ -0,0 +1,138 @@
/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
import { HttpStatusCode, PlayerSettings, VideoCreateResult, VideoPrivacy } from '@peertube/peertube-models'
import {
PeerTubeServer,
cleanupTests,
createSingleServer,
setAccessTokensToServers,
setDefaultVideoChannel
} from '@peertube/peertube-server-commands'
describe('Test player settings API validator', function () {
let server: PeerTubeServer
let ownerAccessToken: string
let userAccessToken: string
let video: VideoCreateResult
let privateVideo: VideoCreateResult
// ---------------------------------------------------------------
before(async function () {
this.timeout(60000)
server = await createSingleServer(1)
await setAccessTokensToServers([ server ])
await setDefaultVideoChannel([ server ])
ownerAccessToken = await server.users.generateUserAndToken('owner')
userAccessToken = await server.users.generateUserAndToken('user1')
video = await server.videos.upload({ token: ownerAccessToken })
privateVideo = await server.videos.upload({ token: ownerAccessToken, attributes: { privacy: VideoPrivacy.PRIVATE } })
})
it('Should fail to get video player settings if the video does not exist', async function () {
await server.playerSettings.getForVideo({ videoId: 42, expectedStatus: HttpStatusCode.NOT_FOUND_404 })
})
it('Should check video privacy before getting player settings of a video', async function () {
const videoId = privateVideo.uuid
await server.playerSettings.getForVideo({ token: server.accessToken, videoId })
await server.playerSettings.getForVideo({ token: ownerAccessToken, videoId })
await server.playerSettings.getForVideo({ token: userAccessToken, videoId, expectedStatus: HttpStatusCode.FORBIDDEN_403 })
await server.playerSettings.getForVideo({ token: null, videoId, expectedStatus: HttpStatusCode.UNAUTHORIZED_401 })
})
it('Should fail to get channel player settings if the channel does not exist', async function () {
await server.playerSettings.getForChannel({
token: ownerAccessToken,
channelHandle: 'unknown',
expectedStatus: HttpStatusCode.NOT_FOUND_404
})
})
it('Should only allow to get raw player settings of a channel by owner/moderators', async function () {
const channelHandle = 'owner_channel'
const videoId = video.uuid
await server.playerSettings.getForChannel({ token: server.accessToken, channelHandle, raw: true })
await server.playerSettings.getForChannel({ token: ownerAccessToken, channelHandle, raw: true })
await server.playerSettings.getForChannel({
token: userAccessToken,
channelHandle,
expectedStatus: HttpStatusCode.FORBIDDEN_403,
raw: true
})
await server.playerSettings.getForChannel({ token: null, channelHandle, expectedStatus: HttpStatusCode.UNAUTHORIZED_401, raw: true })
await server.playerSettings.getForVideo({ token: server.accessToken, videoId, raw: true })
await server.playerSettings.getForVideo({ token: ownerAccessToken, videoId, raw: true })
await server.playerSettings.getForVideo({ token: userAccessToken, videoId, expectedStatus: HttpStatusCode.FORBIDDEN_403, raw: true })
await server.playerSettings.getForVideo({ token: null, videoId, expectedStatus: HttpStatusCode.UNAUTHORIZED_401, raw: true })
})
it('Should only allow to update player settings of a video by owner/moderators', async function () {
const videoId = video.uuid
const playerSettings: PlayerSettings = { theme: 'lucide' }
await server.playerSettings.updateForVideo({ token: server.accessToken, videoId, ...playerSettings })
await server.playerSettings.updateForVideo({ token: ownerAccessToken, videoId, ...playerSettings })
await server.playerSettings.updateForVideo({
token: userAccessToken,
videoId,
...playerSettings,
expectedStatus: HttpStatusCode.FORBIDDEN_403
})
await server.playerSettings.updateForVideo({ token: null, videoId, ...playerSettings, expectedStatus: HttpStatusCode.UNAUTHORIZED_401 })
})
it('Should only allow to update player settings of a channel by owner/moderators', async function () {
const channelHandle = 'owner_channel'
const playerSettings: PlayerSettings = { theme: 'lucide' }
await server.playerSettings.updateForChannel({ token: server.accessToken, channelHandle, ...playerSettings })
await server.playerSettings.updateForChannel({ token: ownerAccessToken, channelHandle, ...playerSettings })
await server.playerSettings.updateForChannel({
token: userAccessToken,
channelHandle,
...playerSettings,
expectedStatus: HttpStatusCode.FORBIDDEN_403
})
await server.playerSettings.updateForChannel({
token: null,
channelHandle,
...playerSettings,
expectedStatus: HttpStatusCode.UNAUTHORIZED_401
})
})
it('Should fail to update player settings with invalid settings', async function () {
const videoId = video.uuid
const channelHandle = 'owner_channel'
{
const playerSettings = { theme: 'invalid' } as any
await server.playerSettings.updateForVideo({ videoId, ...playerSettings, expectedStatus: HttpStatusCode.BAD_REQUEST_400 })
await server.playerSettings.updateForChannel({ channelHandle, ...playerSettings, expectedStatus: HttpStatusCode.BAD_REQUEST_400 })
}
{
const playerSettings = { theme: 'channel-default' } as any
await server.playerSettings.updateForVideo({ videoId, ...playerSettings })
await server.playerSettings.updateForChannel({ channelHandle, ...playerSettings, expectedStatus: HttpStatusCode.BAD_REQUEST_400 })
}
})
after(async function () {
await cleanupTests([ server ])
})
})

View file

@ -48,7 +48,7 @@ describe('Test video passwords validator', function () {
}, },
import: { import: {
videos: { videos: {
http:{ http: {
enabled: true enabled: true
} }
} }
@ -132,7 +132,6 @@ describe('Test video passwords validator', function () {
} }
function validateVideoPasswordList (mode: 'uploadLegacy' | 'uploadResumable' | 'import' | 'updateVideo' | 'updatePasswords' | 'live') { function validateVideoPasswordList (mode: 'uploadLegacy' | 'uploadResumable' | 'import' | 'updateVideo' | 'updatePasswords' | 'live') {
it('Should fail with a password protected privacy without providing a password', async function () { it('Should fail with a password protected privacy without providing a password', async function () {
await checkVideoPasswordOptions({ await checkVideoPasswordOptions({
server, server,
@ -268,7 +267,17 @@ describe('Test video passwords validator', function () {
token?: string token?: string
videoPassword?: string videoPassword?: string
expectedStatus: HttpStatusCodeType expectedStatus: HttpStatusCodeType
mode: 'get' | 'getWithPassword' | 'getWithToken' | 'listCaptions' | 'createThread' | 'listThreads' | 'replyThread' | 'rate' | 'token' mode:
| 'get'
| 'getWithPassword'
| 'getWithToken'
| 'listCaptions'
| 'createThread'
| 'listThreads'
| 'replyThread'
| 'rate'
| 'token'
| 'getPlayerSettings'
}) { }) {
const { server, token = null, videoPassword, expectedStatus, mode } = options const { server, token = null, videoPassword, expectedStatus, mode } = options
@ -351,6 +360,15 @@ describe('Test video passwords validator', function () {
}) })
} }
if (mode === 'getPlayerSettings') {
return server.playerSettings.getForVideo({
videoId: video.id,
token,
expectedStatus,
videoPassword
})
}
if (mode === 'token') { if (mode === 'token') {
return server.videoToken.create({ return server.videoToken.create({
videoId: video.id, videoId: video.id,
@ -380,9 +398,12 @@ describe('Test video passwords validator', function () {
expect(error.status).to.equal(HttpStatusCode.FORBIDDEN_403) expect(error.status).to.equal(HttpStatusCode.FORBIDDEN_403)
} }
function validateVideoAccess (mode: 'get' | 'listCaptions' | 'createThread' | 'listThreads' | 'replyThread' | 'rate' | 'token') { function validateVideoAccess (
mode: 'get' | 'listCaptions' | 'createThread' | 'listThreads' | 'replyThread' | 'getPlayerSettings' | 'rate' | 'token'
) {
const requiresUserAuth = [ 'createThread', 'replyThread', 'rate' ].includes(mode) const requiresUserAuth = [ 'createThread', 'replyThread', 'rate' ].includes(mode)
let tokens: string[] let tokens: string[]
if (!requiresUserAuth) { if (!requiresUserAuth) {
it('Should fail without providing a password for an unlogged user', async function () { it('Should fail without providing a password for an unlogged user', async function () {
const body = await checkVideoAccessOptions({ server, expectedStatus: HttpStatusCode.FORBIDDEN_403, mode }) const body = await checkVideoAccessOptions({ server, expectedStatus: HttpStatusCode.FORBIDDEN_403, mode })
@ -482,7 +503,6 @@ describe('Test video passwords validator', function () {
} }
describe('When accessing password protected video', function () { describe('When accessing password protected video', function () {
describe('For getting a password protected video', function () { describe('For getting a password protected video', function () {
validateVideoAccess('get') validateVideoAccess('get')
}) })
@ -507,6 +527,10 @@ describe('Test video passwords validator', function () {
validateVideoAccess('listCaptions') validateVideoAccess('listCaptions')
}) })
describe('For getting player settings', function () {
validateVideoAccess('getPlayerSettings')
})
describe('For creating video file token', function () { describe('For creating video file token', function () {
validateVideoAccess('token') validateVideoAccess('token')
}) })

View file

@ -26,7 +26,6 @@ describe('Test config defaults', function () {
}) })
describe('Default publish values', function () { describe('Default publish values', function () {
before(async function () { before(async function () {
const overrideConfig = { const overrideConfig = {
defaults: { defaults: {
@ -123,9 +122,7 @@ describe('Test config defaults', function () {
}) })
describe('Default P2P values', function () { describe('Default P2P values', function () {
describe('Webapp default value', function () { describe('Webapp default value', function () {
before(async function () { before(async function () {
const overrideConfig = { const overrideConfig = {
defaults: { defaults: {
@ -167,7 +164,6 @@ describe('Test config defaults', function () {
}) })
describe('Embed default value', function () { describe('Embed default value', function () {
before(async function () { before(async function () {
const overrideConfig = { const overrideConfig = {
defaults: { defaults: {
@ -213,11 +209,11 @@ describe('Test config defaults', function () {
}) })
describe('Default player value', function () { describe('Default player value', function () {
before(async function () { before(async function () {
const overrideConfig = { const overrideConfig = {
defaults: { defaults: {
player: { player: {
theme: 'lucide',
auto_play: false auto_play: false
} }
}, },
@ -230,9 +226,10 @@ describe('Test config defaults', function () {
await server.run(overrideConfig) await server.run(overrideConfig)
}) })
it('Should have appropriate autoplay config', async function () { it('Should have appropriate player config', async function () {
const config = await server.config.getConfig() const config = await server.config.getConfig()
expect(config.defaults.player.theme).to.equal('lucide')
expect(config.defaults.player.autoPlay).to.be.false expect(config.defaults.player.autoPlay).to.be.false
}) })
@ -255,7 +252,6 @@ describe('Test config defaults', function () {
}) })
describe('Default user attributes', function () { describe('Default user attributes', function () {
it('Should create a user and register a user with the default config', async function () { it('Should create a user and register a user with the default config', async function () {
await server.config.updateExistingConfig({ await server.config.updateExistingConfig({
newConfig: { newConfig: {
@ -265,7 +261,7 @@ describe('Test config defaults', function () {
enabled: true enabled: true
} }
}, },
videoQuota : -1, videoQuota: -1,
videoQuotaDaily: -1 videoQuotaDaily: -1
}, },
signup: { signup: {
@ -305,7 +301,7 @@ describe('Test config defaults', function () {
enabled: false enabled: false
} }
}, },
videoQuota : 5242881, videoQuota: 5242881,
videoQuotaDaily: 318742 videoQuotaDaily: 318742
}, },
signup: { signup: {
@ -330,7 +326,6 @@ describe('Test config defaults', function () {
expect(user.videoQuotaDaily).to.equal(318742) expect(user.videoQuotaDaily).to.equal(318742)
} }
}) })
}) })
after(async function () { after(async function () {

View file

@ -159,6 +159,7 @@ function checkInitialConfig (server: PeerTubeServer, data: CustomConfig) {
expect(data.defaults.publish.privacy).to.equal(VideoPrivacy.PUBLIC) expect(data.defaults.publish.privacy).to.equal(VideoPrivacy.PUBLIC)
expect(data.defaults.p2p.embed.enabled).to.be.true expect(data.defaults.p2p.embed.enabled).to.be.true
expect(data.defaults.p2p.webapp.enabled).to.be.true expect(data.defaults.p2p.webapp.enabled).to.be.true
expect(data.defaults.player.theme).to.equal('galaxy')
expect(data.defaults.player.autoPlay).to.be.true expect(data.defaults.player.autoPlay).to.be.true
expect(data.email.body.signature).to.equal('') expect(data.email.body.signature).to.equal('')
@ -473,7 +474,8 @@ function buildNewCustomConfig (server: PeerTubeServer): CustomConfig {
} }
}, },
player: { player: {
autoPlay: false autoPlay: false,
theme: 'lucide'
} }
}, },
email: { email: {

View file

@ -455,6 +455,7 @@ function runTest (withObjectStorage: boolean) {
expect(secondaryChannel.displayName).to.equal('noah display name') expect(secondaryChannel.displayName).to.equal('noah display name')
expect(secondaryChannel.description).to.equal('noah description') expect(secondaryChannel.description).to.equal('noah description')
expect(secondaryChannel.support).to.equal('noah support') expect(secondaryChannel.support).to.equal('noah support')
expect(secondaryChannel.playerSettings.theme).to.equal('galaxy')
expect(secondaryChannel.avatars).to.have.lengthOf(4) expect(secondaryChannel.avatars).to.have.lengthOf(4)
expect(secondaryChannel.banners).to.have.lengthOf(2) expect(secondaryChannel.banners).to.have.lengthOf(2)
@ -554,6 +555,8 @@ function runTest (withObjectStorage: boolean) {
expect(publicVideo.source.metadata?.streams).to.exist expect(publicVideo.source.metadata?.streams).to.exist
expect(publicVideo.source.resolution).to.equal(720) expect(publicVideo.source.resolution).to.equal(720)
expect(publicVideo.source.size).to.equal(218910) expect(publicVideo.source.size).to.equal(218910)
expect(publicVideo.playerSettings.theme).to.equal('lucide')
} }
{ {

View file

@ -193,11 +193,25 @@ function runTest (withObjectStorage: boolean) {
expect(importedMain.avatars).to.have.lengthOf(0) expect(importedMain.avatars).to.have.lengthOf(0)
expect(importedMain.banners).to.have.lengthOf(0) expect(importedMain.banners).to.have.lengthOf(0)
const playerSettingMain = await remoteServer.playerSettings.getForChannel({
channelHandle: 'noah_remote_channel',
token: remoteServer.accessToken,
raw: true
})
expect(playerSettingMain.theme).to.equal('instance-default')
const importedSecond = await remoteServer.channels.get({ token: remoteNoahToken, channelName: 'noah_second_channel' }) const importedSecond = await remoteServer.channels.get({ token: remoteNoahToken, channelName: 'noah_second_channel' })
expect(importedSecond.displayName).to.equal('noah display name') expect(importedSecond.displayName).to.equal('noah display name')
expect(importedSecond.description).to.equal('noah description') expect(importedSecond.description).to.equal('noah description')
expect(importedSecond.support).to.equal('noah support') expect(importedSecond.support).to.equal('noah support')
const playerSettingSecond = await remoteServer.playerSettings.getForChannel({
channelHandle: 'noah_second_channel',
token: remoteServer.accessToken,
raw: true
})
expect(playerSettingSecond.theme).to.equal('galaxy')
for (const banner of importedSecond.banners) { for (const banner of importedSecond.banners) {
await testImage({ url: banner.fileUrl, name: `banner-user-import-resized-${banner.width}.jpg` }) await testImage({ url: banner.fileUrl, name: `banner-user-import-resized-${banner.width}.jpg` })
} }
@ -376,6 +390,13 @@ function runTest (withObjectStorage: boolean) {
expect(publicVideo).to.exist expect(publicVideo).to.exist
expect(publicVideo.privacy.id).to.equal(VideoPrivacy.PUBLIC) expect(publicVideo.privacy.id).to.equal(VideoPrivacy.PUBLIC)
const playerSetting = await remoteServer.playerSettings.getForVideo({
videoId: publicVideo.uuid,
token: remoteServer.accessToken,
raw: true
})
expect(playerSetting.theme).to.equal('lucide')
// Federated // Federated
await server.videos.get({ id: publicVideo.uuid }) await server.videos.get({ id: publicVideo.uuid })
} }
@ -385,6 +406,13 @@ function runTest (withObjectStorage: boolean) {
expect(passwordVideo).to.exist expect(passwordVideo).to.exist
expect(passwordVideo.privacy.id).to.equal(VideoPrivacy.PASSWORD_PROTECTED) expect(passwordVideo.privacy.id).to.equal(VideoPrivacy.PASSWORD_PROTECTED)
const playerSetting = await remoteServer.playerSettings.getForVideo({
videoId: passwordVideo.uuid,
token: remoteServer.accessToken,
raw: true
})
expect(playerSetting.theme).to.equal('channel-default')
const { data: passwords } = await remoteServer.videoPasswords.list({ videoId: passwordVideo.uuid }) const { data: passwords } = await remoteServer.videoPasswords.list({ videoId: passwordVideo.uuid })
expect(passwords.map(p => p.password).sort()).to.deep.equal([ 'password1', 'password2' ]) expect(passwords.map(p => p.password).sort()).to.deep.equal([ 'password1', 'password2' ])

View file

@ -1,6 +1,7 @@
import './channel-import-videos.js' import './channel-import-videos.js'
import './generate-download.js' import './generate-download.js'
import './multiple-servers.js' import './multiple-servers.js'
import './player-settings.js'
import './resumable-upload.js' import './resumable-upload.js'
import './single-server.js' import './single-server.js'
import './video-captions.js' import './video-captions.js'

View file

@ -0,0 +1,283 @@
/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
import {
PeerTubeServer,
cleanupTests,
createMultipleServers,
doubleFollow,
setAccessTokensToServers,
setDefaultVideoChannel,
waitJobs
} from '@peertube/peertube-server-commands'
import { expect } from 'chai'
import { VideoCreateResult } from '../../../../models/src/videos/video-create-result.model.js'
describe('Test player settings', function () {
let servers: PeerTubeServer[]
let video: VideoCreateResult
let otherVideo: VideoCreateResult
let otherVideoAndChannel: VideoCreateResult
// ---------------------------------------------------------------
before(async function () {
this.timeout(60000)
servers = await createMultipleServers(2)
await setAccessTokensToServers(servers)
await setDefaultVideoChannel(servers)
video = await servers[0].videos.upload()
otherVideo = await servers[0].videos.upload()
const otherChannel = await servers[0].channels.create({ attributes: { name: 'other_channel' } })
otherVideoAndChannel = await servers[0].videos.upload({ attributes: { channelId: otherChannel.id } })
await doubleFollow(servers[0], servers[1])
})
async function check (options: {
server: PeerTubeServer
videoId: number | string
channelHandle: string
expectedVideo: string
expectedChannel: string
expectedRawVideo?: string
expectedRawChannel?: string
}) {
const { server, expectedRawVideo, expectedRawChannel, expectedVideo, expectedChannel } = options
// Raw mode
{
if (expectedRawVideo) {
const { theme } = await server.playerSettings.getForVideo({
token: server.accessToken,
videoId: options.videoId,
raw: true
})
expect(theme).to.equal(expectedRawVideo)
}
if (expectedRawChannel) {
const { theme } = await server.playerSettings.getForChannel({
token: server.accessToken,
channelHandle: options.channelHandle,
raw: true
})
expect(theme).to.equal(expectedRawChannel)
}
}
// Interpreted settings mode
{
{
const { theme } = await server.playerSettings.getForVideo({ videoId: options.videoId })
expect(theme).to.equal(expectedVideo)
}
{
const { theme } = await server.playerSettings.getForChannel({ channelHandle: options.channelHandle, raw: false })
expect(theme).to.equal(expectedChannel)
}
}
}
it('Should return default player settings for the instance', async function () {
await check({
server: servers[0],
videoId: video.uuid,
channelHandle: 'root_channel',
expectedRawVideo: 'channel-default',
expectedRawChannel: 'instance-default',
expectedVideo: 'galaxy',
expectedChannel: 'galaxy'
})
await check({
server: servers[1],
videoId: video.uuid,
channelHandle: 'root_channel@' + servers[0].host,
expectedVideo: 'galaxy',
expectedChannel: 'galaxy'
})
})
it('Should update instance settings and return the updated settings', async function () {
await servers[0].config.updateExistingConfig({ newConfig: { defaults: { player: { theme: 'lucide' } } } })
await check({
server: servers[0],
videoId: video.uuid,
channelHandle: 'root_channel',
expectedRawVideo: 'channel-default',
expectedRawChannel: 'instance-default',
expectedVideo: 'lucide',
expectedChannel: 'lucide'
})
// Instance 2 keeps its own instance default
await check({
server: servers[1],
videoId: video.uuid,
channelHandle: 'root_channel@' + servers[0].host,
expectedVideo: 'galaxy',
expectedChannel: 'galaxy'
})
// Update instance 2 default theme to observe changes
await servers[1].config.updateExistingConfig({ newConfig: { defaults: { player: { theme: 'lucide' } } } })
await check({
server: servers[1],
videoId: video.uuid,
channelHandle: 'root_channel@' + servers[0].host,
expectedVideo: 'lucide',
expectedChannel: 'lucide'
})
})
it('Should update player settings of the channel and return the updated settings', async function () {
const { theme } = await servers[0].playerSettings.updateForChannel({ channelHandle: 'root_channel', theme: 'galaxy' })
expect(theme).to.equal('galaxy')
await waitJobs(servers)
await check({
server: servers[0],
videoId: video.uuid,
channelHandle: 'root_channel',
expectedRawVideo: 'channel-default',
expectedRawChannel: 'galaxy',
expectedVideo: 'galaxy',
expectedChannel: 'galaxy'
})
await check({
server: servers[1],
videoId: video.uuid,
channelHandle: 'root_channel@' + servers[0].host,
expectedVideo: 'galaxy',
expectedChannel: 'galaxy'
})
})
it('Should update player settings of the video and return the updated settings', async function () {
const { theme } = await servers[0].playerSettings.updateForVideo({ videoId: video.id, theme: 'lucide' })
expect(theme).to.equal('lucide')
await waitJobs(servers)
await check({
server: servers[0],
videoId: video.uuid,
channelHandle: 'root_channel',
expectedRawVideo: 'lucide',
expectedRawChannel: 'galaxy',
expectedVideo: 'lucide',
expectedChannel: 'galaxy'
})
await check({
server: servers[1],
videoId: video.uuid,
channelHandle: 'root_channel@' + servers[0].host,
expectedVideo: 'lucide',
expectedChannel: 'galaxy'
})
})
it('Should choose the default instance player theme', async function () {
const { theme } = await servers[0].playerSettings.updateForVideo({ videoId: video.id, theme: 'instance-default' })
expect(theme).to.equal('instance-default')
await waitJobs(servers)
await check({
server: servers[0],
videoId: video.uuid,
channelHandle: 'root_channel',
expectedRawVideo: 'instance-default',
expectedRawChannel: 'galaxy',
expectedVideo: 'lucide',
expectedChannel: 'galaxy'
})
await check({
server: servers[1],
videoId: video.uuid,
channelHandle: 'root_channel@' + servers[0].host,
expectedVideo: 'lucide',
expectedChannel: 'galaxy'
})
})
it('Should choose the default channel player theme', async function () {
const { theme } = await servers[0].playerSettings.updateForVideo({ videoId: video.id, theme: 'channel-default' })
expect(theme).to.equal('channel-default')
await waitJobs(servers)
await check({
server: servers[0],
videoId: video.uuid,
channelHandle: 'root_channel',
expectedRawVideo: 'channel-default',
expectedRawChannel: 'galaxy',
expectedVideo: 'galaxy',
expectedChannel: 'galaxy'
})
await check({
server: servers[1],
videoId: video.uuid,
channelHandle: 'root_channel@' + servers[0].host,
expectedVideo: 'galaxy',
expectedChannel: 'galaxy'
})
})
it('Should keep default settings for the other video', async function () {
await check({
server: servers[0],
videoId: otherVideo.uuid,
channelHandle: 'root_channel',
expectedRawVideo: 'channel-default',
expectedRawChannel: 'galaxy',
expectedVideo: 'galaxy',
expectedChannel: 'galaxy'
})
await check({
server: servers[1],
videoId: otherVideo.uuid,
channelHandle: 'root_channel@' + servers[0].host,
expectedVideo: 'galaxy',
expectedChannel: 'galaxy'
})
})
it('Should keep default settings for the other channel', async function () {
await check({
server: servers[0],
videoId: otherVideoAndChannel.uuid,
channelHandle: 'other_channel',
expectedRawVideo: 'channel-default',
expectedRawChannel: 'instance-default',
expectedVideo: 'lucide',
expectedChannel: 'lucide'
})
await check({
server: servers[1],
videoId: otherVideoAndChannel.uuid,
channelHandle: 'other_channel@' + servers[0].host,
expectedVideo: 'lucide',
expectedChannel: 'lucide'
})
})
after(async function () {
await cleanupTests(servers)
})
})

View file

@ -211,13 +211,17 @@ export async function prepareImportExportTests (options: {
fixture: 'avatar.png', fixture: 'avatar.png',
type: 'avatar' type: 'avatar'
}) })
await server.playerSettings.updateForChannel({ channelHandle: 'noah_second_channel', theme: 'galaxy' })
// Videos // Videos
const externalVideo = await remoteServer.videos.quickUpload({ name: 'external video', privacy: VideoPrivacy.PUBLIC }) const externalVideo = await remoteServer.videos.quickUpload({ name: 'external video', privacy: VideoPrivacy.PUBLIC })
// eslint-disable-next-line max-len // eslint-disable-next-line max-len
const noahPrivateVideo = await server.videos.quickUpload({ name: 'noah private video', token: noahToken, privacy: VideoPrivacy.PRIVATE }) const noahPrivateVideo = await server.videos.quickUpload({ name: 'noah private video', token: noahToken, privacy: VideoPrivacy.PRIVATE })
const noahVideo = await server.videos.quickUpload({ name: 'noah public video', token: noahToken, privacy: VideoPrivacy.PUBLIC }) const noahVideo = await server.videos.quickUpload({ name: 'noah public video', token: noahToken, privacy: VideoPrivacy.PUBLIC })
await server.playerSettings.updateForVideo({ videoId: noahVideo.uuid, theme: 'lucide' })
// eslint-disable-next-line max-len // eslint-disable-next-line max-len
const noahVideo2 = await server.videos.upload({ const noahVideo2 = await server.videos.upload({
token: noahToken, token: noahToken,

View file

@ -4,6 +4,7 @@ import { getContextFilter } from '@server/lib/activitypub/context.js'
import { buildChaptersAPHasPart } from '@server/lib/activitypub/video-chapters.js' import { buildChaptersAPHasPart } from '@server/lib/activitypub/video-chapters.js'
import { InternalEventEmitter } from '@server/lib/internal-event-emitter.js' import { InternalEventEmitter } from '@server/lib/internal-event-emitter.js'
import { getServerActor } from '@server/models/application/application.js' import { getServerActor } from '@server/models/application/application.js'
import { PlayerSettingModel } from '@server/models/video/player-setting.js'
import { VideoChapterModel } from '@server/models/video/video-chapter.js' import { VideoChapterModel } from '@server/models/video/video-chapter.js'
import { MAccountId, MActorId, MChannelId, MVideoId } from '@server/types/models/index.js' import { MAccountId, MActorId, MChannelId, MVideoId } from '@server/types/models/index.js'
import cors from 'cors' import cors from 'cors'
@ -177,6 +178,13 @@ activityPubClientRouter.get(
asyncMiddleware(videosCustomGetValidator('only-video-and-blacklist')), asyncMiddleware(videosCustomGetValidator('only-video-and-blacklist')),
asyncMiddleware(videoDislikesController) asyncMiddleware(videoDislikesController)
) )
activityPubClientRouter.get(
'/videos/watch/:id/player-settings',
executeIfActivityPub,
activityPubRateLimiter,
asyncMiddleware(videosCustomGetValidator('only-video-and-blacklist')),
asyncMiddleware(videoPlayerSettingsController)
)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -228,6 +236,13 @@ activityPubClientRouter.get(
asyncMiddleware(videoChannelsHandleValidatorFactory({ checkIsLocal: true, checkManage: false })), asyncMiddleware(videoChannelsHandleValidatorFactory({ checkIsLocal: true, checkManage: false })),
asyncMiddleware(videoChannelPlaylistsController) asyncMiddleware(videoChannelPlaylistsController)
) )
activityPubClientRouter.get(
'/video-channels/:handle/player-settings',
executeIfActivityPub,
activityPubRateLimiter,
asyncMiddleware(videoChannelsHandleValidatorFactory({ checkIsLocal: true, checkManage: false })),
asyncMiddleware(channelPlayerSettingsController)
)
activityPubClientRouter.get( activityPubClientRouter.get(
'/redundancy/streaming-playlists/:streamingPlaylistType/:videoId', '/redundancy/streaming-playlists/:streamingPlaylistType/:videoId',
@ -399,6 +414,30 @@ async function videoCommentsController (req: express.Request, res: express.Respo
return activityPubResponse(activityPubContextify(json, 'Collection', getContextFilter()), res) return activityPubResponse(activityPubContextify(json, 'Collection', getContextFilter()), res)
} }
// ---------------------------------------------------------------------------
async function videoPlayerSettingsController (req: express.Request, res: express.Response) {
const video = res.locals.onlyVideo
if (redirectIfNotOwned(video.url, res)) return
const settings = await PlayerSettingModel.loadByVideoId(video.id)
const json = PlayerSettingModel.formatAPPlayerSetting({ channel: undefined, video, settings })
return activityPubResponse(activityPubContextify(json, 'PlayerSettings', getContextFilter()), res)
}
async function channelPlayerSettingsController (req: express.Request, res: express.Response) {
const channel = res.locals.videoChannel
const settings = await PlayerSettingModel.loadByChannelId(channel.id)
const json = PlayerSettingModel.formatAPPlayerSetting({ channel, video: undefined, settings })
return activityPubResponse(activityPubContextify(json, 'PlayerSettings', getContextFilter()), res)
}
// ---------------------------------------------------------------------------
async function videoChannelController (req: express.Request, res: express.Response) { async function videoChannelController (req: express.Request, res: express.Response) {
const videoChannel = res.locals.videoChannel const videoChannel = res.locals.videoChannel

View file

@ -605,6 +605,7 @@ function customConfig (): CustomConfig {
} }
}, },
player: { player: {
theme: CONFIG.DEFAULTS.PLAYER.THEME,
autoPlay: CONFIG.DEFAULTS.PLAYER.AUTO_PLAY autoPlay: CONFIG.DEFAULTS.PLAYER.AUTO_PLAY
} }
}, },

View file

@ -14,6 +14,7 @@ import { jobsRouter } from './jobs.js'
import { metricsRouter } from './metrics.js' import { metricsRouter } from './metrics.js'
import { oauthClientsRouter } from './oauth-clients.js' import { oauthClientsRouter } from './oauth-clients.js'
import { overviewsRouter } from './overviews.js' import { overviewsRouter } from './overviews.js'
import { playerSettingsRouter } from './player-settings.js'
import { pluginRouter } from './plugins.js' import { pluginRouter } from './plugins.js'
import { runnersRouter } from './runners/index.js' import { runnersRouter } from './runners/index.js'
import { searchRouter } from './search/index.js' import { searchRouter } from './search/index.js'
@ -48,6 +49,7 @@ apiRouter.use('/jobs', jobsRouter)
apiRouter.use('/metrics', metricsRouter) apiRouter.use('/metrics', metricsRouter)
apiRouter.use('/search', searchRouter) apiRouter.use('/search', searchRouter)
apiRouter.use('/overviews', overviewsRouter) apiRouter.use('/overviews', overviewsRouter)
apiRouter.use('/player-settings', playerSettingsRouter)
apiRouter.use('/plugins', pluginRouter) apiRouter.use('/plugins', pluginRouter)
apiRouter.use('/custom-pages', customPageRouter) apiRouter.use('/custom-pages', customPageRouter)
apiRouter.use('/blocklist', blocklistRouter) apiRouter.use('/blocklist', blocklistRouter)

View file

@ -0,0 +1,112 @@
import { PlayerChannelSettingsUpdate, PlayerVideoSettingsUpdate } from '@peertube/peertube-models'
import { upsertPlayerSettings } from '@server/lib/player-settings.js'
import {
getChannelPlayerSettingsValidator,
getVideoPlayerSettingsValidator,
updatePlayerSettingsValidatorFactory,
updateVideoPlayerSettingsValidator
} from '@server/middlewares/validators/player-settings.js'
import { PlayerSettingModel } from '@server/models/video/player-setting.js'
import express from 'express'
import {
apiRateLimiter,
asyncMiddleware,
authenticate,
optionalAuthenticate,
videoChannelsHandleValidatorFactory
} from '../../middlewares/index.js'
import { sendUpdateChannelPlayerSettings, sendUpdateVideoPlayerSettings } from '@server/lib/activitypub/send/send-update.js'
const playerSettingsRouter = express.Router()
playerSettingsRouter.use(apiRateLimiter)
playerSettingsRouter.get(
'/videos/:videoId',
optionalAuthenticate,
asyncMiddleware(getVideoPlayerSettingsValidator),
asyncMiddleware(getVideoPlayerSettings)
)
playerSettingsRouter.put(
'/videos/:videoId',
authenticate,
asyncMiddleware(updateVideoPlayerSettingsValidator),
updatePlayerSettingsValidatorFactory('video'),
asyncMiddleware(updateVideoPlayerSettings)
)
playerSettingsRouter.get(
'/video-channels/:handle',
optionalAuthenticate,
asyncMiddleware(videoChannelsHandleValidatorFactory({ checkIsLocal: false, checkManage: false })),
getChannelPlayerSettingsValidator,
asyncMiddleware(getChannelPlayerSettings)
)
playerSettingsRouter.put(
'/video-channels/:handle',
authenticate,
asyncMiddleware(videoChannelsHandleValidatorFactory({ checkIsLocal: true, checkManage: true })),
updatePlayerSettingsValidatorFactory('channel'),
asyncMiddleware(updateChannelPlayerSettings)
)
// ---------------------------------------------------------------------------
export {
playerSettingsRouter
}
// ---------------------------------------------------------------------------
async function getVideoPlayerSettings (req: express.Request, res: express.Response) {
const video = res.locals.onlyVideo || res.locals.videoAll
const { videoSetting, channelSetting } = await PlayerSettingModel.loadByVideoIdOrChannelId({
channelId: video.channelId,
videoId: video.id
})
if (req.query.raw === true) {
return res.json(PlayerSettingModel.formatVideoPlayerRawSetting(videoSetting))
}
return res.json(PlayerSettingModel.formatVideoPlayerSetting({ videoSetting, channelSetting }))
}
async function getChannelPlayerSettings (req: express.Request, res: express.Response) {
const channel = res.locals.videoChannel
const channelSetting = await PlayerSettingModel.loadByChannelId(channel.id)
if (req.query.raw === true) {
return res.json(PlayerSettingModel.formatChannelPlayerRawSetting(channelSetting))
}
return res.json(PlayerSettingModel.formatChannelPlayerSetting({ channelSetting }))
}
// ---------------------------------------------------------------------------
async function updateVideoPlayerSettings (req: express.Request, res: express.Response) {
const body: PlayerVideoSettingsUpdate = req.body
const video = res.locals.videoAll
const setting = await upsertPlayerSettings({ settings: body, channel: undefined, video })
await sendUpdateVideoPlayerSettings(video, setting, undefined)
return res.json(PlayerSettingModel.formatVideoPlayerRawSetting(setting))
}
async function updateChannelPlayerSettings (req: express.Request, res: express.Response) {
const body: PlayerChannelSettingsUpdate = req.body
const channel = res.locals.videoChannel
const settings = await upsertPlayerSettings({ settings: body, channel, video: undefined })
await sendUpdateChannelPlayerSettings(channel, settings, undefined)
return res.json(PlayerSettingModel.formatChannelPlayerRawSetting(settings))
}

View file

@ -2,10 +2,10 @@ import { arrayify } from '@peertube/peertube-core-utils'
import { ContextType } from '@peertube/peertube-models' import { ContextType } from '@peertube/peertube-models'
import { ACTIVITY_PUB, REMOTE_SCHEME } from '@server/initializers/constants.js' import { ACTIVITY_PUB, REMOTE_SCHEME } from '@server/initializers/constants.js'
import { isArray } from './custom-validators/misc.js' import { isArray } from './custom-validators/misc.js'
import { logger } from './logger.js'
import { buildDigest } from './peertube-crypto.js' import { buildDigest } from './peertube-crypto.js'
import type { signJsonLDObject } from './peertube-jsonld.js' import type { signJsonLDObject } from './peertube-jsonld.js'
import { doJSONRequest } from './requests.js' import { doJSONRequest } from './requests.js'
import { logger } from './logger.js'
export type ContextFilter = <T>(arg: T) => Promise<T> export type ContextFilter = <T>(arg: T) => Promise<T>
@ -75,6 +75,8 @@ type ContextValue = { [id: string]: string | { '@type': string, '@id': string }
const contextStore: { [id in ContextType]: (string | { [id: string]: string })[] } = { const contextStore: { [id in ContextType]: (string | { [id: string]: string })[] } = {
Video: buildContext({ Video: buildContext({
...getPlayerSettingsTypeContext(),
Hashtag: 'as:Hashtag', Hashtag: 'as:Hashtag',
category: 'sc:category', category: 'sc:category',
licence: 'sc:license', licence: 'sc:license',
@ -99,6 +101,7 @@ const contextStore: { [id in ContextType]: (string | { [id: string]: string })[]
}, },
Infohash: 'pt:Infohash', Infohash: 'pt:Infohash',
SensitiveTag: 'pt:SensitiveTag', SensitiveTag: 'pt:SensitiveTag',
tileWidth: { tileWidth: {
@ -131,6 +134,8 @@ const contextStore: { [id in ContextType]: (string | { [id: string]: string })[]
hasParts: 'sc:hasParts', hasParts: 'sc:hasParts',
playerSettings: 'pt:playerSettings',
views: { views: {
'@type': 'sc:Number', '@type': 'sc:Number',
'@id': 'pt:views' '@id': 'pt:views'
@ -236,6 +241,8 @@ const contextStore: { [id in ContextType]: (string | { [id: string]: string })[]
}), }),
Actor: buildContext({ Actor: buildContext({
...getPlayerSettingsTypeContext(),
playlists: { playlists: {
'@id': 'pt:playlists', '@id': 'pt:playlists',
'@type': '@id' '@type': '@id'
@ -303,9 +310,24 @@ const contextStore: { [id in ContextType]: (string | { [id: string]: string })[]
hasPart: 'sc:hasPart', hasPart: 'sc:hasPart',
endOffset: 'sc:endOffset', endOffset: 'sc:endOffset',
startOffset: 'sc:startOffset' startOffset: 'sc:startOffset'
}),
PlayerSettings: buildContext({
...getPlayerSettingsTypeContext(),
theme: 'pt:theme'
}) })
} }
function getPlayerSettingsTypeContext () {
return {
PlayerSettings: {
'@type': '@id',
'@id': 'pt:PlayerSettings'
}
}
}
let allContext: (string | ContextValue)[] let allContext: (string | ContextValue)[]
export function getAllContext () { export function getAllContext () {
if (allContext) return allContext if (allContext) return allContext

View file

@ -1,10 +1,11 @@
import validator from 'validator'
import { Activity, ActivityType } from '@peertube/peertube-models' import { Activity, ActivityType } from '@peertube/peertube-models'
import validator from 'validator'
import { isAbuseReasonValid } from '../abuses.js' import { isAbuseReasonValid } from '../abuses.js'
import { exists } from '../misc.js' import { exists } from '../misc.js'
import { sanitizeAndCheckActorObject } from './actor.js' import { sanitizeAndCheckActorObject } from './actor.js'
import { isCacheFileObjectValid } from './cache-file.js' import { isCacheFileObjectValid } from './cache-file.js'
import { isActivityPubUrlValid, isBaseActivityValid, isObjectValid } from './misc.js' import { isActivityPubUrlValid, isBaseActivityValid, isObjectValid } from './misc.js'
import { sanitizeAndCheckPlayerSettingsObject } from './player-settings.js'
import { isPlaylistObjectValid } from './playlist.js' import { isPlaylistObjectValid } from './playlist.js'
import { sanitizeAndCheckVideoCommentObject } from './video-comments.js' import { sanitizeAndCheckVideoCommentObject } from './video-comments.js'
import { sanitizeAndCheckVideoTorrentObject } from './videos.js' import { sanitizeAndCheckVideoTorrentObject } from './videos.js'
@ -28,7 +29,7 @@ function isActivity (activity: any) {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
const activityCheckers: { [ P in ActivityType ]: (activity: Activity) => boolean } = { const activityCheckers: { [P in ActivityType]: (activity: Activity) => boolean } = {
Create: isCreateActivityValid, Create: isCreateActivityValid,
Update: isUpdateActivityValid, Update: isUpdateActivityValid,
Delete: isDeleteActivityValid, Delete: isDeleteActivityValid,
@ -88,7 +89,6 @@ export function isCreateActivityValid (activity: any) {
isFlagActivityValid(activity.object) || isFlagActivityValid(activity.object) ||
isPlaylistObjectValid(activity.object) || isPlaylistObjectValid(activity.object) ||
isWatchActionObjectValid(activity.object) || isWatchActionObjectValid(activity.object) ||
isCacheFileObjectValid(activity.object) || isCacheFileObjectValid(activity.object) ||
sanitizeAndCheckVideoCommentObject(activity.object) || sanitizeAndCheckVideoCommentObject(activity.object) ||
sanitizeAndCheckVideoTorrentObject(activity.object) sanitizeAndCheckVideoTorrentObject(activity.object)
@ -101,7 +101,9 @@ export function isUpdateActivityValid (activity: any) {
isCacheFileObjectValid(activity.object) || isCacheFileObjectValid(activity.object) ||
isPlaylistObjectValid(activity.object) || isPlaylistObjectValid(activity.object) ||
sanitizeAndCheckVideoTorrentObject(activity.object) || sanitizeAndCheckVideoTorrentObject(activity.object) ||
sanitizeAndCheckActorObject(activity.object) sanitizeAndCheckActorObject(activity.object) ||
sanitizeAndCheckPlayerSettingsObject(activity.object, 'video') ||
sanitizeAndCheckPlayerSettingsObject(activity.object, 'channel')
) )
} }

View file

@ -0,0 +1,13 @@
import { PlayerSettingsObject } from '@peertube/peertube-models'
import { isPlayerChannelThemeSettingValid, isPlayerVideoThemeSettingValid } from '../player-settings.js'
import { isActivityPubUrlValid } from './misc.js'
export function sanitizeAndCheckPlayerSettingsObject (settings: PlayerSettingsObject, target: 'video' | 'channel') {
if (!settings) return false
if (settings.type !== 'PlayerSettings') return false
if (target === 'video' && !isPlayerVideoThemeSettingValid(settings.theme)) return false
if (target === 'channel' && !isPlayerChannelThemeSettingValid(settings.theme)) return false
return isActivityPubUrlValid(settings.id)
}

View file

@ -0,0 +1,16 @@
import { PlayerTheme, PlayerThemeVideoSetting } from '@peertube/peertube-models'
import { DEFAULT_CHANNEL_PLAYER_SETTING_VALUE, DEFAULT_INSTANCE_PLAYER_SETTING_VALUE } from '@server/initializers/constants.js'
export function isPlayerVideoThemeSettingValid (name: PlayerThemeVideoSetting) {
return isPlayerChannelThemeSettingValid(name) || name === DEFAULT_CHANNEL_PLAYER_SETTING_VALUE
}
export function isPlayerChannelThemeSettingValid (name: PlayerThemeVideoSetting) {
return name === DEFAULT_INSTANCE_PLAYER_SETTING_VALUE || isPlayerThemeValid(name as PlayerTheme)
}
const availableThemes = new Set<PlayerTheme>([ 'galaxy', 'lucide' ])
export function isPlayerThemeValid (name: PlayerTheme) {
return availableThemes.has(name)
}

View file

@ -135,6 +135,7 @@ export function checkMissedConfig () {
'defaults.publish.privacy', 'defaults.publish.privacy',
'defaults.publish.licence', 'defaults.publish.licence',
'defaults.player.auto_play', 'defaults.player.auto_play',
'defaults.player.theme',
'instance.name', 'instance.name',
'instance.short_description', 'instance.short_description',
'instance.default_language', 'instance.default_language',

View file

@ -1,6 +1,7 @@
import { import {
BroadcastMessageLevel, BroadcastMessageLevel,
NSFWPolicyType, NSFWPolicyType,
PlayerTheme,
VideoCommentPolicyType, VideoCommentPolicyType,
VideoPrivacyType, VideoPrivacyType,
VideoRedundancyConfigFilter, VideoRedundancyConfigFilter,
@ -172,6 +173,9 @@ const CONFIG = {
} }
}, },
PLAYER: { PLAYER: {
get THEME () {
return config.get<PlayerTheme>('defaults.player.theme')
},
get AUTO_PLAY () { get AUTO_PLAY () {
return config.get<boolean>('defaults.player.auto_play') return config.get<boolean>('defaults.player.auto_play')
} }

View file

@ -8,6 +8,8 @@ import {
FollowState, FollowState,
JobType, JobType,
NSFWPolicyType, NSFWPolicyType,
PlayerThemeChannelSetting,
PlayerThemeVideoSetting,
RunnerJobState, RunnerJobState,
RunnerJobStateType, RunnerJobStateType,
UploadImageType, UploadImageType,
@ -50,7 +52,7 @@ import { CONFIG, registerConfigChangedHandler } from './config.js'
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
export const LAST_MIGRATION_VERSION = 925 export const LAST_MIGRATION_VERSION = 930
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -1177,7 +1179,9 @@ export const PLUGIN_GLOBAL_CSS_PATH = join(CONFIG.STORAGE.TMP_DIR, PLUGIN_GLOBAL
export let PLUGIN_EXTERNAL_AUTH_TOKEN_LIFETIME = 1000 * 60 * 5 // 5 minutes export let PLUGIN_EXTERNAL_AUTH_TOKEN_LIFETIME = 1000 * 60 * 5 // 5 minutes
export const DEFAULT_THEME_NAME = 'default' export const DEFAULT_THEME_NAME = 'default'
export const DEFAULT_USER_THEME_NAME = 'instance-default' export const DEFAULT_INSTANCE_THEME_NAME = 'instance-default'
export const DEFAULT_CHANNEL_PLAYER_SETTING_VALUE: PlayerThemeVideoSetting = 'channel-default'
export const DEFAULT_INSTANCE_PLAYER_SETTING_VALUE: PlayerThemeVideoSetting | PlayerThemeChannelSetting = 'instance-default'
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------

View file

@ -69,6 +69,7 @@ import { VideoTagModel } from '../models/video/video-tag.js'
import { VideoModel } from '../models/video/video.js' import { VideoModel } from '../models/video/video.js'
import { VideoViewModel } from '../models/view/video-view.js' import { VideoViewModel } from '../models/view/video-view.js'
import { CONFIG } from './config.js' import { CONFIG } from './config.js'
import { PlayerSettingModel } from '@server/models/video/player-setting.js'
pg.defaults.parseInt8 = true // Avoid BIGINT to be converted to string pg.defaults.parseInt8 = true // Avoid BIGINT to be converted to string
@ -189,7 +190,8 @@ export async function initDatabaseModels (silent: boolean) {
WatchedWordsListModel, WatchedWordsListModel,
AccountAutomaticTagPolicyModel, AccountAutomaticTagPolicyModel,
UploadImageModel, UploadImageModel,
VideoLiveScheduleModel VideoLiveScheduleModel,
PlayerSettingModel
]) ])
// Check extensions exist in the database // Check extensions exist in the database

View file

@ -0,0 +1,28 @@
import * as Sequelize from 'sequelize'
async function up (utils: {
transaction: Sequelize.Transaction
queryInterface: Sequelize.QueryInterface
sequelize: Sequelize.Sequelize
}): Promise<void> {
const query = `CREATE TABLE IF NOT EXISTS "playerSetting" (
"id" SERIAL,
"theme" VARCHAR(255) NOT NULL DEFAULT 'instance-default',
"videoId" INTEGER REFERENCES "video" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
"channelId" INTEGER REFERENCES "videoChannel" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
"createdAt" TIMESTAMP WITH TIME ZONE NOT NULL,
"updatedAt" TIMESTAMP WITH TIME ZONE NOT NULL,
PRIMARY KEY ("id")
);`
await utils.sequelize.query(query, { transaction: utils.transaction })
}
function down (options) {
throw new Error('Not implemented.')
}
export {
up,
down
}

View file

@ -1,5 +1,5 @@
import { Op, Transaction } from 'sequelize'
import { ActivityPubActor, ActorImageType, ActorImageType_Type } from '@peertube/peertube-models' import { ActivityPubActor, ActorImageType, ActorImageType_Type } from '@peertube/peertube-models'
import { isAccountActor, isChannelActor } from '@server/helpers/actors.js'
import { sequelizeTypescript } from '@server/initializers/database.js' import { sequelizeTypescript } from '@server/initializers/database.js'
import { AccountModel } from '@server/models/account/account.js' import { AccountModel } from '@server/models/account/account.js'
import { ActorModel } from '@server/models/actor/actor.js' import { ActorModel } from '@server/models/actor/actor.js'
@ -15,18 +15,17 @@ import {
MChannel, MChannel,
MServer MServer
} from '@server/types/models/index.js' } from '@server/types/models/index.js'
import { Op, Transaction } from 'sequelize'
import { upsertAPPlayerSettings } from '../../player-settings.js'
import { updateActorImages } from '../image.js' import { updateActorImages } from '../image.js'
import { getActorAttributesFromObject, getActorDisplayNameFromObject, getImagesInfoFromObject } from './object-to-model-attributes.js' import { getActorAttributesFromObject, getActorDisplayNameFromObject, getImagesInfoFromObject } from './object-to-model-attributes.js'
import { fetchActorFollowsCount } from './url-to-object.js' import { fetchActorFollowsCount } from './url-to-object.js'
import { isAccountActor, isChannelActor } from '@server/helpers/actors.js'
export class APActorCreator { export class APActorCreator {
constructor ( constructor (
private readonly actorObject: ActivityPubActor, private readonly actorObject: ActivityPubActor,
private readonly ownerActor?: MActorFullActor private readonly ownerActor?: MActorFullActor
) { ) {
} }
async create (): Promise<MActorFullActor> { async create (): Promise<MActorFullActor> {
@ -34,7 +33,7 @@ export class APActorCreator {
const actorInstance = new ActorModel(getActorAttributesFromObject(this.actorObject, followersCount, followingCount)) const actorInstance = new ActorModel(getActorAttributesFromObject(this.actorObject, followersCount, followingCount))
return sequelizeTypescript.transaction(async t => { const actor = await sequelizeTypescript.transaction(async t => {
const server = await this.setServer(actorInstance, t) const server = await this.setServer(actorInstance, t)
const { actorCreated, created } = await this.saveActor(actorInstance, t) const { actorCreated, created } = await this.saveActor(actorInstance, t)
@ -58,6 +57,17 @@ export class APActorCreator {
return actorCreated return actorCreated
}) })
if (isChannelActor(actor.type) && typeof this.actorObject.playerSettings === 'string') {
await upsertAPPlayerSettings({
settingsObject: this.actorObject.playerSettings,
video: undefined,
channel: actor.VideoChannel,
contextUrl: actor.url
})
}
return actor
} }
private async setServer (actor: MActor, t: Transaction) { private async setServer (actor: MActor, t: Transaction) {

View file

@ -4,6 +4,7 @@ import { logger } from '@server/helpers/logger.js'
import { AccountModel } from '@server/models/account/account.js' import { AccountModel } from '@server/models/account/account.js'
import { VideoChannelModel } from '@server/models/video/video-channel.js' import { VideoChannelModel } from '@server/models/video/video-channel.js'
import { MAccount, MActor, MActorFull, MChannel } from '@server/types/models/index.js' import { MAccount, MActor, MActorFull, MChannel } from '@server/types/models/index.js'
import { upsertAPPlayerSettings } from '../player-settings.js'
import { getOrCreateAPOwner } from './get.js' import { getOrCreateAPOwner } from './get.js'
import { updateActorImages } from './image.js' import { updateActorImages } from './image.js'
import { fetchActorFollowsCount } from './shared/index.js' import { fetchActorFollowsCount } from './shared/index.js'
@ -36,6 +37,15 @@ export class APActorUpdater {
this.accountOrChannel.Account = owner.Account as AccountModel this.accountOrChannel.Account = owner.Account as AccountModel
this.accountOrChannel.support = this.actorObject.support this.accountOrChannel.support = this.actorObject.support
if (typeof this.actorObject.playerSettings === 'string') {
await upsertAPPlayerSettings({
settingsObject: this.actorObject.playerSettings,
video: undefined,
channel: this.accountOrChannel,
contextUrl: this.actor.url
})
}
} }
await runInReadCommittedTransaction(async t => { await runInReadCommittedTransaction(async t => {

View file

@ -0,0 +1,44 @@
import { PlayerSettingsObject } from '@peertube/peertube-models'
import { sanitizeAndCheckPlayerSettingsObject } from '@server/helpers/custom-validators/activitypub/player-settings.js'
import { MChannelId, MChannelUrl, MVideoIdUrl } from '../../types/models/index.js'
import { upsertPlayerSettings } from '../player-settings.js'
import { fetchAPObjectIfNeeded } from './activity.js'
import { checkUrlsSameHost } from './url.js'
export async function upsertAPPlayerSettings (options: {
video: MVideoIdUrl
channel: MChannelUrl & MChannelId
settingsObject: PlayerSettingsObject | string
contextUrl: string
}) {
const { video, channel, contextUrl } = options
if (!video && !channel) throw new Error('Video or channel must be specified')
const settingsObject = await fetchAPObjectIfNeeded<PlayerSettingsObject>(options.settingsObject)
if (!sanitizeAndCheckPlayerSettingsObject(settingsObject, video ? 'video' : 'channel')) {
throw new Error(`Player settings ${settingsObject.id} object is not valid`)
}
if (!checkUrlsSameHost(settingsObject.id, contextUrl)) {
throw new Error(`Player settings ${settingsObject.id} object is not on the same host as context URL ${contextUrl}`)
}
const objectUrl = video?.url || channel?.Actor.url
if (!checkUrlsSameHost(settingsObject.id, objectUrl)) {
throw new Error(`Player settings ${settingsObject.id} object is not on the same host as context URL ${contextUrl}`)
}
await upsertPlayerSettings({ settings: getPlayerSettingsAttributesFromObject(settingsObject), channel, video })
}
// ---------------------------------------------------------------------------
// Private
// ---------------------------------------------------------------------------
function getPlayerSettingsAttributesFromObject (settingsObject: PlayerSettingsObject) {
return {
theme: settingsObject.theme
}
}

View file

@ -5,6 +5,7 @@ import {
ActivityUpdate, ActivityUpdate,
ActivityUpdateObject, ActivityUpdateObject,
CacheFileObject, CacheFileObject,
PlayerSettingsObject,
PlaylistObject, PlaylistObject,
VideoObject VideoObject
} from '@peertube/peertube-models' } from '@peertube/peertube-models'
@ -17,13 +18,15 @@ import { logger } from '../../../helpers/logger.js'
import { sequelizeTypescript } from '../../../initializers/database.js' import { sequelizeTypescript } from '../../../initializers/database.js'
import { ActorModel } from '../../../models/actor/actor.js' import { ActorModel } from '../../../models/actor/actor.js'
import { APProcessorOptions } from '../../../types/activitypub-processor.model.js' import { APProcessorOptions } from '../../../types/activitypub-processor.model.js'
import { MActorFull, MActorSignature } from '../../../types/models/index.js' import { MActorAccountChannelId, MActorFull, MActorSignature } from '../../../types/models/index.js'
import { fetchAPObjectIfNeeded } from '../activity.js' import { fetchAPObjectIfNeeded } from '../activity.js'
import { getOrCreateAPActor } from '../actors/get.js'
import { APActorUpdater } from '../actors/updater.js' import { APActorUpdater } from '../actors/updater.js'
import { createOrUpdateCacheFile } from '../cache-file.js' import { createOrUpdateCacheFile } from '../cache-file.js'
import { upsertAPPlayerSettings } from '../player-settings.js'
import { createOrUpdateVideoPlaylist } from '../playlists/index.js' import { createOrUpdateVideoPlaylist } from '../playlists/index.js'
import { forwardVideoRelatedActivity } from '../send/shared/send-utils.js' import { forwardVideoRelatedActivity } from '../send/shared/send-utils.js'
import { APVideoUpdater, canVideoBeFederated, getOrCreateAPVideo } from '../videos/index.js' import { APVideoUpdater, canVideoBeFederated, getOrCreateAPVideo, maybeGetOrCreateAPVideo } from '../videos/index.js'
async function processUpdateActivity (options: APProcessorOptions<ActivityUpdate<ActivityUpdateObject>>) { async function processUpdateActivity (options: APProcessorOptions<ActivityUpdate<ActivityUpdateObject>>) {
const { activity, byActor } = options const { activity, byActor } = options
@ -51,6 +54,10 @@ async function processUpdateActivity (options: APProcessorOptions<ActivityUpdate
return retryTransactionWrapper(processUpdatePlaylist, byActor, activity, object) return retryTransactionWrapper(processUpdatePlaylist, byActor, activity, object)
} }
if (objectType === 'PlayerSettings') {
return retryTransactionWrapper(processUpdatePlayerSettings, byActor, object)
}
return undefined return undefined
} }
@ -130,3 +137,34 @@ async function processUpdatePlaylist (
await createOrUpdateVideoPlaylist({ playlistObject, contextUrl: byActor.url, to: arrayify(activity.to) }) await createOrUpdateVideoPlaylist({ playlistObject, contextUrl: byActor.url, to: arrayify(activity.to) })
} }
async function processUpdatePlayerSettings (
byActor: MActorSignature,
settingsObject: PlayerSettingsObject
) {
let actor: MActorAccountChannelId
const { video } = await maybeGetOrCreateAPVideo({ videoObject: settingsObject.object })
if (!video) {
try {
actor = await getOrCreateAPActor(settingsObject.object)
} catch {
actor = undefined
}
}
if (!video && !actor?.VideoChannel) {
logger.warn(`Do not process update player settings on unknown video/channel`)
return
}
await upsertAPPlayerSettings({
settingsObject,
contextUrl: byActor.url,
video,
channel: actor
? Object.assign(actor.VideoChannel, { Actor: actor })
: undefined
})
}

View file

@ -1,5 +1,7 @@
import { ActivityAudience, ActivityUpdate, ActivityUpdateObject, VideoPlaylistPrivacy } from '@peertube/peertube-models' import { ActivityAudience, ActivityUpdate, ActivityUpdateObject, VideoPlaylistPrivacy } from '@peertube/peertube-models'
import { getServerActor } from '@server/models/application/application.js' import { getServerActor } from '@server/models/application/application.js'
import { PlayerSettingModel } from '@server/models/video/player-setting.js'
import { MPlayerSetting } from '@server/types/models/video/player-setting.js'
import { Transaction } from 'sequelize' import { Transaction } from 'sequelize'
import { logger } from '../../../helpers/logger.js' import { logger } from '../../../helpers/logger.js'
import { AccountModel } from '../../../models/account/account.js' import { AccountModel } from '../../../models/account/account.js'
@ -11,11 +13,12 @@ import {
MActorLight, MActorLight,
MChannelDefault, MChannelDefault,
MVideoAPLight, MVideoAPLight,
MVideoFullLight,
MVideoPlaylistFull, MVideoPlaylistFull,
MVideoRedundancyVideo MVideoRedundancyVideo
} from '../../../types/models/index.js' } from '../../../types/models/index.js'
import { audiencify, getPlaylistAudience, getPublicAudience, getVideoAudience } from '../audience.js' import { audiencify, getPlaylistAudience, getPublicAudience, getVideoAudience } from '../audience.js'
import { getUpdateActivityPubUrl } from '../url.js' import { getLocalChannelPlayerSettingsActivityPubUrl, getLocalVideoPlayerSettingsActivityPubUrl, getUpdateActivityPubUrl } from '../url.js'
import { canVideoBeFederated } from '../videos/federate.js' import { canVideoBeFederated } from '../videos/federate.js'
import { getActorsInvolvedInVideo } from './shared/index.js' import { getActorsInvolvedInVideo } from './shared/index.js'
import { broadcastToFollowers, sendVideoRelatedActivity } from './shared/send-utils.js' import { broadcastToFollowers, sendVideoRelatedActivity } from './shared/send-utils.js'
@ -58,21 +61,10 @@ export async function sendUpdateActor (accountOrChannel: MChannelDefault | MAcco
const audience = getPublicAudience(byActor) const audience = getPublicAudience(byActor)
const updateActivity = buildUpdateActivity(url, byActor, accountOrChannelObject, audience) const updateActivity = buildUpdateActivity(url, byActor, accountOrChannelObject, audience)
let actorsInvolved: MActor[]
if (accountOrChannel instanceof AccountModel) {
// Actors that shared my videos are involved too
actorsInvolved = await VideoShareModel.loadActorsWhoSharedVideosOf(byActor.id, transaction)
} else {
// Actors that shared videos of my channel are involved too
actorsInvolved = await VideoShareModel.loadActorsByVideoChannel(accountOrChannel.id, transaction)
}
actorsInvolved.push(byActor)
return broadcastToFollowers({ return broadcastToFollowers({
data: updateActivity, data: updateActivity,
byActor, byActor,
toFollowersOf: actorsInvolved, toFollowersOf: await getToFollowersOfForActor(accountOrChannel, transaction),
transaction, transaction,
contextType: 'Actor' contextType: 'Actor'
}) })
@ -127,6 +119,53 @@ export async function sendUpdateVideoPlaylist (videoPlaylist: MVideoPlaylistFull
}) })
} }
export async function sendUpdateVideoPlayerSettings (video: MVideoFullLight, settings: MPlayerSetting, transaction: Transaction) {
if (!canVideoBeFederated(video, false)) return
const byActor = video.VideoChannel.Account.Actor
const settingsUrl = getLocalVideoPlayerSettingsActivityPubUrl(video)
logger.info('Creating job to update video player settings ' + settingsUrl)
const updateUrl = getUpdateActivityPubUrl(settingsUrl, settings.updatedAt.toISOString())
const object = PlayerSettingModel.formatAPPlayerSetting({ settings, video, channel: undefined })
const audience = getVideoAudience(byActor, video.privacy)
const updateActivity = buildUpdateActivity(updateUrl, byActor, object, audience)
const toFollowersOf = await getActorsInvolvedInVideo(video, transaction)
return broadcastToFollowers({
data: updateActivity,
byActor,
toFollowersOf,
transaction,
contextType: 'PlayerSettings'
})
}
export async function sendUpdateChannelPlayerSettings (channel: MChannelDefault, settings: MPlayerSetting, transaction: Transaction) {
const byActor = channel.Actor
const settingsUrl = getLocalChannelPlayerSettingsActivityPubUrl(channel.Actor.preferredUsername)
logger.info('Creating job to update channel player settings actor ' + settingsUrl)
const url = getUpdateActivityPubUrl(byActor.url, byActor.updatedAt.toISOString())
const object = PlayerSettingModel.formatAPPlayerSetting({ settings, video: undefined, channel })
const audience = getPublicAudience(byActor)
const updateActivity = buildUpdateActivity(url, byActor, object, audience)
return broadcastToFollowers({
data: updateActivity,
byActor,
toFollowersOf: await getToFollowersOfForActor(channel, transaction),
transaction,
contextType: 'PlayerSettings'
})
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Private // Private
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -149,3 +188,18 @@ function buildUpdateActivity (
audience audience
) )
} }
async function getToFollowersOfForActor (accountOrChannel: MChannelDefault | MAccountDefault, transaction?: Transaction) {
let actorsInvolved: MActor[]
if (accountOrChannel instanceof AccountModel) {
// Actors that shared my videos are involved too
actorsInvolved = await VideoShareModel.loadActorsWhoSharedVideosOf(accountOrChannel.Actor.id, transaction)
} else {
// Actors that shared videos of my channel are involved too
actorsInvolved = await VideoShareModel.loadActorsByVideoChannel(accountOrChannel.id, transaction)
}
actorsInvolved.push(accountOrChannel.Actor)
return actorsInvolved
}

View file

@ -6,7 +6,8 @@ import {
MActorFollow, MActorFollow,
MActorId, MActorId,
MActorUrl, MActorUrl,
MCommentId, MLocalVideoViewer, MCommentId,
MLocalVideoViewer,
MVideoId, MVideoId,
MVideoPlaylistElement, MVideoPlaylistElement,
MVideoUUID, MVideoUUID,
@ -40,6 +41,10 @@ export function getLocalVideoChannelActivityPubUrl (videoChannelName: string) {
return WEBSERVER.URL + '/video-channels/' + videoChannelName return WEBSERVER.URL + '/video-channels/' + videoChannelName
} }
export function getLocalChannelPlayerSettingsActivityPubUrl (videoChannelName: string) {
return WEBSERVER.URL + '/video-channels/' + videoChannelName + '/player-settings'
}
export function getLocalAccountActivityPubUrl (accountName: string) { export function getLocalAccountActivityPubUrl (accountName: string) {
return WEBSERVER.URL + '/accounts/' + accountName return WEBSERVER.URL + '/accounts/' + accountName
} }
@ -76,6 +81,10 @@ export function getLocalVideoChaptersActivityPubUrl (video: MVideoUrl) {
return video.url + '/chapters' return video.url + '/chapters'
} }
export function getLocalVideoPlayerSettingsActivityPubUrl (video: MVideoUrl) {
return video.url + '/player-settings'
}
export function getLocalVideoLikesActivityPubUrl (video: MVideoUrl) { export function getLocalVideoLikesActivityPubUrl (video: MVideoUrl) {
return video.url + '/likes' return video.url + '/likes'
} }

View file

@ -32,6 +32,7 @@ import {
import { CreationAttributes, Transaction } from 'sequelize' import { CreationAttributes, Transaction } from 'sequelize'
import { fetchAP } from '../../activity.js' import { fetchAP } from '../../activity.js'
import { findOwner, getOrCreateAPActor } from '../../actors/index.js' import { findOwner, getOrCreateAPActor } from '../../actors/index.js'
import { upsertAPPlayerSettings } from '../../player-settings.js'
import { import {
getCaptionAttributesFromObject, getCaptionAttributesFromObject,
getFileAttributesFromUrl, getFileAttributesFromUrl,
@ -160,7 +161,7 @@ export abstract class APVideoAbstractBuilder {
video.VideoFiles = await Promise.all(upsertTasks) video.VideoFiles = await Promise.all(upsertTasks)
} }
protected async updateChaptersOutsideTransaction (video: MVideoFullLight) { protected async updateChapters (video: MVideoFullLight) {
if (!this.videoObject.hasParts || typeof this.videoObject.hasParts !== 'string') return if (!this.videoObject.hasParts || typeof this.videoObject.hasParts !== 'string') return
const { body } = await fetchAP<VideoChaptersObject>(this.videoObject.hasParts) const { body } = await fetchAP<VideoChaptersObject>(this.videoObject.hasParts)
@ -180,6 +181,17 @@ export abstract class APVideoAbstractBuilder {
}) })
} }
protected async upsertPlayerSettings (video: MVideoFullLight) {
if (typeof this.videoObject.playerSettings !== 'string') return
await upsertAPPlayerSettings({
settingsObject: this.videoObject.playerSettings,
video,
channel: undefined,
contextUrl: video.url
})
}
protected async setStreamingPlaylists (video: MVideoFullLight, t: Transaction) { protected async setStreamingPlaylists (video: MVideoFullLight, t: Transaction) {
const streamingPlaylistAttributes = getStreamingPlaylistAttributesFromObject(video, this.videoObject) const streamingPlaylistAttributes = getStreamingPlaylistAttributesFromObject(video, this.videoObject)
const newStreamingPlaylists = streamingPlaylistAttributes.map(a => new VideoStreamingPlaylistModel(a)) const newStreamingPlaylists = streamingPlaylistAttributes.map(a => new VideoStreamingPlaylistModel(a))

View file

@ -62,7 +62,8 @@ export class APVideoCreator extends APVideoAbstractBuilder {
return { autoBlacklisted, videoCreated } return { autoBlacklisted, videoCreated }
}) })
await this.updateChaptersOutsideTransaction(videoCreated) await this.updateChapters(videoCreated)
await this.upsertPlayerSettings(videoCreated)
return { autoBlacklisted, videoCreated } return { autoBlacklisted, videoCreated }
} }

View file

@ -79,7 +79,8 @@ export class APVideoUpdater extends APVideoAbstractBuilder {
await runInReadCommittedTransaction(t => this.setCaptions(videoUpdated, t)) await runInReadCommittedTransaction(t => this.setCaptions(videoUpdated, t))
await this.updateChaptersOutsideTransaction(videoUpdated) await this.updateChapters(videoUpdated)
await this.upsertPlayerSettings(videoUpdated)
await autoBlacklistVideoIfNeeded({ await autoBlacklistVideoIfNeeded({
video: videoUpdated, video: videoUpdated,

View file

@ -0,0 +1,31 @@
import { retryTransactionWrapper } from '@server/helpers/database-utils.js'
import { sequelizeTypescript } from '@server/initializers/database.js'
import { PlayerSettingModel } from '@server/models/video/player-setting.js'
import { PlayerChannelSettings, PlayerVideoSettings } from '../../../packages/models/src/videos/player-settings.js'
import { MChannelId, MVideoId } from '@server/types/models/index.js'
export async function upsertPlayerSettings (options: {
settings: PlayerVideoSettings | PlayerChannelSettings
channel: MChannelId
video: MVideoId
}) {
const { settings, channel, video } = options
if (!channel && !video) throw new Error('channel or video must be specified')
return retryTransactionWrapper(() => {
return sequelizeTypescript.transaction(async transaction => {
const setting = channel
? await PlayerSettingModel.loadByChannelId(channel.id, transaction)
: await PlayerSettingModel.loadByVideoId(video.id, transaction)
if (setting) await setting.destroy({ transaction })
return PlayerSettingModel.create({
theme: settings.theme,
channelId: channel?.id,
videoId: video?.id
}, { transaction })
})
})
}

View file

@ -1,4 +1,4 @@
import { DEFAULT_THEME_NAME, DEFAULT_USER_THEME_NAME } from '../../initializers/constants.js' import { DEFAULT_THEME_NAME, DEFAULT_INSTANCE_THEME_NAME } from '../../initializers/constants.js'
import { PluginManager } from './plugin-manager.js' import { PluginManager } from './plugin-manager.js'
import { CONFIG } from '../../initializers/config.js' import { CONFIG } from '../../initializers/config.js'
import { ServerConfigManager } from '../server-config-manager.js' import { ServerConfigManager } from '../server-config-manager.js'
@ -13,7 +13,7 @@ export function getThemeOrDefault (name: string, defaultTheme: string) {
} }
export function isThemeRegistered (name: string) { export function isThemeRegistered (name: string) {
if (name === DEFAULT_THEME_NAME || name === DEFAULT_USER_THEME_NAME) return true if (name === DEFAULT_THEME_NAME || name === DEFAULT_INSTANCE_THEME_NAME) return true
return PluginManager.Instance.getRegisteredThemes().some(r => r.name === name) || return PluginManager.Instance.getRegisteredThemes().some(r => r.name === name) ||
ServerConfigManager.Instance.getBuiltInThemes().some(r => r.name === name) ServerConfigManager.Instance.getBuiltInThemes().some(r => r.name === name)

View file

@ -116,6 +116,7 @@ class ServerConfigManager {
} }
}, },
player: { player: {
theme: CONFIG.DEFAULTS.PLAYER.THEME,
autoPlay: CONFIG.DEFAULTS.PLAYER.AUTO_PLAY autoPlay: CONFIG.DEFAULTS.PLAYER.AUTO_PLAY
} }
}, },

View file

@ -1,12 +1,13 @@
import { ChannelExportJSON, PlayerChannelSettings } from '@peertube/peertube-models'
import { logger } from '@server/helpers/logger.js' import { logger } from '@server/helpers/logger.js'
import { PlayerSettingModel } from '@server/models/video/player-setting.js'
import { VideoChannelModel } from '@server/models/video/video-channel.js' import { VideoChannelModel } from '@server/models/video/video-channel.js'
import { ExportResult } from './abstract-user-exporter.js'
import { ChannelExportJSON } from '@peertube/peertube-models'
import { MChannelBannerAccountDefault } from '@server/types/models/index.js' import { MChannelBannerAccountDefault } from '@server/types/models/index.js'
import { MPlayerSetting } from '@server/types/models/video/player-setting.js'
import { ExportResult } from './abstract-user-exporter.js'
import { ActorExporter } from './actor-exporter.js' import { ActorExporter } from './actor-exporter.js'
export class ChannelsExporter extends ActorExporter <ChannelExportJSON> { export class ChannelsExporter extends ActorExporter<ChannelExportJSON> {
async export () { async export () {
const channelsJSON: ChannelExportJSON['channels'] = [] const channelsJSON: ChannelExportJSON['channels'] = []
let staticFiles: ExportResult<ChannelExportJSON>['staticFiles'] = [] let staticFiles: ExportResult<ChannelExportJSON>['staticFiles'] = []
@ -31,12 +32,15 @@ export class ChannelsExporter extends ActorExporter <ChannelExportJSON> {
} }
private async exportChannel (channelId: number) { private async exportChannel (channelId: number) {
const channel = await VideoChannelModel.loadAndPopulateAccount(channelId) const [ channel, playerSettings ] = await Promise.all([
VideoChannelModel.loadAndPopulateAccount(channelId),
PlayerSettingModel.loadByChannelId(channelId)
])
const { relativePathsFromJSON, staticFiles } = this.exportActorFiles(channel.Actor) const { relativePathsFromJSON, staticFiles } = this.exportActorFiles(channel.Actor)
return { return {
json: this.exportChannelJSON(channel, relativePathsFromJSON), json: this.exportChannelJSON(channel, playerSettings, relativePathsFromJSON),
staticFiles staticFiles
} }
} }
@ -45,6 +49,7 @@ export class ChannelsExporter extends ActorExporter <ChannelExportJSON> {
private exportChannelJSON ( private exportChannelJSON (
channel: MChannelBannerAccountDefault, channel: MChannelBannerAccountDefault,
playerSettings: MPlayerSetting,
archiveFiles: { avatar: string, banner: string } archiveFiles: { avatar: string, banner: string }
): ChannelExportJSON['channels'][0] { ): ChannelExportJSON['channels'][0] {
return { return {
@ -54,6 +59,8 @@ export class ChannelsExporter extends ActorExporter <ChannelExportJSON> {
description: channel.description, description: channel.description,
support: channel.support, support: channel.support,
playerSettings: this.exportPlayerSettingsJSON(playerSettings),
updatedAt: channel.updatedAt.toISOString(), updatedAt: channel.updatedAt.toISOString(),
createdAt: channel.createdAt.toISOString(), createdAt: channel.createdAt.toISOString(),
@ -61,4 +68,11 @@ export class ChannelsExporter extends ActorExporter <ChannelExportJSON> {
} }
} }
private exportPlayerSettingsJSON (playerSettings: MPlayerSetting) {
if (!playerSettings) return null
return {
theme: playerSettings.theme as PlayerChannelSettings['theme']
}
}
} }

View file

@ -13,6 +13,7 @@ import {
} from '@server/lib/object-storage/videos.js' } from '@server/lib/object-storage/videos.js'
import { VideoDownload } from '@server/lib/video-download.js' import { VideoDownload } from '@server/lib/video-download.js'
import { VideoPathManager } from '@server/lib/video-path-manager.js' import { VideoPathManager } from '@server/lib/video-path-manager.js'
import { PlayerSettingModel } from '@server/models/video/player-setting.js'
import { VideoCaptionModel } from '@server/models/video/video-caption.js' import { VideoCaptionModel } from '@server/models/video/video-caption.js'
import { VideoChannelModel } from '@server/models/video/video-channel.js' import { VideoChannelModel } from '@server/models/video/video-channel.js'
import { VideoChapterModel } from '@server/models/video/video-chapter.js' import { VideoChapterModel } from '@server/models/video/video-chapter.js'
@ -33,6 +34,7 @@ import {
MVideoLiveWithSettingSchedules, MVideoLiveWithSettingSchedules,
MVideoPassword MVideoPassword
} from '@server/types/models/index.js' } from '@server/types/models/index.js'
import { MPlayerSetting } from '@server/types/models/video/player-setting.js'
import { MVideoSource } from '@server/types/models/video/video-source.js' import { MVideoSource } from '@server/types/models/video/video-source.js'
import Bluebird from 'bluebird' import Bluebird from 'bluebird'
import { createReadStream } from 'fs' import { createReadStream } from 'fs'
@ -80,11 +82,12 @@ export class VideosExporter extends AbstractUserExporter<VideoExportJSON> {
} }
private async exportVideo (videoId: number) { private async exportVideo (videoId: number) {
const [ video, captions, source, chapters ] = await Promise.all([ const [ video, captions, source, chapters, playerSettings ] = await Promise.all([
VideoModel.loadFull(videoId), VideoModel.loadFull(videoId),
VideoCaptionModel.listVideoCaptions(videoId), VideoCaptionModel.listVideoCaptions(videoId),
VideoSourceModel.loadLatest(videoId), VideoSourceModel.loadLatest(videoId),
VideoChapterModel.listChaptersOfVideo(videoId) VideoChapterModel.listChaptersOfVideo(videoId),
PlayerSettingModel.loadByVideoId(videoId)
]) ])
const passwords = video.privacy === VideoPrivacy.PASSWORD_PROTECTED const passwords = video.privacy === VideoPrivacy.PASSWORD_PROTECTED
@ -101,7 +104,16 @@ export class VideosExporter extends AbstractUserExporter<VideoExportJSON> {
const { relativePathsFromJSON, staticFiles, exportedVideoFileOrSource } = await this.exportVideoFiles({ video, captions }) const { relativePathsFromJSON, staticFiles, exportedVideoFileOrSource } = await this.exportVideoFiles({ video, captions })
return { return {
json: this.exportVideoJSON({ video, captions, live, passwords, source, chapters, archiveFiles: relativePathsFromJSON }), json: this.exportVideoJSON({
video,
captions,
live,
passwords,
source,
chapters,
playerSettings,
archiveFiles: relativePathsFromJSON
}),
staticFiles, staticFiles,
relativePathsFromJSON, relativePathsFromJSON,
activityPubOutbox: await this.exportVideoAP(videoAP, chapters, exportedVideoFileOrSource) activityPubOutbox: await this.exportVideoAP(videoAP, chapters, exportedVideoFileOrSource)
@ -116,10 +128,11 @@ export class VideosExporter extends AbstractUserExporter<VideoExportJSON> {
live: MVideoLiveWithSettingSchedules live: MVideoLiveWithSettingSchedules
passwords: MVideoPassword[] passwords: MVideoPassword[]
source: MVideoSource source: MVideoSource
playerSettings: MPlayerSetting
chapters: MVideoChapter[] chapters: MVideoChapter[]
archiveFiles: VideoExportJSON['videos'][0]['archiveFiles'] archiveFiles: VideoExportJSON['videos'][0]['archiveFiles']
}): VideoExportJSON['videos'][0] { }): VideoExportJSON['videos'][0] {
const { video, captions, live, passwords, source, chapters, archiveFiles } = options const { video, captions, live, passwords, source, chapters, playerSettings, archiveFiles } = options
return { return {
uuid: video.uuid, uuid: video.uuid,
@ -182,6 +195,8 @@ export class VideosExporter extends AbstractUserExporter<VideoExportJSON> {
source: this.exportVideoSourceJSON(source), source: this.exportVideoSourceJSON(source),
playerSettings: this.exportPlayerSettingsJSON(playerSettings),
archiveFiles archiveFiles
} }
} }
@ -261,6 +276,14 @@ export class VideosExporter extends AbstractUserExporter<VideoExportJSON> {
} }
} }
private exportPlayerSettingsJSON (playerSettings: MPlayerSetting) {
if (!playerSettings) return null
return {
theme: playerSettings.theme
}
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
private async exportVideoAP ( private async exportVideoAP (

Some files were not shown because too many files have changed in this diff Show more