1
0
Fork 0
mirror of https://github.com/Chocobozzz/PeerTube.git synced 2025-10-02 17:29:29 +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",
"maximumWarning": "6kb",
"maximumError": "120kb"
"maximumError": "140kb"
}
],
"fileReplacements": [

View file

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

View file

@ -7,26 +7,32 @@
</div>
<div class="content-col">
<ng-container formGroupName="theme">
<div class="form-group">
<label i18n for="themeDefault">Theme</label>
<div class="form-group" formGroupName="theme">
<label i18n for="themeDefault">Theme</label>
<my-select-options formControlName="default" inputId="themeDefault" [items]="availableThemes"></my-select-options>
</div>
</ng-container>
<my-select-options formControlName="default" inputId="themeDefault" [items]="availableThemes"></my-select-options>
</div>
<ng-container formGroupName="client">
<ng-container formGroupName="videos">
<ng-container formGroupName="miniature">
<div class="form-group">
<my-peertube-checkbox
inputName="clientVideosMiniaturePreferAuthorDisplayName"
formControlName="preferAuthorDisplayName"
i18n-labelText
labelText="Prefer author display name in video miniature"
></my-peertube-checkbox>
</div>
</ng-container>
<div class="form-group" formGroupName="miniature">
<my-peertube-checkbox
inputName="clientVideosMiniaturePreferAuthorDisplayName"
formControlName="preferAuthorDisplayName"
i18n-labelText
labelText="Prefer author display name in video miniature"
></my-peertube-checkbox>
</div>
</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>
</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 { SelectOptionsComponent } from '@app/shared/shared-forms/select/select-options.component'
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 { ColorPaletteThemeConfig, ThemeCustomizationKey } from '@root-helpers/theme-manager'
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 { AlertComponent } from '../../../shared/shared-main/common/alert.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')
@ -65,6 +66,12 @@ type Form = {
inputBorderRadius: FormControl<string>
}>
}>
defaults: FormGroup<{
player: FormGroup<{
theme: FormControl<PlayerTheme>
}>
}>
}
type FieldType = 'color' | 'radius'
@ -84,7 +91,8 @@ type FieldType = 'color' | 'radius'
SelectOptionsComponent,
HelpComponent,
PeertubeCheckboxComponent,
SelectCustomValueComponent
SelectCustomValueComponent,
SelectPlayerThemeComponent
]
})
export class AdminConfigCustomizationComponent implements OnInit, OnDestroy, CanComponentDeactivate {
@ -108,6 +116,7 @@ export class AdminConfigCustomizationComponent implements OnInit, OnDestroy, Can
}[] = []
availableThemes: SelectOptionsItem[]
availablePlayerThemes: SelectOptionsItem<PlayerTheme>[] = []
private customizationResetFields = new Set<ThemeCustomizationKey>()
private customConfig: CustomConfig
@ -164,6 +173,11 @@ export class AdminConfigCustomizationComponent implements OnInit, OnDestroy, Can
...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.subscribeToCustomizationChanges()
@ -265,6 +279,11 @@ export class AdminConfigCustomizationComponent implements OnInit, OnDestroy, Can
headerBackgroundColor: null,
inputBorderRadius: null
}
},
defaults: {
player: {
theme: null
}
}
}

View file

@ -7,7 +7,6 @@
</div>
<div class="content-col">
<div class="form-group" formGroupName="instance">
<label i18n id="instanceDefaultClientRouteLabel" for="instanceDefaultClientRoute">Landing page</label>
@ -43,13 +42,14 @@
</div>
<ng-container formGroupName="client">
<ng-container formGroupName="menu">
<ng-container formGroupName="login">
<div class="form-group">
<my-peertube-checkbox
inputName="clientMenuLoginRedirectOnSingleExternalAuth" formControlName="redirectOnSingleExternalAuth"
i18n-labelText labelText="Redirect users on single external auth when users click on the login button in menu"
inputName="clientMenuLoginRedirectOnSingleExternalAuth"
formControlName="redirectOnSingleExternalAuth"
i18n-labelText
labelText="Redirect users on single external auth when users click on the login button in menu"
>
<ng-container ngProjectAs="description">
@if (countExternalAuth() === 0) {
@ -58,12 +58,11 @@
<span i18n>⚠️ You have multiple external auth plugins enabled</span>
}
</ng-container>
</my-peertube-checkbox>
</my-peertube-checkbox>
</div>
</ng-container>
</ng-container>
</ng-container>
</div>
</div>
@ -76,20 +75,22 @@
</div>
<div class="content-col">
<ng-container formGroupName="broadcastMessage">
<div class="form-group">
<my-peertube-checkbox
inputName="broadcastMessageEnabled" formControlName="enabled"
i18n-labelText labelText="Enable broadcast message"
inputName="broadcastMessageEnabled"
formControlName="enabled"
i18n-labelText
labelText="Enable broadcast message"
></my-peertube-checkbox>
</div>
<div class="form-group">
<my-peertube-checkbox
inputName="broadcastMessageDismissable" formControlName="dismissable"
i18n-labelText labelText="Allow users to dismiss the broadcast message "
inputName="broadcastMessageDismissable"
formControlName="dismissable"
i18n-labelText
labelText="Allow users to dismiss the broadcast message "
></my-peertube-checkbox>
</div>
@ -111,31 +112,28 @@
<label i18n for="broadcastMessageMessage">Message</label><my-help helpType="markdownText"></my-help>
<my-markdown-textarea
inputId="broadcastMessageMessage" formControlName="message"
[formError]="formErrors.broadcastMessage.message" markdownType="to-unsafe-html"
inputId="broadcastMessageMessage"
formControlName="message"
[formError]="formErrors.broadcastMessage.message"
markdownType="to-unsafe-html"
></my-markdown-textarea>
<div *ngIf="formErrors.broadcastMessage.message" class="form-error" role="alert">{{ formErrors.broadcastMessage.message }}</div>
</div>
</ng-container>
</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">
<h2 i18n>NEW USERS</h2>
</div>
<div class="content-col">
<ng-container formGroupName="signup">
<div class="form-group">
<my-peertube-checkbox
inputName="signupEnabled" formControlName="enabled"
i18n-labelText labelText="Enable Signup"
>
<my-peertube-checkbox inputName="signupEnabled" formControlName="enabled" i18n-labelText labelText="Enable Signup">
<ng-container ngProjectAs="description">
<span i18n>⚠️ This functionality requires a lot of attention and extra moderation</span>
@ -144,16 +142,22 @@
<ng-container ngProjectAs="extra">
<div class="form-group">
<my-peertube-checkbox [ngClass]="getDisabledSignupClass()"
inputName="signupRequiresApproval" formControlName="requiresApproval"
i18n-labelText labelText="Signup requires approval by moderators"
<my-peertube-checkbox
[ngClass]="getDisabledSignupClass()"
inputName="signupRequiresApproval"
formControlName="requiresApproval"
i18n-labelText
labelText="Signup requires approval by moderators"
></my-peertube-checkbox>
</div>
<div class="form-group">
<my-peertube-checkbox [ngClass]="getDisabledSignupClass()"
inputName="signupRequiresEmailVerification" formControlName="requiresEmailVerification"
i18n-labelText labelText="Signup requires email verification"
<my-peertube-checkbox
[ngClass]="getDisabledSignupClass()"
inputName="signupRequiresEmailVerification"
formControlName="requiresEmailVerification"
i18n-labelText
labelText="Signup requires email verification"
></my-peertube-checkbox>
</div>
@ -163,8 +167,12 @@
<div class="number-with-unit">
<input
type="number" min="-1" id="signupLimit" class="form-control"
formControlName="limit" [ngClass]="{ 'input-error': formErrors.signup.limit }"
type="number"
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>
</div>
@ -179,8 +187,12 @@
<div class="number-with-unit">
<input
type="number" min="1" id="signupMinimumAge" class="form-control"
formControlName="minimumAge" [ngClass]="{ 'input-error': formErrors.signup.minimumAge }"
type="number"
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>
</div>
@ -201,7 +213,9 @@
inputId="userVideoQuota"
[items]="getVideoQuotaOptions()"
formControlName="videoQuota"
i18n-inputSuffix inputSuffix="bytes" inputType="number"
i18n-inputSuffix
inputSuffix="bytes"
inputType="number"
[clearable]="false"
></my-select-custom-value>
@ -218,7 +232,9 @@
inputId="userVideoQuotaDaily"
[items]="getVideoQuotaDailyOptions()"
formControlName="videoQuotaDaily"
i18n-inputSuffix inputSuffix="bytes" inputType="number"
i18n-inputSuffix
inputSuffix="bytes"
inputType="number"
[clearable]="false"
></my-select-custom-value>
@ -228,15 +244,16 @@
<ng-container formGroupName="history">
<ng-container formGroupName="videos">
<my-peertube-checkbox
inputName="videosHistoryEnabled" formControlName="enabled"
i18n-labelText labelText="Automatically enable video history for a new user"
inputName="videosHistoryEnabled"
formControlName="enabled"
i18n-labelText
labelText="Automatically enable video history for a new user"
>
</my-peertube-checkbox>
</ng-container>
</ng-container>
</div>
</ng-container>
</div>
</div>
@ -246,11 +263,8 @@
</div>
<div class="content-col">
<ng-container formGroupName="import">
<ng-container formGroupName="videos">
<div class="form-group">
<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>
@ -265,39 +279,46 @@
<div class="form-group" formGroupName="http">
<my-peertube-checkbox
inputName="importVideosHttpEnabled" formControlName="enabled"
i18n-labelText labelText="Allow import with HTTP URL (e.g. YouTube)"
inputName="importVideosHttpEnabled"
formControlName="enabled"
i18n-labelText
labelText="Allow import with HTTP URL (e.g. YouTube)"
>
<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>
</my-peertube-checkbox>
</div>
<div class="form-group" formGroupName="torrent">
<my-peertube-checkbox
inputName="importVideosTorrentEnabled" formControlName="enabled"
i18n-labelText labelText="Allow import with a torrent file or a magnet URI"
inputName="importVideosTorrentEnabled"
formControlName="enabled"
i18n-labelText
labelText="Allow import with a torrent file or a magnet URI"
>
<ng-container ngProjectAs="description">
<span i18n>⚠️ We don't recommend to enable this feature if you don't trust your users</span>
</ng-container>
</my-peertube-checkbox>
<ng-container ngProjectAs="description">
<span i18n>⚠️ We don't recommend to enable this feature if you don't trust your users</span>
</ng-container>
</my-peertube-checkbox>
</div>
</ng-container>
<ng-container formGroupName="videoChannelSynchronization">
<div class="form-group">
<my-peertube-checkbox
inputName="importSynchronizationEnabled" formControlName="enabled"
i18n-labelText labelText="Allow channel synchronization with channel of other platforms like YouTube"
inputName="importSynchronizationEnabled"
formControlName="enabled"
i18n-labelText
labelText="Allow channel synchronization with channel of other platforms like YouTube"
>
<ng-container ngProjectAs="description">
<span i18n [hidden]="isImportVideosHttpEnabled()">
⛔ You need to allow import with HTTP URL to be able to activate this feature.
</span>
</ng-container>
<ng-container ngProjectAs="description">
<span i18n [hidden]="isImportVideosHttpEnabled()">
⛔ You need to allow import with HTTP URL to be able to activate this feature.
</span>
</ng-container>
</my-peertube-checkbox>
</div>
@ -306,16 +327,21 @@
<div class="number-with-unit">
<input
type="number" min="1" id="videoChannelSynchronizationMaxPerUser" class="form-control"
formControlName="maxPerUser" [ngClass]="{ 'input-error': formErrors['import']['videoChannelSynchronization']['maxPerUser'] }"
type="number"
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>
</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>
</ng-container>
</ng-container>
</div>
</div>
@ -354,22 +380,21 @@
</div>
<div class="content-col">
<ng-container formGroupName="autoBlacklist">
<ng-container formGroupName="videos">
<ng-container formGroupName="ofUsers">
<div class="form-group">
<my-peertube-checkbox
inputName="autoBlacklistVideosOfUsersEnabled" formControlName="enabled"
i18n-labelText labelText="Block new videos automatically"
inputName="autoBlacklistVideosOfUsersEnabled"
formControlName="enabled"
i18n-labelText
labelText="Block new videos automatically"
>
<ng-container ngProjectAs="description">
<span i18n>Unless a user is marked as trusted, their videos will stay private until a moderator reviews them.</span>
</ng-container>
</my-peertube-checkbox>
<ng-container ngProjectAs="description">
<span i18n>Unless a user is marked as trusted, their videos will stay private until a moderator reviews them.</span>
</ng-container>
</my-peertube-checkbox>
</div>
</ng-container>
</ng-container>
</ng-container>
@ -378,8 +403,10 @@
<ng-container formGroupName="update">
<div class="form-group">
<my-peertube-checkbox
inputName="videoFileUpdateEnabled" formControlName="enabled"
i18n-labelText labelText="Allow users to upload a new version of their video"
inputName="videoFileUpdateEnabled"
formControlName="enabled"
i18n-labelText
labelText="Allow users to upload a new version of their video"
>
</my-peertube-checkbox>
</div>
@ -388,10 +415,7 @@
<ng-container formGroupName="storyboards">
<div class="form-group">
<my-peertube-checkbox
inputName="storyboardsEnabled" formControlName="enabled"
i18n-labelText labelText="Enable video storyboards"
>
<my-peertube-checkbox inputName="storyboardsEnabled" formControlName="enabled" i18n-labelText labelText="Enable video storyboards">
<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>
</ng-container>
@ -415,19 +439,19 @@
<ng-container formGroupName="videoTranscription">
<div class="form-group">
<my-peertube-checkbox
inputName="videoTranscriptionEnabled" formControlName="enabled"
i18n-labelText labelText="Enable video transcription"
>
<my-peertube-checkbox inputName="videoTranscriptionEnabled" formControlName="enabled" i18n-labelText labelText="Enable video transcription">
<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 ngProjectAs="extra">
<div class="form-group" formGroupName="remoteRunners" [ngClass]="getTranscriptionRunnerDisabledClass()">
<my-peertube-checkbox
inputName="videoTranscriptionRemoteRunnersEnabled" formControlName="enabled"
i18n-labelText labelText="Enable remote runners for transcription"
inputName="videoTranscriptionRemoteRunnersEnabled"
formControlName="enabled"
i18n-labelText
labelText="Enable remote runners for transcription"
>
<ng-container ngProjectAs="description">
<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="publish">
<div class="form-group">
<label i18n for="defaultsPublishPrivacy">Default video privacy</label>
@ -482,8 +505,12 @@
<div class="number-with-unit">
<input
type="number" min="1" id="videoChannelsMaxPerUser" class="form-control"
formControlName="maxPerUser" [ngClass]="{ 'input-error': formErrors.videoChannels.maxPerUser }"
type="number"
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>
</div>
@ -502,12 +529,13 @@
</div>
<div class="content-col">
<ng-container formGroupName="videoComments">
<div class="form-group">
<my-peertube-checkbox
inputName="videoCommentsAcceptRemoteComments" formControlName="acceptRemoteComments"
i18n-labelText labelText="Accept comments made on remote platforms"
inputName="videoCommentsAcceptRemoteComments"
formControlName="acceptRemoteComments"
i18n-labelText
labelText="Accept comments made on remote platforms"
>
<ng-container ngProjectAs="description">
<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">
<div class="form-group">
<my-peertube-checkbox
inputName="followersChannelsEnabled" formControlName="enabled"
i18n-labelText labelText="Remote actors can follow channels of your platform"
inputName="followersChannelsEnabled"
formControlName="enabled"
i18n-labelText
labelText="Remote actors can follow channels of your platform"
>
<ng-container ngProjectAs="description">
<span i18n>This setting is not retroactive: current followers of channels of your platform will not be affected</span>
</ng-container>
</my-peertube-checkbox>
</my-peertube-checkbox>
</div>
</ng-container>
<ng-container formGroupName="instance">
<div class="form-group">
<my-peertube-checkbox
inputName="followersInstanceEnabled" formControlName="enabled"
i18n-labelText labelText="Remote actors can follow your platform"
inputName="followersInstanceEnabled"
formControlName="enabled"
i18n-labelText
labelText="Remote actors can follow your platform"
>
<ng-container ngProjectAs="description">
<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">
<my-peertube-checkbox
inputName="followersInstanceManualApproval" formControlName="manualApproval"
i18n-labelText labelText="Manually approve new followers that follow your platform"
inputName="followersInstanceManualApproval"
formControlName="manualApproval"
i18n-labelText
labelText="Manually approve new followers that follow your platform"
></my-peertube-checkbox>
</div>
</ng-container>
@ -554,12 +587,13 @@
<ng-container formGroupName="followings">
<ng-container formGroupName="instance">
<ng-container formGroupName="autoFollowBack">
<div class="form-group">
<my-peertube-checkbox
inputName="followingsInstanceAutoFollowBackEnabled" formControlName="enabled"
i18n-labelText labelText="Automatically follow back followers that follow your platform"
inputName="followingsInstanceAutoFollowBackEnabled"
formControlName="enabled"
i18n-labelText
labelText="Automatically follow back followers that follow your platform"
>
<ng-container ngProjectAs="description">
<span i18n>⚠️ This functionality requires a lot of attention and extra moderation</span>
@ -571,14 +605,21 @@
<ng-container formGroupName="autoFollowIndex">
<div class="form-group">
<my-peertube-checkbox
inputName="followingsInstanceAutoFollowIndexEnabled" formControlName="enabled"
i18n-labelText labelText="Automatically follow platforms of a public index"
inputName="followingsInstanceAutoFollowIndexEnabled"
formControlName="enabled"
i18n-labelText
labelText="Automatically follow platforms of a public index"
>
<ng-container ngProjectAs="description">
<div i18n>⚠️ This functionality requires a lot of attention and extra moderation.</div>
<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>
</ng-container>
@ -586,19 +627,22 @@
<div [ngClass]="{ 'disabled-checkbox-extra': !isAutoFollowIndexEnabled() }">
<label i18n for="followingsInstanceAutoFollowIndexUrl">Index URL</label>
<input
type="text" id="followingsInstanceAutoFollowIndexUrl" class="form-control"
formControlName="indexUrl" [ngClass]="{ 'input-error': formErrors.followings.instance.autoFollowIndex.indexUrl }"
type="text"
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>
</ng-container>
</my-peertube-checkbox>
</div>
</ng-container>
</ng-container>
</ng-container>
</div>
</div>
@ -609,27 +653,34 @@
<div class="content-col">
<ng-container formGroupName="defaults">
<ng-container formGroupName="player">
<div class="form-group" formGroupName="player">
<my-peertube-checkbox
inputName="defaultsPlayerAutoplay" formControlName="autoPlay"
i18n-labelText labelText="Automatically play videos in the player"
></my-peertube-checkbox>
</div>
<div class="form-group">
<my-peertube-checkbox
inputName="defaultsPlayerAutoplay"
formControlName="autoPlay"
i18n-labelText
labelText="Automatically play videos in the player"
></my-peertube-checkbox>
</div>
</ng-container>
<ng-container formGroupName="p2p">
<div class="form-group" formGroupName="webapp">
<my-peertube-checkbox
inputName="defaultsP2PWebappEnabled" formControlName="enabled"
i18n-labelText labelText="Enable P2P streaming by default on your platform"
inputName="defaultsP2PWebappEnabled"
formControlName="enabled"
i18n-labelText
labelText="Enable P2P streaming by default on your platform"
></my-peertube-checkbox>
</div>
<div class="form-group" formGroupName="embed">
<my-peertube-checkbox
inputName="defaultsP2PEmbedEnabled" formControlName="enabled"
i18n-labelText labelText="Enable P2P streaming by default for videos embedded on external websites"
inputName="defaultsP2PEmbedEnabled"
formControlName="enabled"
i18n-labelText
labelText="Enable P2P streaming by default for videos embedded on external websites"
></my-peertube-checkbox>
</div>
</ng-container>
@ -643,14 +694,14 @@
</div>
<div class="content-col">
<ng-container formGroupName="search">
<ng-container formGroupName="remoteUri">
<div class="form-group">
<my-peertube-checkbox
inputName="searchRemoteUriUsers" formControlName="users"
i18n-labelText labelText="Allow users to do remote URI/handle search"
inputName="searchRemoteUriUsers"
formControlName="users"
i18n-labelText
labelText="Allow users to do remote URI/handle search"
>
<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>
@ -660,23 +711,21 @@
<div class="form-group">
<my-peertube-checkbox
inputName="searchRemoteUriAnonymous" formControlName="anonymous"
i18n-labelText labelText="Allow anonymous to do remote URI/handle search"
inputName="searchRemoteUriAnonymous"
formControlName="anonymous"
i18n-labelText
labelText="Allow anonymous to do remote URI/handle search"
>
<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>
</ng-container>
</my-peertube-checkbox>
</div>
</ng-container>
<ng-container formGroupName="searchIndex">
<div class="form-group">
<my-peertube-checkbox
inputName="searchIndexEnabled" formControlName="enabled"
i18n-labelText labelText="Enable global search"
>
<my-peertube-checkbox inputName="searchIndexEnabled" formControlName="enabled" i18n-labelText labelText="Enable global search">
<ng-container ngProjectAs="description">
<div i18n>⚠️ This functionality depends heavily on the moderation of platforms followed by the search index you select</div>
</ng-container>
@ -690,39 +739,44 @@
</div>
<input
type="text" id="searchIndexUrl" class="form-control"
formControlName="url" [ngClass]="{ 'input-error': formErrors.search.searchIndex.url }"
type="text"
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>
<div class="mt-3">
<my-peertube-checkbox [ngClass]="getDisabledSearchIndexClass()"
inputName="searchIndexDisableLocalSearch" formControlName="disableLocalSearch"
i18n-labelText labelText="Disable local search in search bar"
<my-peertube-checkbox
[ngClass]="getDisabledSearchIndexClass()"
inputName="searchIndexDisableLocalSearch"
formControlName="disableLocalSearch"
i18n-labelText
labelText="Disable local search in search bar"
></my-peertube-checkbox>
</div>
<div class="mt-3">
<my-peertube-checkbox [ngClass]="getDisabledSearchIndexClass()"
inputName="searchIndexIsDefaultSearch" formControlName="isDefaultSearch"
i18n-labelText labelText="Search bar uses the global search index by default"
<my-peertube-checkbox
[ngClass]="getDisabledSearchIndexClass()"
inputName="searchIndexIsDefaultSearch"
formControlName="isDefaultSearch"
i18n-labelText
labelText="Search bar uses the global search index by default"
>
<ng-container ngProjectAs="description">
<span i18n>Otherwise, the local search will be used by default</span>
</ng-container>
</my-peertube-checkbox>
</div>
</ng-container>
</my-peertube-checkbox>
</div>
</ng-container>
</ng-container>
</div>
</div>
@ -732,14 +786,10 @@
</div>
<div class="content-col">
<ng-container formGroupName="import">
<ng-container formGroupName="users">
<div class="form-group">
<my-peertube-checkbox
inputName="importUsersEnabled" formControlName="enabled"
i18n-labelText labelText="Allow your users to import a data archive"
>
<my-peertube-checkbox inputName="importUsersEnabled" formControlName="enabled" i18n-labelText labelText="Allow your users to import a data archive">
<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 (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 formGroupName="export">
<ng-container formGroupName="users">
<div class="form-group">
<my-peertube-checkbox
inputName="exportUsersEnabled" formControlName="enabled"
i18n-labelText labelText="Allow your users to export their data"
>
<my-peertube-checkbox inputName="exportUsersEnabled" formControlName="enabled" i18n-labelText labelText="Allow your users to export their data">
<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>
</ng-container>
<ng-container ngProjectAs="extra">
<div class="form-group" [ngClass]="getDisabledExportUsersClass()">
<label i18n id="exportUsersMaxUserVideoQuota" for="exportUsersMaxUserVideoQuota">Max user video quota allowed to generate the export</label>
@ -774,7 +818,9 @@
inputId="exportUsersMaxUserVideoQuota"
[items]="exportMaxUserVideoQuotaOptions"
formControlName="maxUserVideoQuota"
i18n-inputSuffix inputSuffix="bytes" inputType="number"
i18n-inputSuffix
inputSuffix="bytes"
inputType="number"
[clearable]="false"
></my-select-custom-value>
@ -784,20 +830,21 @@
<div class="form-group" [ngClass]="getDisabledExportUsersClass()">
<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 *ngIf="formErrors.export.users.exportExpiration" class="form-error" role="alert">{{ formErrors.export.users.exportExpiration }}</div>
</div>
</ng-container>
</my-peertube-checkbox>
</div>
</ng-container>
</ng-container>
</div>
</div>
</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 { AlertComponent } from '@app/shared/shared-main/common/alert.component'
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 { pairwise } from 'rxjs/operators'
import { SelectOptionsItem } from 'src/types/select-options-item.model'

View file

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

View file

@ -8,6 +8,8 @@ import { manageRoutes } from '../shared-manage/routes'
import { VideoStudioService } from '../shared-manage/studio/video-studio.service'
import { VideoManageComponent } from './video-manage.component'
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 [
{
@ -16,12 +18,14 @@ export default [
canActivate: [ LoginGuard ],
canDeactivate: [ CanDeactivateGuard ],
providers: [
VideoManageController,
VideoManageResolver,
LiveVideoService,
I18nPrimengCalendarService,
VideoUploadService,
VideoStudioService,
VideoStateMessageService
VideoStateMessageService,
PlayerSettingsService
],
resolve: {
resolverData: VideoManageResolver

View file

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

View file

@ -3,7 +3,6 @@ import { Component, HostListener, inject, OnDestroy, OnInit } from '@angular/cor
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
import { ActivatedRoute } from '@angular/router'
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 { VideoManageController } from '../shared-manage/video-manage-controller.service'
import { VideoManageResolverData } from './video-manage.resolver'
@ -16,8 +15,7 @@ import { VideoManageResolverData } from './video-manage.resolver'
FormsModule,
ReactiveFormsModule,
VideoManageContainerComponent
],
providers: [ VideoManageController ]
]
})
export class VideoManageComponent implements OnInit, OnDestroy, CanComponentDeactivate {
private route = inject(ActivatedRoute)
@ -29,18 +27,9 @@ export class VideoManageComponent implements OnInit, OnDestroy, CanComponentDeac
isUpdatingVideo = false
loaded = false
async ngOnInit () {
ngOnInit () {
const data = this.route.snapshot.data.resolverData as VideoManageResolverData
const { video, userChannels, captions, chapters, videoSource, live, videoPasswords, userQuota, privacies } = data
const videoEdit = await VideoEdit.createFromAPI(this.serverService.getHTMLConfig(), {
video,
captions,
chapters,
live,
videoSource,
videoPasswords: videoPasswords.map(p => p.password)
})
const { userChannels, userQuota, privacies, videoEdit } = data
this.manageController.setStore({
videoEdit,
@ -50,8 +39,6 @@ export class VideoManageComponent implements OnInit, OnDestroy, CanComponentDeac
})
this.manageController.setConfig({ manageType: 'update', serverConfig: this.serverService.getHTMLConfig() })
this.loaded = true
}
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 {
LiveVideo,
PlayerVideoSettings,
UserVideoQuota,
VideoCaption,
VideoChapter,
@ -22,6 +23,8 @@ import {
import { forkJoin, of } from 'rxjs'
import { map, switchMap } from 'rxjs/operators'
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 = {
video: VideoDetails
@ -33,6 +36,8 @@ export type VideoManageResolverData = {
videoPasswords: VideoPassword[]
userQuota: UserVideoQuota
privacies: VideoConstant<VideoPrivacyType>[]
videoEdit: VideoEdit
playerSettings: PlayerVideoSettings
}
@Injectable()
@ -45,6 +50,7 @@ export class VideoManageResolver {
private videoPasswordService = inject(VideoPasswordService)
private userService = inject(UserService)
private serverService = inject(ServerService)
private playerSettingsService = inject(PlayerSettingsService)
resolve (route: ActivatedRouteSnapshot) {
const uuid: string = route.params['uuid']
@ -52,18 +58,32 @@ export class VideoManageResolver {
return this.videoService.getVideo({ videoId: uuid })
.pipe(
switchMap(video => forkJoin(this.buildObservables(video))),
map(([ video, videoSource, userChannels, captions, chapters, live, videoPasswords, userQuota, privacies ]) =>
({
video,
userChannels,
captions,
chapters,
videoSource,
live,
videoPasswords,
userQuota,
privacies
}) as VideoManageResolverData
switchMap(
async ([ video, videoSource, userChannels, captions, chapters, live, videoPasswords, userQuota, privacies, playerSettings ]) => {
const videoEdit = await VideoEdit.createFromAPI(this.serverService.getHTMLConfig(), {
video,
captions,
chapters,
live,
videoSource,
playerSettings,
videoPasswords: videoPasswords.map(p => p.password)
})
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
? this.videoPasswordService.getVideoPasswords({ videoUUID: video.uuid })
: of([]),
: of([] as VideoPassword[]),
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 { CanDeactivateGuard, LoginGuard } from '@app/core'
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 { VideoUploadService } from '../shared-manage/common/video-upload.service'
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 { VideoPublishComponent } from './video-publish.component'
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')
@ -43,6 +44,7 @@ export default [
providers: [
VideoPublishResolver,
VideoManageController,
PlayerSettingsService,
VideoStateMessageService,
LiveVideoService,
I18nPrimengCalendarService,

View file

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

View file

@ -6,6 +6,8 @@ import {
LiveVideoCreate,
LiveVideoUpdate,
NSFWFlag,
PlayerVideoSettings,
PlayerVideoSettingsUpdate,
VideoCaption,
VideoChapter,
VideoCreate,
@ -65,6 +67,8 @@ type StudioForm = {
'add-watermark'?: { file?: File }
}
type PlayerSettingsForm = PlayerVideoSettingsUpdate
// ---------------------------------------------------------------------------
type LoadFromPublishOptions = Required<Pick<VideoCreate, 'channelId' | 'support'>> & Partial<Pick<VideoCreate, 'name'>>
@ -115,6 +119,7 @@ type UpdateFromAPIOptions = {
captions?: VideoCaption[]
videoPasswords?: string[]
videoSource?: VideoSource
playerSettings?: PlayerVideoSettings
}
// ---------------------------------------------------------------------------
@ -143,6 +148,7 @@ export class VideoEdit {
private live: LiveUpdate
private replaceFile: File
private studioTasks: VideoStudioTask[] = []
private playerSettings: PlayerVideoSettings
private videoImport: Pick<VideoImportCreate, 'magnetUri' | 'torrentfile' | 'targetUrl'>
@ -185,6 +191,7 @@ export class VideoEdit {
previewfile?: { size: number }
live?: LiveUpdate
playerSettings?: PlayerVideoSettings
pluginData?: any
pluginDefaults?: Record<string, string | boolean>
@ -294,12 +301,13 @@ export class VideoEdit {
}
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)
this.loadVideo({ video, videoPasswords, saveInStore: true, loadPrivacy })
this.loadLive(live)
this.loadPlayerSettings(playerSettings)
if (captions !== undefined) {
this.captions = captions
@ -449,6 +457,17 @@ export class VideoEdit {
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: {
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 () {
return this.metadata.videoSource
}
@ -825,6 +864,10 @@ export class VideoEdit {
return this.studioTasks
}
getPlayerSettings () {
return this.playerSettings
}
getStudioTasksSummary () {
return this.getStudioTasks().map(t => {
if (t.name === 'add-intro') {
@ -941,6 +984,21 @@ export class VideoEdit {
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 () {
@ -950,7 +1008,8 @@ export class VideoEdit {
this.hasStudioTasks() ||
this.hasChaptersChanges() ||
this.hasCommonChanges() ||
this.hasPluginDataChanges()
this.hasPluginDataChanges() ||
this.hasPlayerSettingsChanges()
}
// ---------------------------------------------------------------------------

View file

@ -31,8 +31,15 @@
</div>
<p-datepicker
inputId="originallyPublishedAt" formControlName="originallyPublishedAt" [dateFormat]="calendarDateFormat" [firstDayOfWeek]="0"
[showTime]="true" [hideOnDateTimeSelect]="true" [monthNavigator]="true" [yearNavigator]="true" [yearRange]="myYearRange"
inputId="originallyPublishedAt"
formControlName="originallyPublishedAt"
[dateFormat]="calendarDateFormat"
[firstDayOfWeek]="0"
[showTime]="true"
[hideOnDateTimeSelect]="true"
[monthNavigator]="true"
[yearNavigator]="true"
[yearRange]="myYearRange"
baseZIndex="20000"
>
</p-datepicker>
@ -42,10 +49,15 @@
</div>
</div>
<my-peertube-checkbox
inputName="downloadEnabled" formControlName="downloadEnabled"
i18n-labelText labelText="Enable download"
></my-peertube-checkbox>
<my-peertube-checkbox inputName="downloadEnabled" formControlName="downloadEnabled" i18n-labelText labelText="Enable download"></my-peertube-checkbox>
<div class="form-group" formGroupName="playerSettings">
<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>

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 { FormControl, FormGroup, FormsModule, ReactiveFormsModule } from '@angular/forms'
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 { FormReactiveErrors, FormReactiveService, FormReactiveMessages } from '@app/shared/shared-forms/form-reactive.service'
import { HTMLServerConfig } from '@peertube/peertube-models'
import { FormReactiveErrors, FormReactiveMessages, FormReactiveService } from '@app/shared/shared-forms/form-reactive.service'
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 { DatePickerModule } from 'primeng/datepicker'
import { Subscription } from 'rxjs'
@ -19,6 +20,10 @@ const debugLogger = debug('peertube:video-manage')
type Form = {
downloadEnabled: FormControl<boolean>
originallyPublishedAt: FormControl<Date>
playerSettings: FormGroup<{
theme: FormControl<PlayerVideoSettings['theme']>
}>
}
@Component({
@ -28,12 +33,13 @@ type Form = {
],
templateUrl: './video-customization.component.html',
imports: [
CommonModule,
FormsModule,
ReactiveFormsModule,
NgIf,
DatePickerModule,
PeertubeCheckboxComponent,
GlobalIconComponent
GlobalIconComponent,
SelectPlayerThemeComponent
]
})
export class VideoCustomizationComponent implements OnInit, OnDestroy {
@ -47,6 +53,7 @@ export class VideoCustomizationComponent implements OnInit, OnDestroy {
validationMessages: FormReactiveMessages = {}
videoEdit: VideoEdit
videoChannel: Pick<VideoChannel, 'name' | 'displayName'>
calendarDateFormat: string
myYearRange: string
@ -63,17 +70,24 @@ export class VideoCustomizationComponent implements OnInit, OnDestroy {
ngOnInit () {
this.serverConfig = this.serverService.getHTMLConfig()
const { videoEdit } = this.manageController.getStore()
const { videoEdit, userChannels } = this.manageController.getStore()
this.videoEdit = videoEdit
const channelItem = userChannels.find(c => c.id === videoEdit.toCommonFormPatch().channelId)
this.videoChannel = { name: channelItem.name, displayName: channelItem.label }
this.buildForm()
}
private buildForm () {
const defaultValues = this.videoEdit.toCommonFormPatch()
const obj: BuildFormArgument = {
const defaultValues = { ...this.videoEdit.toCommonFormPatch(), playerSettings: this.videoEdit.toPlayerSettingsFormPatch() }
const obj: BuildFormArgumentTyped<Form> = {
downloadEnabled: null,
originallyPublishedAt: VIDEO_ORIGINALLY_PUBLISHED_AT_VALIDATOR
originallyPublishedAt: VIDEO_ORIGINALLY_PUBLISHED_AT_VALIDATOR,
playerSettings: {
theme: null
}
}
const {
@ -93,12 +107,18 @@ export class VideoCustomizationComponent implements OnInit, OnDestroy {
debugLogger('Updating form values', formValues)
this.videoEdit.loadFromCommonForm(formValues)
this.videoEdit.loadFromPlayerSettingsForm({
theme: formValues.playerSettings.theme
})
})
this.formReactiveService.markAllAsDirty(this.form.controls)
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 { VideoService } from '@app/shared/shared-main/video/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 {
HTMLServerConfig,
@ -47,6 +48,7 @@ export class VideoManageController implements OnDestroy {
private formReactiveService = inject(FormReactiveService)
private videoStudio = inject(VideoStudioService)
private peertubeRouter = inject(PeerTubeRouterService)
private playerSettingsService = inject(PlayerSettingsService)
private videoEdit: VideoEdit
private userChannels: SelectChannelItem[]
@ -245,6 +247,16 @@ export class VideoManageController implements OnDestroy {
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(() => {
if (!isLive || !this.videoEdit.hasLiveChanges()) return of(true)
@ -283,16 +295,19 @@ export class VideoManageController implements OnDestroy {
!isLive
? 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({
video,
videoPasswords: videoPasswords.map(p => p.password),
live,
chapters: chaptersRes?.chapters,
captions: captionsRes?.data
captions: captionsRes?.data,
playerSettings
})
}),
first(), // To complete

View file

@ -1,6 +1,6 @@
<div class="actor" *ngIf="actor">
<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 (hasAvatar()) {

View file

@ -41,7 +41,7 @@ export class ActorAvatarEditComponent implements OnInit, OnChanges {
maxAvatarSize = 0
avatarExtensions = ''
preview: string
previewUrl: string
actor: ActorAvatarInput
@ -55,6 +55,8 @@ export class ActorAvatarEditComponent implements OnInit, OnChanges {
}
ngOnChanges () {
this.previewUrl = undefined
this.actor = {
avatars: this.avatars(),
name: this.username()
@ -73,16 +75,23 @@ export class ActorAvatarEditComponent implements OnInit, OnChanges {
this.avatarChange.emit(formData)
if (this.previewImage()) {
imageToDataURL(avatarfile).then(result => this.preview = result)
imageToDataURL(avatarfile).then(result => this.previewUrl = result)
}
}
deleteAvatar () {
this.preview = undefined
if (this.previewImage()) {
this.previewUrl = null
this.actor.avatars = []
}
this.avatarDelete.emit()
}
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-img-edit-container">
<div class="banner-placeholder">
<img *ngIf="hasBanner()" [src]="preview || bannerUrl()" alt="Banner" />
<img *ngIf="hasBanner()" [src]="getBannerUrl()" alt="Banner" />
</div>
<div *ngIf="!hasBanner()" class="actor-img-edit-button button-file primary-button button-focus-within" [ngbTooltip]="bannerFormat" placement="right" container="body">
<ng-container *ngTemplateOutlet="uploadNewBanner"></ng-container>
</div>
<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>
@if (!hasBanner()) {
<div class="actor-img-edit-button button-file primary-button button-focus-within" [ngbTooltip]="bannerFormat" placement="right" container="body">
<ng-container *ngTemplateOutlet="uploadNewBanner"></ng-container>
</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>

View file

@ -1,8 +1,8 @@
import { NgIf, NgTemplateOutlet } from '@angular/common'
import { Component, ElementRef, OnInit, inject, input, output, viewChild } from '@angular/core'
import { CommonModule, NgTemplateOutlet } from '@angular/common'
import { Component, ElementRef, OnInit, booleanAttribute, inject, input, output, viewChild } from '@angular/core'
import { SafeResourceUrl } from '@angular/platform-browser'
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 { imageToDataURL } from '@root-helpers/images'
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-banner-edit.component.scss'
],
imports: [ NgIf, NgbTooltip, NgTemplateOutlet, NgbDropdown, NgbDropdownToggle, GlobalIconComponent, NgbDropdownMenu ]
imports: [ CommonModule, NgbTooltipModule, NgTemplateOutlet, NgbDropdownModule, GlobalIconComponent ]
})
export class ActorBannerEditComponent implements OnInit {
private serverService = inject(ServerService)
@ -23,8 +23,8 @@ export class ActorBannerEditComponent implements OnInit {
readonly bannerfileInput = viewChild<ElementRef<HTMLInputElement>>('bannerfileInput')
readonly bannerPopover = viewChild<NgbPopover>('bannerPopover')
readonly bannerUrl = input<string>(undefined)
readonly previewImage = input(false)
readonly bannerUrl = input<string>()
readonly previewImage = input(false, { transform: booleanAttribute })
readonly bannerChange = output<FormData>()
readonly bannerDelete = output()
@ -63,11 +63,23 @@ export class ActorBannerEditComponent implements OnInit {
}
deleteBanner () {
this.preview = undefined
if (this.previewImage()) {
this.preview = null
}
this.bannerDelete.emit()
}
hasBanner () {
// User deleted the avatar
if (this.preview === null) return false
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 { PeerTubeRouterService } from '@app/core'
import { fromEvent, Observable, Subscription } from 'rxjs'
import { distinctUntilChanged, filter, map, share, startWith, throttleTime } from 'rxjs/operators'
@ -8,7 +7,6 @@ import { distinctUntilChanged, filter, map, share, startWith, throttleTime } fro
standalone: true
})
export class InfiniteScrollerDirective implements OnInit, OnDestroy, AfterViewChecked {
private peertubeRouter = inject(PeerTubeRouterService)
private el = inject(ElementRef)
readonly percentLimit = input(70)
@ -18,7 +16,7 @@ export class InfiniteScrollerDirective implements OnInit, OnDestroy, AfterViewCh
readonly nearOfBottom = output()
private decimalLimit = 0
private lastCurrentBottom = -1
private lastCurrentBottom: number
private scrollDownSub: Subscription
private container: HTMLElement
@ -98,6 +96,11 @@ export class InfiniteScrollerDirective implements OnInit, OnDestroy, AfterViewCh
}
private isScrollingDown (current: number) {
if (this.lastCurrentBottom === undefined) {
this.lastCurrentBottom = current
return false
}
const result = 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, OnInit, inject } from '@angular/core'
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
import { AfterViewInit, Component, inject } from '@angular/core'
import { Router } from '@angular/router'
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 { VideoChannelService } from '@app/shared/shared-main/channel/video-channel.service'
import { AlertComponent } from '@app/shared/shared-main/common/alert.component'
import { HttpStatusCode, VideoChannelCreate } from '@peertube/peertube-models'
import { HttpStatusCode, PlayerChannelSettings, VideoChannelCreate } from '@peertube/peertube-models'
import { of } from 'rxjs'
import { switchMap } from 'rxjs/operators'
import { ActorAvatarEditComponent } from '../shared-actor-image-edit/actor-avatar-edit.component'
import { ActorBannerEditComponent } from '../shared-actor-image-edit/actor-banner-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'
import { PlayerSettingsService } from '../shared-video/player-settings.service'
import { FormValidatedOutput, VideoChannelEditComponent } from './video-channel-edit.component'
@Component({
templateUrl: './video-channel-edit.component.html',
styleUrls: [ './video-channel-edit.component.scss' ],
template: `
<my-video-channel-edit
mode="create" [channel]="channel" [rawPlayerSettings]="rawPlayerSettings" [error]="error"
(formValidated)="onFormValidated($event)"
>
</my-video-channel-edit>
`,
imports: [
NgIf,
FormsModule,
ReactiveFormsModule,
ActorBannerEditComponent,
ActorAvatarEditComponent,
NgClass,
HelpComponent,
MarkdownTextareaComponent,
PeertubeCheckboxComponent,
AlertComponent,
MarkdownHintComponent
VideoChannelEditComponent
],
providers: [
PlayerSettingsService
]
})
export class VideoChannelCreateComponent extends VideoChannelEdit implements OnInit, AfterViewInit {
protected formReactiveService = inject(FormReactiveService)
export class VideoChannelCreateComponent implements AfterViewInit {
private authService = inject(AuthService)
private notifier = inject(Notifier)
private router = inject(Router)
private videoChannelService = inject(VideoChannelService)
private hooks = inject(HooksService)
private playerSettingsService = inject(PlayerSettingsService)
error: string
videoChannel = new VideoChannel({})
private avatar: FormData
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
})
channel = new VideoChannel({})
rawPlayerSettings: PlayerChannelSettings = {
theme: 'instance-default'
}
ngAfterViewInit () {
this.hooks.runAction('action:video-channel-create.init', 'video-channel')
}
formValidated () {
onFormValidated (output: FormValidatedOutput) {
this.error = undefined
const body = this.form.value
const videoChannelCreate: VideoChannelCreate = {
name: body.name,
displayName: body['display-name'],
description: body.description || null,
support: body.support || null
const channelCreate: VideoChannelCreate = {
name: output.channel.name,
displayName: output.channel.displayName,
description: output.channel.description,
support: output.channel.support
}
this.videoChannelService.createVideoChannel(videoChannelCreate)
this.videoChannelService.createVideoChannel(channelCreate)
.pipe(
switchMap(() => this.uploadAvatar()),
switchMap(() => this.uploadBanner())
switchMap(() => {
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({
next: () => {
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' ])
},
error: err => {
let message = err.message
if (err.status === HttpStatusCode.CONFLICT_409) {
this.error = $localize`This name already exists on this platform.`
return
message = $localize`Channel name "${channelCreate.name}" already exists on this platform.`
}
this.error = err.message
this.notifier.error(message)
}
})
}
onAvatarChange (formData: FormData) {
this.avatar = formData
private uploadAvatar (username: string, avatar?: FormData) {
if (!avatar) return of(undefined)
return this.videoChannelService.changeVideoChannelImage(username, avatar, 'avatar')
}
onAvatarDelete () {
this.avatar = null
}
private uploadBanner (username: string, banner?: FormData) {
if (!banner) return of(undefined)
onBannerChange (formData: FormData) {
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')
return this.videoChannelService.changeVideoChannelImage(username, 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">
<form (ngSubmit)="formValidated()" [formGroup]="form">
<form (ngSubmit)="onFormValidated()" [formGroup]="form">
<div class="pt-two-cols"> <!-- channel grid -->
<div class="title-col">
@if (isCreation()) {
@if (mode() === 'create') {
<h2 i18n>NEW CHANNEL</h2>
} @else {
<h2 i18n>UPDATE CHANNEL</h2>
@ -14,40 +14,40 @@
<div class="content-col">
<my-actor-banner-edit
*ngIf="videoChannel" [previewImage]="isCreation()" class="d-block mb-4"
[bannerUrl]="videoChannel?.bannerUrl" (bannerChange)="onBannerChange($event)" (bannerDelete)="onBannerDelete()"
*ngIf="channel()" previewImage="true" class="d-block mb-4"
[bannerUrl]="channel()?.bannerUrl" (bannerChange)="onBannerChange($event)" (bannerDelete)="onBannerDelete()"
></my-actor-banner-edit>
<my-actor-avatar-edit
*ngIf="videoChannel" class="d-block mb-4" actorType="channel"
[displayName]="videoChannel.displayName" [previewImage]="isCreation()" [avatars]="videoChannel.avatars"
[username]="!isCreation() && videoChannel.name" [subscribers]="!isCreation() && videoChannel.followersCount"
*ngIf="channel()" class="d-block mb-4" actorType="channel"
[displayName]="channel().displayName" previewImage="true" [avatars]="channel().avatars"
[username]="mode() === 'update' && channel().name" [subscribers]="mode() === 'update' && channel().followersCount"
(avatarChange)="onAvatarChange($event)" (avatarDelete)="onAvatarDelete()"
></my-actor-avatar-edit>
<div class="form-group" *ngIf="isCreation()">
<div class="form-group" *ngIf="mode() === 'create'">
<label i18n for="name">Name</label>
<div class="input-group">
<input
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>
<div *ngIf="formErrors['name']" class="form-error" role="alert">
{{ formErrors['name'] }}
<div *ngIf="formErrors.name" class="form-error" role="alert">
{{ formErrors.name }}
</div>
</div>
<div class="form-group">
<label i18n for="display-name">Display name</label>
<label i18n for="displayName">Display name</label>
<input
type="text" id="display-name" class="form-control"
formControlName="display-name" [ngClass]="{ 'input-error': formErrors['display-name'] }"
type="text" id="displayName" class="form-control"
formControlName="displayName" [ngClass]="{ 'input-error': formErrors.displayName }"
>
<div *ngIf="formErrors['display-name']" class="form-error" role="alert">
{{ formErrors['display-name'] }}
<div *ngIf="formErrors.displayName" class="form-error" role="alert">
{{ formErrors.displayName }}
</div>
</div>
@ -58,7 +58,7 @@
<my-markdown-textarea
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>
<div *ngIf="formErrors.description" class="form-error" role="alert">
@ -75,7 +75,7 @@
<my-markdown-textarea
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>
</div>
@ -86,6 +86,13 @@
></my-peertube-checkbox>
</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">
</div>
</div>

View file

@ -1,16 +1,16 @@
@use '_variables' as *;
@use '_mixins' as *;
@use '_form-mixins' as *;
@use "_variables" as *;
@use "_mixins" as *;
@use "_form-mixins" as *;
my-actor-banner-edit {
max-width: 500px;
}
input[type=text] {
input[type="text"] {
@include peertube-input-text(340px);
}
input[type=submit] {
input[type="submit"] {
@include margin-left(auto);
}
@ -18,6 +18,8 @@ input[type=submit] {
max-width: 500px;
}
.peertube-select-container {
@include peertube-select-container(340px);
my-select-player-theme {
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 { HttpErrorResponse } from '@angular/common/http'
import { AfterViewInit, Component, OnDestroy, OnInit, inject } from '@angular/core'
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
import { AfterViewInit, Component, inject, OnDestroy, OnInit } from '@angular/core'
import { ActivatedRoute } from '@angular/router'
import { AuthService, HooksService, Notifier, RedirectService } from '@app/core'
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 { AlertComponent } from '@app/shared/shared-main/common/alert.component'
import { shallowCopy } from '@peertube/peertube-core-utils'
import { VideoChannelUpdate } from '@peertube/peertube-models'
import { Subscription } from 'rxjs'
import { ActorAvatarEditComponent } from '../shared-actor-image-edit/actor-avatar-edit.component'
import { ActorBannerEditComponent } from '../shared-actor-image-edit/actor-banner-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'
import { PlayerChannelSettings, VideoChannelUpdate } from '@peertube/peertube-models'
import { catchError, forkJoin, Subscription, switchMap, tap, throwError } from 'rxjs'
import { VideoChannel } from '../shared-main/channel/video-channel.model'
import { PlayerSettingsService } from '../shared-video/player-settings.service'
import { FormValidatedOutput, VideoChannelEditComponent } from './video-channel-edit.component'
@Component({
selector: 'my-video-channel-update',
templateUrl: './video-channel-edit.component.html',
styleUrls: [ './video-channel-edit.component.scss' ],
template: `
@if (channel && rawPlayerSettings) {
<my-video-channel-edit
mode="update" [channel]="channel" [rawPlayerSettings]="rawPlayerSettings" [error]="error"
(formValidated)="onFormValidated($event)"
>
</my-video-channel-edit>
}
`,
imports: [
NgIf,
FormsModule,
ReactiveFormsModule,
ActorBannerEditComponent,
ActorAvatarEditComponent,
NgClass,
HelpComponent,
MarkdownTextareaComponent,
PeertubeCheckboxComponent,
AlertComponent,
MarkdownHintComponent
VideoChannelEditComponent
],
providers: [
PlayerSettingsService
]
})
export class VideoChannelUpdateComponent extends VideoChannelEdit implements OnInit, AfterViewInit, OnDestroy {
protected formReactiveService = inject(FormReactiveService)
export class VideoChannelUpdateComponent implements OnInit, AfterViewInit, OnDestroy {
private authService = inject(AuthService)
private notifier = inject(Notifier)
private route = inject(ActivatedRoute)
private videoChannelService = inject(VideoChannelService)
private playerSettingsService = inject(PlayerSettingsService)
private redirectService = inject(RedirectService)
private hooks = inject(HooksService)
channel: VideoChannel
rawPlayerSettings: PlayerChannelSettings
error: string
private paramsSub: Subscription
private oldSupportField: string
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 => {
const videoChannelName = routeParams['videoChannelName']
this.videoChannelService.getVideoChannel(videoChannelName)
.subscribe({
next: videoChannelToUpdate => {
this.videoChannel = videoChannelToUpdate
forkJoin([
this.videoChannelService.getVideoChannel(videoChannelName),
this.playerSettingsService.getChannelSettings({ channelHandle: videoChannelName, raw: true })
]).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
this.form.patchValue({
'display-name': videoChannelToUpdate.displayName,
'description': videoChannelToUpdate.description,
'support': videoChannelToUpdate.support
})
},
error: err => {
this.error = err.message
}
})
error: err => this.notifier.error(err.message)
})
})
}
@ -98,112 +71,84 @@ export class VideoChannelUpdateComponent extends VideoChannelEdit implements OnI
if (this.paramsSub) this.paramsSub.unsubscribe()
}
formValidated () {
onFormValidated (output: FormValidatedOutput) {
this.error = undefined
const body = this.form.value
const videoChannelUpdate: VideoChannelUpdate = {
displayName: body['display-name'],
description: body.description || null,
support: body.support || null,
bulkVideosSupportUpdate: body.bulkVideosSupportUpdate || false
displayName: output.channel.displayName,
description: output.channel.description,
support: output.channel.support,
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({
next: () => {
// So my-actor-avatar component detects changes
this.channel = shallowCopy(this.channel)
this.authService.refreshUserInformation()
this.notifier.success($localize`Video channel ${videoChannelUpdate.displayName} updated.`)
this.redirectService.redirectToPreviousRoute('/c/' + this.videoChannel.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)
this.redirectService.redirectToPreviousRoute('/c/' + this.channel.name)
},
error: err => this.notifier.error(err.message)
})
}
onBannerChange (formData: FormData) {
this.videoChannelService.changeVideoChannelImage(this.videoChannel.name, formData, 'banner')
.subscribe({
next: data => {
this.notifier.success($localize`Banner changed.`)
private updateOrDeleteAvatar (avatar: FormData) {
if (!avatar) {
return this.videoChannelService.deleteVideoChannelImage(this.channel.name, 'avatar')
.pipe(tap(() => this.channel.resetAvatar()))
}
this.videoChannel.updateBanner(data.banners)
},
error: (err: HttpErrorResponse) =>
genericUploadErrorHandler({
err,
name: $localize`banner`,
notifier: this.notifier
return this.videoChannelService.changeVideoChannelImage(this.channel.name, avatar, 'avatar')
.pipe(
tap(data => this.channel.updateAvatar(data.avatars)),
catchError(err =>
throwError(() => {
return new Error(genericUploadErrorHandler({
err,
name: $localize`avatar`,
notifier: this.notifier
}))
})
})
)
)
}
onBannerDelete () {
this.videoChannelService.deleteVideoChannelImage(this.videoChannel.name, 'banner')
.subscribe({
next: () => {
this.notifier.success($localize`Banner deleted.`)
private updateOrDeleteBanner (banner: FormData) {
if (!banner) {
return this.videoChannelService.deleteVideoChannelImage(this.channel.name, 'banner')
.pipe(tap(() => this.channel.resetBanner()))
}
this.videoChannel.resetBanner()
},
error: err => this.notifier.error(err.message)
})
}
isCreation () {
return false
}
getFormButtonTitle () {
return $localize`Update ${this.videoChannel?.name}`
}
isBulkUpdateVideosDisplayed () {
if (this.oldSupportField === undefined) return false
return this.oldSupportField !== this.form.value['support']
return this.videoChannelService.changeVideoChannelImage(this.channel.name, banner, 'banner')
.pipe(
tap(data => this.channel.updateBanner(data.banners)),
catchError(err =>
throwError(() => {
return new Error(genericUploadErrorHandler({
err,
name: $localize`banner`,
notifier: this.notifier
}))
})
)
)
}
}

View file

@ -103,7 +103,7 @@ export class PeerTubePlayer {
await this.buildPlayerIfNeeded()
for (const theme of [ 'default', 'lucide' ]) {
for (const theme of [ 'galaxy', 'lucide' ]) {
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
.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 {
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));
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 {
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 {
--big-play-button-size: 78px;
--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 {
--big-play-button-size: 46px;
--big-play-button-icon-size: 20px;

View file

@ -1,13 +1,12 @@
import debug from 'debug'
import videojs from 'video.js'
import MenuButton from 'video.js/dist/types/menu/menu-button'
import { VideojsComponent, VideojsMenu, VideojsMenuItem, VideojsMenuItemOptions, VideojsPlayer } from '../../types'
import { toTitleCase } from '../common'
import { SettingsDialog } from './settings-dialog'
import { SettingsButton } from './settings-menu-button'
import { SettingsPanel } from './settings-panel'
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')

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 { PeerTubeDockPluginOptions } from '../shared/dock/peertube-dock-plugin'
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 = {
playerElement: () => HTMLVideoElement
@ -53,7 +50,7 @@ export type PeerTubePlayerConstructorOptions = {
export type PeerTubePlayerLoadOptions = {
mode: PlayerMode
theme: PeerTubePlayerTheme
theme: PlayerTheme
startTime?: 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 { CoreConfig } from 'p2p-media-loader-core'
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 { PlaylistPlugin } from '../shared/playlist/playlist-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 { StatsForNerdsPlugin } from '../shared/stats/stats-plugin'
import { UpNextPlugin } from '../shared/upnext/upnext-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' {
export interface VideoJsPlayer {

View file

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

View file

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

View file

@ -5,9 +5,7 @@ import { AuthHTTP } from './auth-http'
import { getBackendUrl } from './url'
export class VideoFetcher {
constructor (private readonly http: AuthHTTP) {
}
async loadVideo ({ videoId, videoPassword }: { videoId: string, videoPassword?: string }) {
@ -39,8 +37,9 @@ export class VideoFetcher {
const captionsPromise = this.loadVideoCaptions({ videoId, videoPassword })
const chaptersPromise = this.loadVideoChapters({ videoId, videoPassword })
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) {
@ -70,10 +69,18 @@ export class VideoFetcher {
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) {
return getBackendUrl() + '/api/v1/videos/' + id
}
private getPlayerSettingsUrl (id: string) {
return getBackendUrl() + '/api/v1/player-settings/videos/' + id
}
private getLiveUrl (videoId: string) {
return getBackendUrl() + '/api/v1/videos/live/' + videoId
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,17 +1,19 @@
import { AbuseObject } from './abuse-object.js'
import { CacheFileObject } from './cache-file-object.js'
import { PlayerSettingsObject } from './player-settings-object.js'
import { PlaylistObject } from './playlist-object.js'
import { VideoCommentObject } from './video-comment-object.js'
import { VideoObject } from './video-object.js'
import { WatchActionObject } from './watch-action-object.js'
export type ActivityObject =
VideoObject |
AbuseObject |
VideoCommentObject |
CacheFileObject |
PlaylistObject |
WatchActionObject |
string
| VideoObject
| AbuseObject
| VideoCommentObject
| CacheFileObject
| PlaylistObject
| WatchActionObject
| PlayerSettingsObject
| 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-comment-object.js'
export * from './video-object.js'
export * from './player-settings-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
comments: string
hasParts: string | VideoChapterObject[]
playerSettings: string
attributedTo: ActivityPubAttributedTo[]

View file

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

View file

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

View file

@ -11,6 +11,7 @@ export * from './metrics/index.js'
export * from './moderation/index.js'
export * from './nodeinfo/index.js'
export * from './overviews/index.js'
export * from './player/index.js'
export * from './plugins/index.js'
export * from './redundancy/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 { NSFWPolicyType } from '../videos/nsfw-policy.type.js'
import { BroadcastMessageLevel } from './broadcast-message-level.type.js'
@ -370,6 +371,7 @@ export interface CustomConfig {
}
player: {
theme: PlayerTheme
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 { NSFWPolicyType } from '../videos/nsfw-policy.type.js'
import { VideoPrivacyType } from '../videos/video-privacy.enum.js'
@ -103,6 +103,7 @@ export interface ServerConfig {
}
player: {
theme: PlayerTheme
autoPlay: boolean
}
}

View file

@ -16,6 +16,8 @@ export * from './chapter/index.js'
export * from './nsfw-flag.enum.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 './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 { ChildProcess, fork } from 'child_process'
import { copy } from 'fs-extra/esm'
import merge from 'lodash-es/merge.js'
import { join } from 'path'
import { BulkCommand } from '../bulk/index.js'
import { CLICommand } from '../cli/index.js'
@ -36,6 +37,7 @@ import {
CommentsCommand,
HistoryCommand,
LiveCommand,
PlayerSettingsCommand,
PlaylistsCommand,
ServicesCommand,
StoryboardCommand,
@ -58,7 +60,6 @@ import { PluginsCommand } from './plugins-command.js'
import { RedundancyCommand } from './redundancy-command.js'
import { ServersCommand } from './servers-command.js'
import { StatsCommand } from './stats-command.js'
import merge from 'lodash-es/merge.js'
export type RunServerOptions = {
autoEnableImportProxy?: boolean
@ -154,6 +155,7 @@ export class PeerTubeServer {
videoToken?: VideoTokenCommand
registrations?: RegistrationsCommand
videoPasswords?: VideoPasswordsCommand
playerSettings?: PlayerSettingsCommand
storyboard?: StoryboardCommand
chapters?: ChaptersCommand
@ -460,6 +462,8 @@ export class PeerTubeServer {
this.videoToken = new VideoTokenCommand(this)
this.registrations = new RegistrationsCommand(this)
this.playerSettings = new PlayerSettingsCommand(this)
this.storyboard = new StoryboardCommand(this)
this.chapters = new ChaptersCommand(this)

View file

@ -6,6 +6,7 @@ export * from './channels-command.js'
export * from './chapters-command.js'
export * from './channel-syncs-command.js'
export * from './comments-command.js'
export * from './player-settings-command.js'
export * from './history-command.js'
export * from './video-imports-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 './metrics.js'
import './my-user.js'
import './player-settings.js'
import './plugins.js'
import './redundancy.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: {
videos: {
http:{
http: {
enabled: true
}
}
@ -132,7 +132,6 @@ describe('Test video passwords validator', function () {
}
function validateVideoPasswordList (mode: 'uploadLegacy' | 'uploadResumable' | 'import' | 'updateVideo' | 'updatePasswords' | 'live') {
it('Should fail with a password protected privacy without providing a password', async function () {
await checkVideoPasswordOptions({
server,
@ -268,7 +267,17 @@ describe('Test video passwords validator', function () {
token?: string
videoPassword?: string
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
@ -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') {
return server.videoToken.create({
videoId: video.id,
@ -380,9 +398,12 @@ describe('Test video passwords validator', function () {
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)
let tokens: string[]
if (!requiresUserAuth) {
it('Should fail without providing a password for an unlogged user', async function () {
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('For getting a password protected video', function () {
validateVideoAccess('get')
})
@ -507,6 +527,10 @@ describe('Test video passwords validator', function () {
validateVideoAccess('listCaptions')
})
describe('For getting player settings', function () {
validateVideoAccess('getPlayerSettings')
})
describe('For creating video file token', function () {
validateVideoAccess('token')
})

View file

@ -26,7 +26,6 @@ describe('Test config defaults', function () {
})
describe('Default publish values', function () {
before(async function () {
const overrideConfig = {
defaults: {
@ -123,9 +122,7 @@ describe('Test config defaults', function () {
})
describe('Default P2P values', function () {
describe('Webapp default value', function () {
before(async function () {
const overrideConfig = {
defaults: {
@ -167,7 +164,6 @@ describe('Test config defaults', function () {
})
describe('Embed default value', function () {
before(async function () {
const overrideConfig = {
defaults: {
@ -213,11 +209,11 @@ describe('Test config defaults', function () {
})
describe('Default player value', function () {
before(async function () {
const overrideConfig = {
defaults: {
player: {
theme: 'lucide',
auto_play: false
}
},
@ -230,9 +226,10 @@ describe('Test config defaults', function () {
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()
expect(config.defaults.player.theme).to.equal('lucide')
expect(config.defaults.player.autoPlay).to.be.false
})
@ -255,7 +252,6 @@ describe('Test config defaults', function () {
})
describe('Default user attributes', function () {
it('Should create a user and register a user with the default config', async function () {
await server.config.updateExistingConfig({
newConfig: {
@ -265,7 +261,7 @@ describe('Test config defaults', function () {
enabled: true
}
},
videoQuota : -1,
videoQuota: -1,
videoQuotaDaily: -1
},
signup: {
@ -305,7 +301,7 @@ describe('Test config defaults', function () {
enabled: false
}
},
videoQuota : 5242881,
videoQuota: 5242881,
videoQuotaDaily: 318742
},
signup: {
@ -330,7 +326,6 @@ describe('Test config defaults', function () {
expect(user.videoQuotaDaily).to.equal(318742)
}
})
})
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.p2p.embed.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.email.body.signature).to.equal('')
@ -473,7 +474,8 @@ function buildNewCustomConfig (server: PeerTubeServer): CustomConfig {
}
},
player: {
autoPlay: false
autoPlay: false,
theme: 'lucide'
}
},
email: {

View file

@ -455,6 +455,7 @@ function runTest (withObjectStorage: boolean) {
expect(secondaryChannel.displayName).to.equal('noah display name')
expect(secondaryChannel.description).to.equal('noah description')
expect(secondaryChannel.support).to.equal('noah support')
expect(secondaryChannel.playerSettings.theme).to.equal('galaxy')
expect(secondaryChannel.avatars).to.have.lengthOf(4)
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.resolution).to.equal(720)
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.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' })
expect(importedSecond.displayName).to.equal('noah display name')
expect(importedSecond.description).to.equal('noah description')
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) {
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.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
await server.videos.get({ id: publicVideo.uuid })
}
@ -385,6 +406,13 @@ function runTest (withObjectStorage: boolean) {
expect(passwordVideo).to.exist
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 })
expect(passwords.map(p => p.password).sort()).to.deep.equal([ 'password1', 'password2' ])

View file

@ -1,6 +1,7 @@
import './channel-import-videos.js'
import './generate-download.js'
import './multiple-servers.js'
import './player-settings.js'
import './resumable-upload.js'
import './single-server.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',
type: 'avatar'
})
await server.playerSettings.updateForChannel({ channelHandle: 'noah_second_channel', theme: 'galaxy' })
// Videos
const externalVideo = await remoteServer.videos.quickUpload({ name: 'external video', privacy: VideoPrivacy.PUBLIC })
// eslint-disable-next-line max-len
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 })
await server.playerSettings.updateForVideo({ videoId: noahVideo.uuid, theme: 'lucide' })
// eslint-disable-next-line max-len
const noahVideo2 = await server.videos.upload({
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 { InternalEventEmitter } from '@server/lib/internal-event-emitter.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 { MAccountId, MActorId, MChannelId, MVideoId } from '@server/types/models/index.js'
import cors from 'cors'
@ -177,6 +178,13 @@ activityPubClientRouter.get(
asyncMiddleware(videosCustomGetValidator('only-video-and-blacklist')),
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(videoChannelPlaylistsController)
)
activityPubClientRouter.get(
'/video-channels/:handle/player-settings',
executeIfActivityPub,
activityPubRateLimiter,
asyncMiddleware(videoChannelsHandleValidatorFactory({ checkIsLocal: true, checkManage: false })),
asyncMiddleware(channelPlayerSettingsController)
)
activityPubClientRouter.get(
'/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)
}
// ---------------------------------------------------------------------------
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) {
const videoChannel = res.locals.videoChannel

View file

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

View file

@ -14,6 +14,7 @@ import { jobsRouter } from './jobs.js'
import { metricsRouter } from './metrics.js'
import { oauthClientsRouter } from './oauth-clients.js'
import { overviewsRouter } from './overviews.js'
import { playerSettingsRouter } from './player-settings.js'
import { pluginRouter } from './plugins.js'
import { runnersRouter } from './runners/index.js'
import { searchRouter } from './search/index.js'
@ -48,6 +49,7 @@ apiRouter.use('/jobs', jobsRouter)
apiRouter.use('/metrics', metricsRouter)
apiRouter.use('/search', searchRouter)
apiRouter.use('/overviews', overviewsRouter)
apiRouter.use('/player-settings', playerSettingsRouter)
apiRouter.use('/plugins', pluginRouter)
apiRouter.use('/custom-pages', customPageRouter)
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 { ACTIVITY_PUB, REMOTE_SCHEME } from '@server/initializers/constants.js'
import { isArray } from './custom-validators/misc.js'
import { logger } from './logger.js'
import { buildDigest } from './peertube-crypto.js'
import type { signJsonLDObject } from './peertube-jsonld.js'
import { doJSONRequest } from './requests.js'
import { logger } from './logger.js'
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 })[] } = {
Video: buildContext({
...getPlayerSettingsTypeContext(),
Hashtag: 'as:Hashtag',
category: 'sc:category',
licence: 'sc:license',
@ -99,6 +101,7 @@ const contextStore: { [id in ContextType]: (string | { [id: string]: string })[]
},
Infohash: 'pt:Infohash',
SensitiveTag: 'pt:SensitiveTag',
tileWidth: {
@ -131,6 +134,8 @@ const contextStore: { [id in ContextType]: (string | { [id: string]: string })[]
hasParts: 'sc:hasParts',
playerSettings: 'pt:playerSettings',
views: {
'@type': 'sc:Number',
'@id': 'pt:views'
@ -236,6 +241,8 @@ const contextStore: { [id in ContextType]: (string | { [id: string]: string })[]
}),
Actor: buildContext({
...getPlayerSettingsTypeContext(),
playlists: {
'@id': 'pt:playlists',
'@type': '@id'
@ -303,9 +310,24 @@ const contextStore: { [id in ContextType]: (string | { [id: string]: string })[]
hasPart: 'sc:hasPart',
endOffset: 'sc:endOffset',
startOffset: 'sc:startOffset'
}),
PlayerSettings: buildContext({
...getPlayerSettingsTypeContext(),
theme: 'pt:theme'
})
}
function getPlayerSettingsTypeContext () {
return {
PlayerSettings: {
'@type': '@id',
'@id': 'pt:PlayerSettings'
}
}
}
let allContext: (string | ContextValue)[]
export function getAllContext () {
if (allContext) return allContext

View file

@ -1,10 +1,11 @@
import validator from 'validator'
import { Activity, ActivityType } from '@peertube/peertube-models'
import validator from 'validator'
import { isAbuseReasonValid } from '../abuses.js'
import { exists } from '../misc.js'
import { sanitizeAndCheckActorObject } from './actor.js'
import { isCacheFileObjectValid } from './cache-file.js'
import { isActivityPubUrlValid, isBaseActivityValid, isObjectValid } from './misc.js'
import { sanitizeAndCheckPlayerSettingsObject } from './player-settings.js'
import { isPlaylistObjectValid } from './playlist.js'
import { sanitizeAndCheckVideoCommentObject } from './video-comments.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,
Update: isUpdateActivityValid,
Delete: isDeleteActivityValid,
@ -88,7 +89,6 @@ export function isCreateActivityValid (activity: any) {
isFlagActivityValid(activity.object) ||
isPlaylistObjectValid(activity.object) ||
isWatchActionObjectValid(activity.object) ||
isCacheFileObjectValid(activity.object) ||
sanitizeAndCheckVideoCommentObject(activity.object) ||
sanitizeAndCheckVideoTorrentObject(activity.object)
@ -101,7 +101,9 @@ export function isUpdateActivityValid (activity: any) {
isCacheFileObjectValid(activity.object) ||
isPlaylistObjectValid(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.licence',
'defaults.player.auto_play',
'defaults.player.theme',
'instance.name',
'instance.short_description',
'instance.default_language',

View file

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

View file

@ -8,6 +8,8 @@ import {
FollowState,
JobType,
NSFWPolicyType,
PlayerThemeChannelSetting,
PlayerThemeVideoSetting,
RunnerJobState,
RunnerJobStateType,
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 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 { VideoViewModel } from '../models/view/video-view.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
@ -189,7 +190,8 @@ export async function initDatabaseModels (silent: boolean) {
WatchedWordsListModel,
AccountAutomaticTagPolicyModel,
UploadImageModel,
VideoLiveScheduleModel
VideoLiveScheduleModel,
PlayerSettingModel
])
// 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 { isAccountActor, isChannelActor } from '@server/helpers/actors.js'
import { sequelizeTypescript } from '@server/initializers/database.js'
import { AccountModel } from '@server/models/account/account.js'
import { ActorModel } from '@server/models/actor/actor.js'
@ -15,18 +15,17 @@ import {
MChannel,
MServer
} from '@server/types/models/index.js'
import { Op, Transaction } from 'sequelize'
import { upsertAPPlayerSettings } from '../../player-settings.js'
import { updateActorImages } from '../image.js'
import { getActorAttributesFromObject, getActorDisplayNameFromObject, getImagesInfoFromObject } from './object-to-model-attributes.js'
import { fetchActorFollowsCount } from './url-to-object.js'
import { isAccountActor, isChannelActor } from '@server/helpers/actors.js'
export class APActorCreator {
constructor (
private readonly actorObject: ActivityPubActor,
private readonly ownerActor?: MActorFullActor
) {
}
async create (): Promise<MActorFullActor> {
@ -34,7 +33,7 @@ export class APActorCreator {
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 { actorCreated, created } = await this.saveActor(actorInstance, t)
@ -58,6 +57,17 @@ export class APActorCreator {
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) {

View file

@ -4,6 +4,7 @@ import { logger } from '@server/helpers/logger.js'
import { AccountModel } from '@server/models/account/account.js'
import { VideoChannelModel } from '@server/models/video/video-channel.js'
import { MAccount, MActor, MActorFull, MChannel } from '@server/types/models/index.js'
import { upsertAPPlayerSettings } from '../player-settings.js'
import { getOrCreateAPOwner } from './get.js'
import { updateActorImages } from './image.js'
import { fetchActorFollowsCount } from './shared/index.js'
@ -36,6 +37,15 @@ export class APActorUpdater {
this.accountOrChannel.Account = owner.Account as AccountModel
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 => {

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,
ActivityUpdateObject,
CacheFileObject,
PlayerSettingsObject,
PlaylistObject,
VideoObject
} from '@peertube/peertube-models'
@ -17,13 +18,15 @@ import { logger } from '../../../helpers/logger.js'
import { sequelizeTypescript } from '../../../initializers/database.js'
import { ActorModel } from '../../../models/actor/actor.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 { getOrCreateAPActor } from '../actors/get.js'
import { APActorUpdater } from '../actors/updater.js'
import { createOrUpdateCacheFile } from '../cache-file.js'
import { upsertAPPlayerSettings } from '../player-settings.js'
import { createOrUpdateVideoPlaylist } from '../playlists/index.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>>) {
const { activity, byActor } = options
@ -51,6 +54,10 @@ async function processUpdateActivity (options: APProcessorOptions<ActivityUpdate
return retryTransactionWrapper(processUpdatePlaylist, byActor, activity, object)
}
if (objectType === 'PlayerSettings') {
return retryTransactionWrapper(processUpdatePlayerSettings, byActor, object)
}
return undefined
}
@ -130,3 +137,34 @@ async function processUpdatePlaylist (
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 { 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 { logger } from '../../../helpers/logger.js'
import { AccountModel } from '../../../models/account/account.js'
@ -11,11 +13,12 @@ import {
MActorLight,
MChannelDefault,
MVideoAPLight,
MVideoFullLight,
MVideoPlaylistFull,
MVideoRedundancyVideo
} from '../../../types/models/index.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 { getActorsInvolvedInVideo } from './shared/index.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 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({
data: updateActivity,
byActor,
toFollowersOf: actorsInvolved,
toFollowersOf: await getToFollowersOfForActor(accountOrChannel, transaction),
transaction,
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
// ---------------------------------------------------------------------------
@ -149,3 +188,18 @@ function buildUpdateActivity (
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,
MActorId,
MActorUrl,
MCommentId, MLocalVideoViewer,
MCommentId,
MLocalVideoViewer,
MVideoId,
MVideoPlaylistElement,
MVideoUUID,
@ -40,6 +41,10 @@ export function getLocalVideoChannelActivityPubUrl (videoChannelName: string) {
return WEBSERVER.URL + '/video-channels/' + videoChannelName
}
export function getLocalChannelPlayerSettingsActivityPubUrl (videoChannelName: string) {
return WEBSERVER.URL + '/video-channels/' + videoChannelName + '/player-settings'
}
export function getLocalAccountActivityPubUrl (accountName: string) {
return WEBSERVER.URL + '/accounts/' + accountName
}
@ -76,6 +81,10 @@ export function getLocalVideoChaptersActivityPubUrl (video: MVideoUrl) {
return video.url + '/chapters'
}
export function getLocalVideoPlayerSettingsActivityPubUrl (video: MVideoUrl) {
return video.url + '/player-settings'
}
export function getLocalVideoLikesActivityPubUrl (video: MVideoUrl) {
return video.url + '/likes'
}

View file

@ -32,6 +32,7 @@ import {
import { CreationAttributes, Transaction } from 'sequelize'
import { fetchAP } from '../../activity.js'
import { findOwner, getOrCreateAPActor } from '../../actors/index.js'
import { upsertAPPlayerSettings } from '../../player-settings.js'
import {
getCaptionAttributesFromObject,
getFileAttributesFromUrl,
@ -160,7 +161,7 @@ export abstract class APVideoAbstractBuilder {
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
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) {
const streamingPlaylistAttributes = getStreamingPlaylistAttributesFromObject(video, this.videoObject)
const newStreamingPlaylists = streamingPlaylistAttributes.map(a => new VideoStreamingPlaylistModel(a))

View file

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

View file

@ -79,7 +79,8 @@ export class APVideoUpdater extends APVideoAbstractBuilder {
await runInReadCommittedTransaction(t => this.setCaptions(videoUpdated, t))
await this.updateChaptersOutsideTransaction(videoUpdated)
await this.updateChapters(videoUpdated)
await this.upsertPlayerSettings(videoUpdated)
await autoBlacklistVideoIfNeeded({
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 { CONFIG } from '../../initializers/config.js'
import { ServerConfigManager } from '../server-config-manager.js'
@ -13,7 +13,7 @@ export function getThemeOrDefault (name: string, defaultTheme: 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) ||
ServerConfigManager.Instance.getBuiltInThemes().some(r => r.name === name)

View file

@ -116,6 +116,7 @@ class ServerConfigManager {
}
},
player: {
theme: CONFIG.DEFAULTS.PLAYER.THEME,
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 { PlayerSettingModel } from '@server/models/video/player-setting.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 { MPlayerSetting } from '@server/types/models/video/player-setting.js'
import { ExportResult } from './abstract-user-exporter.js'
import { ActorExporter } from './actor-exporter.js'
export class ChannelsExporter extends ActorExporter <ChannelExportJSON> {
export class ChannelsExporter extends ActorExporter<ChannelExportJSON> {
async export () {
const channelsJSON: ChannelExportJSON['channels'] = []
let staticFiles: ExportResult<ChannelExportJSON>['staticFiles'] = []
@ -31,12 +32,15 @@ export class ChannelsExporter extends ActorExporter <ChannelExportJSON> {
}
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)
return {
json: this.exportChannelJSON(channel, relativePathsFromJSON),
json: this.exportChannelJSON(channel, playerSettings, relativePathsFromJSON),
staticFiles
}
}
@ -45,6 +49,7 @@ export class ChannelsExporter extends ActorExporter <ChannelExportJSON> {
private exportChannelJSON (
channel: MChannelBannerAccountDefault,
playerSettings: MPlayerSetting,
archiveFiles: { avatar: string, banner: string }
): ChannelExportJSON['channels'][0] {
return {
@ -54,6 +59,8 @@ export class ChannelsExporter extends ActorExporter <ChannelExportJSON> {
description: channel.description,
support: channel.support,
playerSettings: this.exportPlayerSettingsJSON(playerSettings),
updatedAt: channel.updatedAt.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'
import { VideoDownload } from '@server/lib/video-download.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 { VideoChannelModel } from '@server/models/video/video-channel.js'
import { VideoChapterModel } from '@server/models/video/video-chapter.js'
@ -33,6 +34,7 @@ import {
MVideoLiveWithSettingSchedules,
MVideoPassword
} 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 Bluebird from 'bluebird'
import { createReadStream } from 'fs'
@ -80,11 +82,12 @@ export class VideosExporter extends AbstractUserExporter<VideoExportJSON> {
}
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),
VideoCaptionModel.listVideoCaptions(videoId),
VideoSourceModel.loadLatest(videoId),
VideoChapterModel.listChaptersOfVideo(videoId)
VideoChapterModel.listChaptersOfVideo(videoId),
PlayerSettingModel.loadByVideoId(videoId)
])
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 })
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,
relativePathsFromJSON,
activityPubOutbox: await this.exportVideoAP(videoAP, chapters, exportedVideoFileOrSource)
@ -116,10 +128,11 @@ export class VideosExporter extends AbstractUserExporter<VideoExportJSON> {
live: MVideoLiveWithSettingSchedules
passwords: MVideoPassword[]
source: MVideoSource
playerSettings: MPlayerSetting
chapters: MVideoChapter[]
archiveFiles: VideoExportJSON['videos'][0]['archiveFiles']
}): VideoExportJSON['videos'][0] {
const { video, captions, live, passwords, source, chapters, archiveFiles } = options
const { video, captions, live, passwords, source, chapters, playerSettings, archiveFiles } = options
return {
uuid: video.uuid,
@ -182,6 +195,8 @@ export class VideosExporter extends AbstractUserExporter<VideoExportJSON> {
source: this.exportVideoSourceJSON(source),
playerSettings: this.exportPlayerSettingsJSON(playerSettings),
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 (

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