diff --git a/client/src/app/+video-watch/shared/premiere/index.ts b/client/src/app/+video-watch/shared/premiere/index.ts new file mode 100644 index 00000000000..6800a0a5430 --- /dev/null +++ b/client/src/app/+video-watch/shared/premiere/index.ts @@ -0,0 +1 @@ +export * from './video-premiere.component' diff --git a/client/src/app/+video-watch/shared/premiere/video-premiere.component.html b/client/src/app/+video-watch/shared/premiere/video-premiere.component.html new file mode 100644 index 00000000000..15df273ae0f --- /dev/null +++ b/client/src/app/+video-watch/shared/premiere/video-premiere.component.html @@ -0,0 +1,40 @@ +
+
+
+

This video will be available soon!

+
+ +
+ @if (!countdown.isExpired) { +
+
+ {{ countdown.days }} + {{ countdown.days === 1 ? 'day' : 'days' }} +
+
+ {{ countdown.hours.toString().padStart(2, '0') }} + hours +
+
+ {{ countdown.minutes.toString().padStart(2, '0') }} + minutes +
+
+ {{ countdown.seconds.toString().padStart(2, '0') }} + seconds +
+
+ } @else { +

Premiere has started!

+ } +
+ +
+ + +
+
+
\ No newline at end of file diff --git a/client/src/app/+video-watch/shared/premiere/video-premiere.component.scss b/client/src/app/+video-watch/shared/premiere/video-premiere.component.scss new file mode 100644 index 00000000000..06e96d56b5b --- /dev/null +++ b/client/src/app/+video-watch/shared/premiere/video-premiere.component.scss @@ -0,0 +1,140 @@ +@use '_variables' as *; +@use '_mixins' as *; +@use '_bootstrap-variables'; + +.premiere-widget { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + z-index: 2; + padding: 20px; + text-align: center; + background-color: rgba(0, 0, 0, 0.8); + display: flex; + align-items: center; + justify-content: center; + border-radius: 3px; + pointer-events: auto; + + .premiere-info { + max-width: 500px; + + .premiere-title { + margin-bottom: 20px; + + h2 { + font-size: 1.8em; + font-weight: 600; + color: #fff; + margin: 0; + } + } + + .premiere-countdown { + margin-bottom: 30px; + + .countdown-timer { + display: flex; + justify-content: center; + gap: 15px; + margin-bottom: 20px; + flex-wrap: wrap; + + .countdown-unit { + display: flex; + flex-direction: column; + align-items: center; + background-color: rgba(255, 255, 255, 0.1); + border-radius: 8px; + padding: 12px 16px; + min-width: 70px; + backdrop-filter: blur(10px); + border: 1px solid rgba(255, 255, 255, 0.2); + + .countdown-number { + font-size: 2.2em; + font-weight: 700; + color: #fff; + line-height: 1; + margin-bottom: 5px; + font-family: 'Roboto Mono', monospace; + text-shadow: 0 2px 4px rgba(0, 0, 0, 0.3); + } + + .countdown-label { + font-size: 0.85em; + color: rgba(255, 255, 255, 0.9); + font-weight: 500; + text-transform: uppercase; + letter-spacing: 0.5px; + } + } + } + + .countdown-expired { + font-size: 1.4em; + color: #ff6b6b; + font-weight: 600; + margin-bottom: 15px; + text-shadow: 0 2px 4px rgba(0, 0, 0, 0.3); + } + } + + .premiere-actions { + .premiere-subscribe-button { + ::ng-deep .btn { + padding: 10px 20px; + font-size: 1em; + background-color: rgba(255, 255, 255, 0.1); + border-color: rgba(255, 255, 255, 0.3); + color: #fff; + + &:hover { + background-color: rgba(255, 255, 255, 0.2); + border-color: rgba(255, 255, 255, 0.5); + } + + my-global-icon { + margin-right: 5px; + } + } + } + } + } +} + +// Mobile responsive styles +@media screen and (max-width: 600px) { + .premiere-widget { + padding: 15px; + + .premiere-info { + max-width: 100%; + + .premiere-title h2 { + font-size: 1.4em; + } + + .premiere-countdown { + .countdown-timer { + gap: 8px; + + .countdown-unit { + padding: 8px 12px; + min-width: 55px; + + .countdown-number { + font-size: 1.8em; + } + + .countdown-label { + font-size: 0.75em; + } + } + } + } + } + } +} \ No newline at end of file diff --git a/client/src/app/+video-watch/shared/premiere/video-premiere.component.ts b/client/src/app/+video-watch/shared/premiere/video-premiere.component.ts new file mode 100644 index 00000000000..6faecf9d42a --- /dev/null +++ b/client/src/app/+video-watch/shared/premiere/video-premiere.component.ts @@ -0,0 +1,79 @@ +import { Component, Input, OnInit, OnDestroy } from '@angular/core' +import { NgIf } from '@angular/common' +import { VideoDetails } from '@app/shared/shared-main/video/video-details.model' +import { SubscribeButtonComponent } from '@app/shared/shared-user-subscription/subscribe-button.component' + +interface CountdownTime { + days: number + hours: number + minutes: number + seconds: number + isExpired: boolean +} + +@Component({ + selector: 'my-video-premiere', + templateUrl: './video-premiere.component.html', + styleUrls: [ './video-premiere.component.scss' ], + standalone: true, + imports: [ + NgIf, + SubscribeButtonComponent + ] +}) +export class VideoPremiereComponent implements OnInit, OnDestroy { + @Input() video: VideoDetails + @Input() theaterEnabled = false + + countdown: CountdownTime = { + days: 0, + hours: 0, + minutes: 0, + seconds: 0, + isExpired: false + } + + private countdownInterval: any + + ngOnInit() { + this.startCountdown() + } + + ngOnDestroy() { + if (this.countdownInterval) { + clearInterval(this.countdownInterval) + } + } + + private startCountdown() { + this.updateCountdown() + this.countdownInterval = setInterval(() => { + this.updateCountdown() + }, 1000) + } + + private updateCountdown() { + if (!this.video?.scheduledUpdate?.updateAt) { + this.countdown.isExpired = true + return + } + + const premiereTime = new Date(this.video.scheduledUpdate.updateAt).getTime() + const now = new Date().getTime() + const timeDiff = premiereTime - now + + if (timeDiff <= 0) { + this.countdown.isExpired = true + if (this.countdownInterval) { + clearInterval(this.countdownInterval) + } + return + } + + this.countdown.days = Math.floor(timeDiff / (1000 * 60 * 60 * 24)) + this.countdown.hours = Math.floor((timeDiff % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60)) + this.countdown.minutes = Math.floor((timeDiff % (1000 * 60 * 60)) / (1000 * 60)) + this.countdown.seconds = Math.floor((timeDiff % (1000 * 60)) / 1000) + this.countdown.isExpired = false + } +} diff --git a/client/src/app/+video-watch/video-watch.component.html b/client/src/app/+video-watch/video-watch.component.html index 0fb189f3a5c..a5e69f55ef4 100644 --- a/client/src/app/+video-watch/video-watch.component.html +++ b/client/src/app/+video-watch/video-watch.component.html @@ -10,9 +10,23 @@ Please try refreshing the page, or try again later. -
- -
+ @if (video && video.privacy.id === VideoPrivacy.PREMIERE) { +
+ + + +
+ } @else { +
+ +
+ }
& { } export class VideoEdit { - static readonly SPECIAL_SCHEDULED_PRIVACY = -1 - private isNewVideo = false private common: CommonUpdate = {} private captions: VideoCaptionWithPathEdit[] = [] @@ -503,12 +501,11 @@ export class VideoEdit { } if (values.privacy !== undefined) { - // If schedule publication, the video is private and will be changed to public privacy - if (values.privacy === VideoEdit.SPECIAL_SCHEDULED_PRIVACY) { + if (values.privacy === VideoPrivacy.PREMIERE && values.schedulePublicationAt) { const updateAt = new Date(values.schedulePublicationAt) updateAt.setSeconds(0) - this.common.privacy = VideoPrivacy.PRIVATE + this.common.privacy = VideoPrivacy.PREMIERE this.common.scheduleUpdate = { updateAt: values.schedulePublicationAt @@ -572,7 +569,7 @@ export class VideoEdit { // Special case if we scheduled an update if (this.common.scheduleUpdate) { Object.assign(json, { - privacy: VideoEdit.SPECIAL_SCHEDULED_PRIVACY, + privacy: this.common.scheduleUpdate.privacy, schedulePublicationAt: new Date(this.common.scheduleUpdate.updateAt.toString()) }) } diff --git a/client/src/app/+videos-publish-manage/shared-manage/main-info/video-main-info.component.ts b/client/src/app/+videos-publish-manage/shared-manage/main-info/video-main-info.component.ts index 40498cf1154..e2b3634e745 100644 --- a/client/src/app/+videos-publish-manage/shared-manage/main-info/video-main-info.component.ts +++ b/client/src/app/+videos-publish-manage/shared-manage/main-info/video-main-info.component.ts @@ -253,16 +253,6 @@ export class VideoMainInfoComponent implements OnInit, OnDestroy { private buildPrivacies () { const { privacies } = this.manageController.getStore() this.videoPrivacies = this.videoService.explainedPrivacyLabels(privacies).videoPrivacies - - // Can't schedule publication if private privacy is not available (could be deleted by a plugin) - const hasPrivatePrivacy = this.videoPrivacies.some(p => p.id === VideoPrivacy.PRIVATE) - if (this.forbidScheduledPublication || !hasPrivatePrivacy) return - - this.videoPrivacies.push({ - id: VideoEdit.SPECIAL_SCHEDULED_PRIVACY, - label: $localize`Scheduled`, - description: $localize`Hide the video until a specific date` - }) } private buildForm () { @@ -400,7 +390,7 @@ export class VideoMainInfoComponent implements OnInit, OnDestroy { } private updateScheduleRelatedControls (newPrivacyId: number, isInitialPatch = false) { - this.schedulePublicationSelected = newPrivacyId === VideoEdit.SPECIAL_SCHEDULED_PRIVACY + this.schedulePublicationSelected = newPrivacyId === VideoPrivacy.PREMIERE const scheduleControl = this.form.get('schedulePublicationAt') const waitTranscodingControl = this.form.get('waitTranscoding') diff --git a/client/src/app/shared/shared-main/video/video.service.ts b/client/src/app/shared/shared-main/video/video.service.ts index 4c6a62182c3..84e3c2901f2 100644 --- a/client/src/app/shared/shared-main/video/video.service.ts +++ b/client/src/app/shared/shared-main/video/video.service.ts @@ -532,7 +532,8 @@ export class VideoService { [VideoPrivacy.UNLISTED]: $localize`Only shareable via a private link`, [VideoPrivacy.PUBLIC]: $localize`Anyone can see this video`, [VideoPrivacy.INTERNAL]: $localize`Only users of this platform can see this video`, - [VideoPrivacy.PASSWORD_PROTECTED]: $localize`Only users with the appropriate password can see this video` + [VideoPrivacy.PASSWORD_PROTECTED]: $localize`Only users with the appropriate password can see this video`, + [VideoPrivacy.PREMIERE]: $localize`Video will be available soon` } const videoPrivacies = serverPrivacies.map(p => { diff --git a/client/src/app/shared/shared-video/privacy-badge.component.ts b/client/src/app/shared/shared-video/privacy-badge.component.ts index e19a284d046..e3a67b19def 100644 --- a/client/src/app/shared/shared-video/privacy-badge.component.ts +++ b/client/src/app/shared/shared-video/privacy-badge.component.ts @@ -20,7 +20,8 @@ export class PrivacyBadgeComponent implements OnChanges { [VideoPrivacy.INTERNAL]: 'badge-yellow', [VideoPrivacy.PRIVATE]: 'badge-grey', [VideoPrivacy.PASSWORD_PROTECTED]: 'badge-purple', - [VideoPrivacy.UNLISTED]: 'badge-blue' + [VideoPrivacy.UNLISTED]: 'badge-blue', + [VideoPrivacy.PREMIERE]: 'badge-yellow' } private playlistBadges: { [id in VideoPlaylistPrivacyType]: string } = { @@ -49,6 +50,12 @@ export class PrivacyBadgeComponent implements OnChanges { private buildLabel () { if (this.video()) { + // For PREMIERE videos, always show "Premiere" even if scheduled + if (this.video().privacy.id === VideoPrivacy.PREMIERE) { + return this.video().privacy.label + } + + // For other privacy types, show "Scheduled" if there's a scheduled update if (this.video().scheduledUpdate) return $localize`Scheduled` return this.video().privacy.label @@ -64,6 +71,11 @@ export class PrivacyBadgeComponent implements OnChanges { private buildTooltip () { if (this.video()?.scheduledUpdate) { const updateAt = new Date(this.video().scheduledUpdate.updateAt.toString()).toLocaleString(this.localeId) + + if (this.video().privacy.id === VideoPrivacy.PREMIERE) { + return $localize`Premieres on ${updateAt}` + } + return $localize`Scheduled on ${updateAt}` } diff --git a/client/src/app/shared/shared-video/video-state-badge.component.ts b/client/src/app/shared/shared-video/video-state-badge.component.ts index de569dbed8b..b670fafe53a 100644 --- a/client/src/app/shared/shared-video/video-state-badge.component.ts +++ b/client/src/app/shared/shared-video/video-state-badge.component.ts @@ -8,7 +8,7 @@ import { Video, VideoPrivacy, VideoState, VideoStateType } from '@peertube/peert imports: [ CommonModule ] }) export class VideoStateBadgeComponent implements OnChanges { - readonly video = input.required>() + readonly video = input.required>() private states: { [id in VideoStateType]: string } = { [VideoState.PUBLISHED]: 'badge-green', @@ -56,6 +56,12 @@ export class VideoStateBadgeComponent implements OnChanges { return } + if (video.privacy.id === VideoPrivacy.PREMIERE && video.scheduledUpdate) { + this.label = $localize`Scheduled` + this.badgeClass = 'badge-yellow' + return + } + this.label = $localize`Published` return diff --git a/packages/core-utils/src/videos/common.ts b/packages/core-utils/src/videos/common.ts index 60c1c1eac7a..2bdf5500da6 100644 --- a/packages/core-utils/src/videos/common.ts +++ b/packages/core-utils/src/videos/common.ts @@ -1,7 +1,7 @@ import { VideoDetails, VideoPrivacy, VideoResolution, VideoStreamingPlaylistType } from '@peertube/peertube-models' export function getAllPrivacies () { - return [ VideoPrivacy.PUBLIC, VideoPrivacy.INTERNAL, VideoPrivacy.PRIVATE, VideoPrivacy.UNLISTED, VideoPrivacy.PASSWORD_PROTECTED ] + return [ VideoPrivacy.PUBLIC, VideoPrivacy.INTERNAL, VideoPrivacy.PRIVATE, VideoPrivacy.UNLISTED, VideoPrivacy.PASSWORD_PROTECTED, VideoPrivacy.PREMIERE ] } export function getAllFiles (video: Partial>) { diff --git a/packages/models/src/videos/video-privacy.enum.ts b/packages/models/src/videos/video-privacy.enum.ts index cbcc91b3fe9..d29970e76fb 100644 --- a/packages/models/src/videos/video-privacy.enum.ts +++ b/packages/models/src/videos/video-privacy.enum.ts @@ -3,7 +3,8 @@ export const VideoPrivacy = { UNLISTED: 2, PRIVATE: 3, INTERNAL: 4, - PASSWORD_PROTECTED: 5 + PASSWORD_PROTECTED: 5, + PREMIERE: 6 } as const export type VideoPrivacyType = typeof VideoPrivacy[keyof typeof VideoPrivacy] diff --git a/server/core/helpers/video.ts b/server/core/helpers/video.ts index ff7539bc28a..8c2d9255d8e 100644 --- a/server/core/helpers/video.ts +++ b/server/core/helpers/video.ts @@ -15,8 +15,8 @@ export function extractVideo (videoOrPlaylist: MVideo | MStreamingPlaylistVideo) export function getPrivaciesForFederation () { return (CONFIG.FEDERATION.VIDEOS.FEDERATE_UNLISTED === true) - ? [ { privacy: VideoPrivacy.PUBLIC }, { privacy: VideoPrivacy.UNLISTED } ] - : [ { privacy: VideoPrivacy.PUBLIC } ] + ? [ { privacy: VideoPrivacy.PUBLIC }, { privacy: VideoPrivacy.UNLISTED }, { privacy: VideoPrivacy.PREMIERE } ] + : [ { privacy: VideoPrivacy.PUBLIC }, { privacy: VideoPrivacy.PREMIERE } ] } export function getExtFromMimetype (mimeTypes: { [id: string]: string | string[] }, mimeType: string) { diff --git a/server/core/initializers/constants.ts b/server/core/initializers/constants.ts index ee3bbffc29d..80f4d69d746 100644 --- a/server/core/initializers/constants.ts +++ b/server/core/initializers/constants.ts @@ -595,7 +595,8 @@ export const VIDEO_PRIVACIES: { [id in VideoPrivacyType]: string } = { [VideoPrivacy.UNLISTED]: 'Unlisted', [VideoPrivacy.PRIVATE]: 'Private', [VideoPrivacy.INTERNAL]: 'Internal', - [VideoPrivacy.PASSWORD_PROTECTED]: 'Password protected' + [VideoPrivacy.PASSWORD_PROTECTED]: 'Password protected', + [VideoPrivacy.PREMIERE]: 'Premiere' } export const VIDEO_STATES: { [id in VideoStateType]: string } = { diff --git a/server/core/middlewares/validators/shared/videos.ts b/server/core/middlewares/validators/shared/videos.ts index e1ff1ad90da..e75306cff91 100644 --- a/server/core/middlewares/validators/shared/videos.ts +++ b/server/core/middlewares/validators/shared/videos.ts @@ -120,7 +120,7 @@ export async function checkCanSeeVideo (options: { return checkCanSeePasswordProtectedVideo({ req, res, video }) } - if (video.privacy === VideoPrivacy.UNLISTED || video.privacy === VideoPrivacy.PUBLIC) { + if (video.privacy === VideoPrivacy.UNLISTED || video.privacy === VideoPrivacy.PUBLIC || video.privacy === VideoPrivacy.PREMIERE) { return true } diff --git a/server/core/models/video/video.ts b/server/core/models/video/video.ts index 84142cbe2dc..140d7b89289 100644 --- a/server/core/models/video/video.ts +++ b/server/core/models/video/video.ts @@ -2162,7 +2162,7 @@ export class VideoModel extends SequelizeModel { return false } - if (this.privacy === VideoPrivacy.PUBLIC || this.privacy === VideoPrivacy.PASSWORD_PROTECTED) { + if (this.privacy === VideoPrivacy.PUBLIC || this.privacy === VideoPrivacy.PASSWORD_PROTECTED || this.privacy === VideoPrivacy.PREMIERE) { return false }