]> git.immae.eu Git - github/Chocobozzz/PeerTube.git/commitdiff
Add modal to display live information
authorChocobozzz <me@florianbigard.com>
Wed, 28 Oct 2020 09:49:20 +0000 (10:49 +0100)
committerChocobozzz <chocobozzz@cpy.re>
Mon, 9 Nov 2020 14:33:04 +0000 (15:33 +0100)
18 files changed:
client/src/app/+my-account/my-account-videos/modals/live-stream-information.component.html [new file with mode: 0644]
client/src/app/+my-account/my-account-videos/modals/live-stream-information.component.scss [moved from client/src/app/+my-account/my-account-videos/video-change-ownership/video-change-ownership.component.scss with 100% similarity]
client/src/app/+my-account/my-account-videos/modals/live-stream-information.component.ts [new file with mode: 0644]
client/src/app/+my-account/my-account-videos/modals/video-change-ownership.component.html [moved from client/src/app/+my-account/my-account-videos/video-change-ownership/video-change-ownership.component.html with 96% similarity]
client/src/app/+my-account/my-account-videos/modals/video-change-ownership.component.scss [new file with mode: 0644]
client/src/app/+my-account/my-account-videos/modals/video-change-ownership.component.ts [moved from client/src/app/+my-account/my-account-videos/video-change-ownership/video-change-ownership.component.ts with 100% similarity]
client/src/app/+my-account/my-account-videos/my-account-videos.component.html
client/src/app/+my-account/my-account-videos/my-account-videos.component.ts
client/src/app/+my-account/my-account.module.ts
client/src/app/+videos/+video-watch/video-watch.component.ts
client/src/app/shared/shared-icons/global-icon.component.ts
client/src/app/shared/shared-moderation/video-block.component.html
client/src/app/shared/shared-moderation/video-block.component.scss
client/src/app/shared/shared-video-miniature/video-actions-dropdown.component.ts
client/src/assets/images/feather/live.svg [new file with mode: 0644]
server/lib/job-queue/handlers/video-live-ending.ts
server/lib/job-queue/handlers/video-transcoding.ts
server/lib/video.ts

diff --git a/client/src/app/+my-account/my-account-videos/modals/live-stream-information.component.html b/client/src/app/+my-account/my-account-videos/modals/live-stream-information.component.html
new file mode 100644 (file)
index 0000000..5e2323b
--- /dev/null
@@ -0,0 +1,33 @@
+<ng-template #modal let-close="close" let-dismiss="dismiss">
+  <div class="modal-header">
+    <h4 i18n class="modal-title">Live information</h4>
+
+    <my-global-icon iconName="cross" aria-label="Close" role="button" (click)="dismiss()"></my-global-icon>
+  </div>
+
+  <div class="modal-body">
+    <div class="form-group">
+      <label for="liveVideoRTMPUrl" i18n>Live RTMP Url</label>
+      <my-input-readonly-copy id="liveVideoRTMPUrl" [value]="rtmpUrl"></my-input-readonly-copy>
+    </div>
+
+    <div class="form-group">
+      <label for="liveVideoStreamKey" i18n>Live stream key</label>
+      <my-input-readonly-copy id="liveVideoStreamKey" [value]="streamKey"></my-input-readonly-copy>
+    </div>
+  </div>
+
+  <div class="modal-footer">
+    <div class="form-group inputs">
+      <input
+        type="button" role="button" i18n-value value="Close" class="action-button action-button-cancel"
+        (click)="dismiss()"
+      >
+
+      <my-edit-button
+        i18n-label label="Update live settings"
+        [routerLink]="[ '/videos', 'update', video.uuid ]" (click)="dismiss()"
+      ></my-edit-button>
+    </div>
+  </div>
+</ng-template>
diff --git a/client/src/app/+my-account/my-account-videos/modals/live-stream-information.component.ts b/client/src/app/+my-account/my-account-videos/modals/live-stream-information.component.ts
new file mode 100644 (file)
index 0000000..a5885a8
--- /dev/null
@@ -0,0 +1,40 @@
+import { Component, ElementRef, ViewChild } from '@angular/core'
+import { LiveVideoService, Video } from '@app/shared/shared-main'
+import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
+
+@Component({
+  selector: 'my-live-stream-information',
+  templateUrl: './live-stream-information.component.html',
+  styleUrls: [ './live-stream-information.component.scss' ]
+})
+export class LiveStreamInformationComponent {
+  @ViewChild('modal', { static: true }) modal: ElementRef
+
+  video: Video
+  rtmpUrl = ''
+  streamKey = ''
+
+  constructor (
+    private modalService: NgbModal,
+    private liveVideoService: LiveVideoService
+  ) { }
+
+  show (video: Video) {
+    this.video = video
+    this.rtmpUrl = ''
+    this.streamKey = ''
+
+    this.loadLiveInfo(video)
+
+    this.modalService
+      .open(this.modal, { centered: true })
+  }
+
+  private loadLiveInfo (video: Video) {
+    this.liveVideoService.getVideoLive(video.id)
+      .subscribe(live => {
+        this.rtmpUrl = live.rtmpUrl
+        this.streamKey = live.streamKey
+      })
+  }
+}
similarity index 96%
rename from client/src/app/+my-account/my-account-videos/video-change-ownership/video-change-ownership.component.html
rename to client/src/app/+my-account/my-account-videos/modals/video-change-ownership.component.html
index 9d809d2bf0758d7660137bb36eb82ad3e527e4ba..c7c5a0b69c8b282a5e0dc2d604d29c4aef25d9b6 100644 (file)
@@ -16,7 +16,7 @@
     </div>
   </div>
 
-  <div class="modal-footer inputs">
+  <div class="modal-footer">
     <div class="form-group inputs">
       <input
         type="button" role="button" i18n-value value="Cancel" class="action-button action-button-cancel"
diff --git a/client/src/app/+my-account/my-account-videos/modals/video-change-ownership.component.scss b/client/src/app/+my-account/my-account-videos/modals/video-change-ownership.component.scss
new file mode 100644 (file)
index 0000000..a79fec1
--- /dev/null
@@ -0,0 +1,10 @@
+@import '_variables';
+@import '_mixins';
+
+p-autocomplete {
+  display: block;
+}
+
+.form-group {
+  margin: 20px 0;
+}
\ No newline at end of file
index f2ed0ac99c8ae2a7cd0ce30e45ad1b4790136eff..aa5b284e7ddbfb6011ab757833614747e95fef6b 100644 (file)
 
   <ng-template ptTemplate="rowButtons" let-video>
     <div class="action-button">
-      <my-delete-button label (click)="deleteVideo(video)"></my-delete-button>
-
       <my-edit-button label [routerLink]="[ '/videos', 'update', video.uuid ]"></my-edit-button>
 
-      <my-button i18n-label label="Change ownership"
-                 className="action-button-change-ownership grey-button"
-                 icon="ownership-change"
-                 (click)="changeOwnership($event, video)"
-      ></my-button>
+      <my-action-dropdown [actions]="videoActions" [entry]="{ video: video }"></my-action-dropdown>
     </div>
   </ng-template>
 </my-videos-selection>
 
 
 <my-video-change-ownership #videoChangeOwnershipModal></my-video-change-ownership>
+<my-live-stream-information #liveStreamInformationModal></my-live-stream-information>
index 46a02a41a4e0631a0590547daba7ed995859b24e..7a30192396411a115db1b93f927555529617db74 100644 (file)
@@ -5,10 +5,11 @@ import { ActivatedRoute, Router } from '@angular/router'
 import { AuthService, ComponentPagination, ConfirmService, Notifier, ScreenService, ServerService } from '@app/core'
 import { DisableForReuseHook } from '@app/core/routing/disable-for-reuse-hook'
 import { immutableAssign } from '@app/helpers'
-import { Video, VideoService } from '@app/shared/shared-main'
+import { DropdownAction, Video, VideoService } from '@app/shared/shared-main'
 import { MiniatureDisplayOptions, OwnerDisplayType, SelectionType, VideosSelectionComponent } from '@app/shared/shared-video-miniature'
 import { VideoSortField } from '@shared/models'
-import { VideoChangeOwnershipComponent } from './video-change-ownership/video-change-ownership.component'
+import { VideoChangeOwnershipComponent } from './modals/video-change-ownership.component'
+import { LiveStreamInformationComponent } from './modals/live-stream-information.component'
 
 @Component({
   selector: 'my-account-videos',
@@ -18,6 +19,7 @@ import { VideoChangeOwnershipComponent } from './video-change-ownership/video-ch
 export class MyAccountVideosComponent implements OnInit, DisableForReuseHook {
   @ViewChild('videosSelection', { static: true }) videosSelection: VideosSelectionComponent
   @ViewChild('videoChangeOwnershipModal', { static: true }) videoChangeOwnershipModal: VideoChangeOwnershipComponent
+  @ViewChild('liveStreamInformationModal', { static: true }) liveStreamInformationModal: LiveStreamInformationComponent
 
   titlePage: string
   selection: SelectionType = {}
@@ -37,6 +39,8 @@ export class MyAccountVideosComponent implements OnInit, DisableForReuseHook {
   }
   ownerDisplayType: OwnerDisplayType = 'videoChannel'
 
+  videoActions: DropdownAction<{ video: Video }>[] = []
+
   videos: Video[] = []
   videosSearch: string
   videosSearchChanged = new Subject<string>()
@@ -56,6 +60,8 @@ export class MyAccountVideosComponent implements OnInit, DisableForReuseHook {
   }
 
   ngOnInit () {
+    this.buildActions()
+
     this.videosSearchChanged
       .pipe(debounceTime(500))
       .subscribe(() => {
@@ -138,12 +144,36 @@ export class MyAccountVideosComponent implements OnInit, DisableForReuseHook {
         )
   }
 
-  changeOwnership (event: Event, video: Video) {
-    event.preventDefault()
+  changeOwnership (video: Video) {
     this.videoChangeOwnershipModal.show(video)
   }
 
+  displayLiveInformation (video: Video) {
+    this.liveStreamInformationModal.show(video)
+  }
+
   private removeVideoFromArray (id: number) {
     this.videos = this.videos.filter(v => v.id !== id)
   }
+
+  private buildActions () {
+    this.videoActions = [
+      {
+        label: $localize`Display live information`,
+        handler: ({ video }) => this.displayLiveInformation(video),
+        isDisplayed: ({ video }) => video.isLive,
+        iconName: 'live'
+      },
+      {
+        label: $localize`Change ownership`,
+        handler: ({ video }) => this.changeOwnership(video),
+        iconName: 'ownership-change'
+      },
+      {
+        label: $localize`Delete`,
+        handler: ({ video }) => this.deleteVideo(video),
+        iconName: 'delete'
+      }
+    ]
+  }
 }
index 5f7ed4d2fc63ad218c325fd432ad1cd5eba5c276..6b8baff5279c7dd5cac821fe344b487195e07e7e 100644 (file)
@@ -34,7 +34,8 @@ import { MyAccountVideoPlaylistElementsComponent } from './my-account-video-play
 import { MyAccountVideoPlaylistUpdateComponent } from './my-account-video-playlists/my-account-video-playlist-update.component'
 import { MyAccountVideoPlaylistsComponent } from './my-account-video-playlists/my-account-video-playlists.component'
 import { MyAccountVideosComponent } from './my-account-videos/my-account-videos.component'
-import { VideoChangeOwnershipComponent } from './my-account-videos/video-change-ownership/video-change-ownership.component'
+import { VideoChangeOwnershipComponent } from './my-account-videos/modals/video-change-ownership.component'
+import { LiveStreamInformationComponent } from './my-account-videos/modals/live-stream-information.component'
 import { MyAccountComponent } from './my-account.component'
 
 @NgModule({
@@ -68,6 +69,8 @@ import { MyAccountComponent } from './my-account.component'
     MyAccountVideosComponent,
 
     VideoChangeOwnershipComponent,
+    LiveStreamInformationComponent,
+
     MyAccountOwnershipComponent,
     MyAccountAcceptOwnershipComponent,
     MyAccountVideoImportsComponent,
index e4edb42fb893391aac6a73a671faa012ed345dab..9a34397312163581ba160d553f94f47ea8f0b817 100644 (file)
@@ -226,7 +226,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
   }
 
   isVideoDownloadable () {
-    return this.video && this.video instanceof VideoDetails && this.video.downloadEnabled
+    return this.video && this.video instanceof VideoDetails && this.video.downloadEnabled && !this.video.isLive
   }
 
   loadCompleteDescription () {
index 99efcd599b237dc1c9f9184da0f2fcf01a007f2e..ab71bc3e705eb308e670751d8d83cfb09408359e 100644 (file)
@@ -66,6 +66,7 @@ const icons = {
   'cross': require('!!raw-loader?!../../../assets/images/feather/x.svg').default,
   'tick': require('!!raw-loader?!../../../assets/images/feather/check.svg').default,
   'columns': require('!!raw-loader?!../../../assets/images/feather/columns.svg').default,
+  'live': require('!!raw-loader?!../../../assets/images/feather/live.svg').default,
   'repeat': require('!!raw-loader?!../../../assets/images/feather/repeat.svg').default,
   'message-circle': require('!!raw-loader?!../../../assets/images/feather/message-circle.svg').default
 }
index 5e73d66c5014dc15e907b768ca8cc7337f04f959..e982c4d7748114da19db213a20e199d07329d7c4 100644 (file)
@@ -1,6 +1,7 @@
 <ng-template #modal>
   <div class="modal-header">
-    <h4 i18n class="modal-title">Block video "{{ video.name }}"</h4>
+    <h4 i18n class="modal-title" *ngIf="!video.isLive">Block video "{{ video.name }}"</h4>
+    <h4 i18n class="modal-title" *ngIf="video.isLive">Block live "{{ video.name }}"</h4>
     <my-global-icon iconName="cross" aria-label="Close" role="button" (click)="hide()"></my-global-icon>
   </div>
 
         </my-peertube-checkbox>
       </div>
 
+      <strong class="live-info" *ngIf="video.isLive" i18n>
+        Blocking this live will automatically terminate the live stream.
+      </strong>
+
       <div class="form-group inputs">
         <input
           type="button" role="button" i18n-value value="Cancel" class="action-button action-button-cancel"
index afcdb9a16f31ceb4db00a0714dd1c7059538b839..afa0d96f796c8d0878fde6af0035455ccae5c891 100644 (file)
@@ -4,3 +4,8 @@
 textarea {
   @include peertube-textarea(100%, 100px);
 }
+
+.live-info {
+  font-size: 15px;
+  margin: 40px 0 20px 0;
+}
index 4ef17bfe3c6f040f85e8db77eab79d6806d12e56..8f4c129a5c92b650f0a1912e543fe14487aaf7ad 100644 (file)
@@ -186,7 +186,12 @@ export class VideoActionsDropdownComponent implements OnChanges {
   async removeVideo () {
     this.modalOpened.emit()
 
-    const res = await this.confirmService.confirm($localize`Do you really want to delete this video?`, $localize`Delete`)
+    let message = $localize`Do you really want to delete this video?`
+    if (this.video.isLive) {
+      message += ' ' + $localize`The live stream will be automatically terminated.`
+    }
+
+    const res = await this.confirmService.confirm(message, $localize`Delete`)
     if (res === false) return
 
     this.videoService.removeVideo(this.video.id)
diff --git a/client/src/assets/images/feather/live.svg b/client/src/assets/images/feather/live.svg
new file mode 100644 (file)
index 0000000..5abfcd1
--- /dev/null
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-radio"><circle cx="12" cy="12" r="2"></circle><path d="M16.24 7.76a6 6 0 0 1 0 8.49m-8.48-.01a6 6 0 0 1 0-8.49m11.31-2.82a10 10 0 0 1 0 14.14m-14.14 0a10 10 0 0 1 0-14.14"></path></svg>
\ No newline at end of file
index cd5bb1d1cdbbf864470802d105d4546b6cce050d..32eeff4d1d40205369607d67e97e10d24905da32 100644 (file)
@@ -1,7 +1,8 @@
 import * as Bull from 'bull'
 import { readdir, remove } from 'fs-extra'
 import { join } from 'path'
-import { getVideoFileResolution, hlsPlaylistToFragmentedMP4 } from '@server/helpers/ffmpeg-utils'
+import { getDurationFromVideoFile, getVideoFileResolution, hlsPlaylistToFragmentedMP4 } from '@server/helpers/ffmpeg-utils'
+import { publishAndFederateIfNeeded } from '@server/lib/video'
 import { getHLSDirectory } from '@server/lib/video-paths'
 import { generateHlsPlaylist } from '@server/lib/video-transcoding'
 import { VideoModel } from '@server/models/video/video'
@@ -44,6 +45,7 @@ async function saveLive (video: MVideo, live: MVideoLive) {
 
   const playlistFiles = files.filter(f => f.endsWith('.m3u8') && f !== 'master.m3u8')
   const resolutions: number[] = []
+  let duration: number
 
   for (const playlistFile of playlistFiles) {
     const playlistPath = join(hlsDirectory, playlistFile)
@@ -58,6 +60,10 @@ async function saveLive (video: MVideo, live: MVideoLive) {
     const segmentFiles = files.filter(f => f.startsWith(shouldStartWith) && f.endsWith('.ts'))
     await hlsPlaylistToFragmentedMP4(hlsDirectory, segmentFiles, mp4TmpName)
 
+    if (!duration) {
+      duration = await getDurationFromVideoFile(mp4TmpName)
+    }
+
     resolutions.push(videoFileResolution)
   }
 
@@ -67,6 +73,8 @@ async function saveLive (video: MVideo, live: MVideoLive) {
 
   video.isLive = false
   video.state = VideoState.TO_TRANSCODE
+  video.duration = duration
+
   await video.save()
 
   const videoWithFiles = await VideoModel.loadWithFiles(video.id)
@@ -86,6 +94,8 @@ async function saveLive (video: MVideo, live: MVideoLive) {
 
   video.state = VideoState.PUBLISHED
   await video.save()
+
+  await publishAndFederateIfNeeded(video)
 }
 
 async function cleanupLive (video: MVideo, streamingPlaylist: MStreamingPlaylist) {
index 2aebc29f77a1e4db08065f9d058d68ef5dbb8d29..843a9f1b59d220212c2efed2e12984ec8b9149c2 100644 (file)
@@ -1,4 +1,5 @@
 import * as Bull from 'bull'
+import { publishAndFederateIfNeeded } from '@server/lib/video'
 import { getVideoFilePath } from '@server/lib/video-paths'
 import { MVideoFullLight, MVideoUUID, MVideoWithFile } from '@server/types/models'
 import {
@@ -174,25 +175,3 @@ function createHlsJobIfEnabled (payload?: { videoUUID: string, resolution: numbe
     return JobQueue.Instance.createJob({ type: 'video-transcoding', payload: hlsTranscodingPayload })
   }
 }
-
-async function publishAndFederateIfNeeded (video: MVideoUUID) {
-  const { videoDatabase, videoPublished } = await sequelizeTypescript.transaction(async t => {
-    // Maybe the video changed in database, refresh it
-    const videoDatabase = await VideoModel.loadAndPopulateAccountAndServerAndTags(video.uuid, t)
-    // Video does not exist anymore
-    if (!videoDatabase) return undefined
-
-    // We transcoded the video file in another format, now we can publish it
-    const videoPublished = await videoDatabase.publishIfNeededAndSave(t)
-
-    // If the video was not published, we consider it is a new one for other instances
-    await federateVideoIfNeeded(videoDatabase, videoPublished, t)
-
-    return { videoDatabase, videoPublished }
-  })
-
-  if (videoPublished) {
-    Notifier.Instance.notifyOnNewVideoIfNeeded(videoDatabase)
-    Notifier.Instance.notifyOnVideoPublishedAfterTranscoding(videoDatabase)
-  }
-}
index 6df41e6cd910ea6a731f5017c0a9bf80766cbac6..81b7c41592c5c35e2b87fc6ca27b5f115556cc7e 100644 (file)
@@ -1,9 +1,12 @@
 import { Transaction } from 'sequelize/types'
+import { sequelizeTypescript } from '@server/initializers/database'
 import { TagModel } from '@server/models/video/tag'
 import { VideoModel } from '@server/models/video/video'
 import { FilteredModelAttributes } from '@server/types'
-import { MTag, MThumbnail, MVideoTag, MVideoThumbnail } from '@server/types/models'
+import { MTag, MThumbnail, MVideoTag, MVideoThumbnail, MVideoUUID } from '@server/types/models'
 import { ThumbnailType, VideoCreate, VideoPrivacy } from '@shared/models'
+import { federateVideoIfNeeded } from './activitypub/videos'
+import { Notifier } from './notifier'
 import { createVideoMiniatureFromExisting } from './thumbnail'
 
 function buildLocalVideoFromReq (videoInfo: VideoCreate, channelId: number): FilteredModelAttributes<VideoModel> {
@@ -78,10 +81,33 @@ async function setVideoTags (options: {
   }
 }
 
+async function publishAndFederateIfNeeded (video: MVideoUUID) {
+  const { videoDatabase, videoPublished } = await sequelizeTypescript.transaction(async t => {
+    // Maybe the video changed in database, refresh it
+    const videoDatabase = await VideoModel.loadAndPopulateAccountAndServerAndTags(video.uuid, t)
+    // Video does not exist anymore
+    if (!videoDatabase) return undefined
+
+    // We transcoded the video file in another format, now we can publish it
+    const videoPublished = await videoDatabase.publishIfNeededAndSave(t)
+
+    // If the video was not published, we consider it is a new one for other instances
+    await federateVideoIfNeeded(videoDatabase, videoPublished, t)
+
+    return { videoDatabase, videoPublished }
+  })
+
+  if (videoPublished) {
+    Notifier.Instance.notifyOnNewVideoIfNeeded(videoDatabase)
+    Notifier.Instance.notifyOnVideoPublishedAfterTranscoding(videoDatabase)
+  }
+}
+
 // ---------------------------------------------------------------------------
 
 export {
   buildLocalVideoFromReq,
+  publishAndFederateIfNeeded,
   buildVideoThumbnailsFromReq,
   setVideoTags
 }