1
0
Fork 0

Add modal to display live information

This commit is contained in:
Chocobozzz 2020-10-28 10:49:20 +01:00 committed by Chocobozzz
parent 31c82cd914
commit d846d99c6c
18 changed files with 183 additions and 40 deletions

View file

@ -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>

View file

@ -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
})
}
}

View file

@ -16,7 +16,7 @@
</div> </div>
</div> </div>
<div class="modal-footer inputs"> <div class="modal-footer">
<div class="form-group inputs"> <div class="form-group inputs">
<input <input
type="button" role="button" i18n-value value="Cancel" class="action-button action-button-cancel" type="button" role="button" i18n-value value="Cancel" class="action-button action-button-cancel"

View file

@ -0,0 +1,10 @@
@import '_variables';
@import '_mixins';
p-autocomplete {
display: block;
}
.form-group {
margin: 20px 0;
}

View file

@ -34,18 +34,13 @@
<ng-template ptTemplate="rowButtons" let-video> <ng-template ptTemplate="rowButtons" let-video>
<div class="action-button"> <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-edit-button label [routerLink]="[ '/videos', 'update', video.uuid ]"></my-edit-button>
<my-button i18n-label label="Change ownership" <my-action-dropdown [actions]="videoActions" [entry]="{ video: video }"></my-action-dropdown>
className="action-button-change-ownership grey-button"
icon="ownership-change"
(click)="changeOwnership($event, video)"
></my-button>
</div> </div>
</ng-template> </ng-template>
</my-videos-selection> </my-videos-selection>
<my-video-change-ownership #videoChangeOwnershipModal></my-video-change-ownership> <my-video-change-ownership #videoChangeOwnershipModal></my-video-change-ownership>
<my-live-stream-information #liveStreamInformationModal></my-live-stream-information>

View file

@ -5,10 +5,11 @@ import { ActivatedRoute, Router } from '@angular/router'
import { AuthService, ComponentPagination, ConfirmService, Notifier, ScreenService, ServerService } from '@app/core' import { AuthService, ComponentPagination, ConfirmService, Notifier, ScreenService, ServerService } from '@app/core'
import { DisableForReuseHook } from '@app/core/routing/disable-for-reuse-hook' import { DisableForReuseHook } from '@app/core/routing/disable-for-reuse-hook'
import { immutableAssign } from '@app/helpers' 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 { MiniatureDisplayOptions, OwnerDisplayType, SelectionType, VideosSelectionComponent } from '@app/shared/shared-video-miniature'
import { VideoSortField } from '@shared/models' 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({ @Component({
selector: 'my-account-videos', selector: 'my-account-videos',
@ -18,6 +19,7 @@ import { VideoChangeOwnershipComponent } from './video-change-ownership/video-ch
export class MyAccountVideosComponent implements OnInit, DisableForReuseHook { export class MyAccountVideosComponent implements OnInit, DisableForReuseHook {
@ViewChild('videosSelection', { static: true }) videosSelection: VideosSelectionComponent @ViewChild('videosSelection', { static: true }) videosSelection: VideosSelectionComponent
@ViewChild('videoChangeOwnershipModal', { static: true }) videoChangeOwnershipModal: VideoChangeOwnershipComponent @ViewChild('videoChangeOwnershipModal', { static: true }) videoChangeOwnershipModal: VideoChangeOwnershipComponent
@ViewChild('liveStreamInformationModal', { static: true }) liveStreamInformationModal: LiveStreamInformationComponent
titlePage: string titlePage: string
selection: SelectionType = {} selection: SelectionType = {}
@ -37,6 +39,8 @@ export class MyAccountVideosComponent implements OnInit, DisableForReuseHook {
} }
ownerDisplayType: OwnerDisplayType = 'videoChannel' ownerDisplayType: OwnerDisplayType = 'videoChannel'
videoActions: DropdownAction<{ video: Video }>[] = []
videos: Video[] = [] videos: Video[] = []
videosSearch: string videosSearch: string
videosSearchChanged = new Subject<string>() videosSearchChanged = new Subject<string>()
@ -56,6 +60,8 @@ export class MyAccountVideosComponent implements OnInit, DisableForReuseHook {
} }
ngOnInit () { ngOnInit () {
this.buildActions()
this.videosSearchChanged this.videosSearchChanged
.pipe(debounceTime(500)) .pipe(debounceTime(500))
.subscribe(() => { .subscribe(() => {
@ -138,12 +144,36 @@ export class MyAccountVideosComponent implements OnInit, DisableForReuseHook {
) )
} }
changeOwnership (event: Event, video: Video) { changeOwnership (video: Video) {
event.preventDefault()
this.videoChangeOwnershipModal.show(video) this.videoChangeOwnershipModal.show(video)
} }
displayLiveInformation (video: Video) {
this.liveStreamInformationModal.show(video)
}
private removeVideoFromArray (id: number) { private removeVideoFromArray (id: number) {
this.videos = this.videos.filter(v => v.id !== id) 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'
}
]
}
} }

View 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 { 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 { MyAccountVideoPlaylistsComponent } from './my-account-video-playlists/my-account-video-playlists.component'
import { MyAccountVideosComponent } from './my-account-videos/my-account-videos.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' import { MyAccountComponent } from './my-account.component'
@NgModule({ @NgModule({
@ -68,6 +69,8 @@ import { MyAccountComponent } from './my-account.component'
MyAccountVideosComponent, MyAccountVideosComponent,
VideoChangeOwnershipComponent, VideoChangeOwnershipComponent,
LiveStreamInformationComponent,
MyAccountOwnershipComponent, MyAccountOwnershipComponent,
MyAccountAcceptOwnershipComponent, MyAccountAcceptOwnershipComponent,
MyAccountVideoImportsComponent, MyAccountVideoImportsComponent,

View file

@ -226,7 +226,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
} }
isVideoDownloadable () { 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 () { loadCompleteDescription () {

View file

@ -66,6 +66,7 @@ const icons = {
'cross': require('!!raw-loader?!../../../assets/images/feather/x.svg').default, 'cross': require('!!raw-loader?!../../../assets/images/feather/x.svg').default,
'tick': require('!!raw-loader?!../../../assets/images/feather/check.svg').default, 'tick': require('!!raw-loader?!../../../assets/images/feather/check.svg').default,
'columns': require('!!raw-loader?!../../../assets/images/feather/columns.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, 'repeat': require('!!raw-loader?!../../../assets/images/feather/repeat.svg').default,
'message-circle': require('!!raw-loader?!../../../assets/images/feather/message-circle.svg').default 'message-circle': require('!!raw-loader?!../../../assets/images/feather/message-circle.svg').default
} }

View file

@ -1,6 +1,7 @@
<ng-template #modal> <ng-template #modal>
<div class="modal-header"> <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> <my-global-icon iconName="cross" aria-label="Close" role="button" (click)="hide()"></my-global-icon>
</div> </div>
@ -28,6 +29,10 @@
</my-peertube-checkbox> </my-peertube-checkbox>
</div> </div>
<strong class="live-info" *ngIf="video.isLive" i18n>
Blocking this live will automatically terminate the live stream.
</strong>
<div class="form-group inputs"> <div class="form-group inputs">
<input <input
type="button" role="button" i18n-value value="Cancel" class="action-button action-button-cancel" type="button" role="button" i18n-value value="Cancel" class="action-button action-button-cancel"

View file

@ -4,3 +4,8 @@
textarea { textarea {
@include peertube-textarea(100%, 100px); @include peertube-textarea(100%, 100px);
} }
.live-info {
font-size: 15px;
margin: 40px 0 20px 0;
}

View file

@ -186,7 +186,12 @@ export class VideoActionsDropdownComponent implements OnChanges {
async removeVideo () { async removeVideo () {
this.modalOpened.emit() 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 if (res === false) return
this.videoService.removeVideo(this.video.id) this.videoService.removeVideo(this.video.id)

View file

@ -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>

After

Width:  |  Height:  |  Size: 389 B

View file

@ -1,7 +1,8 @@
import * as Bull from 'bull' import * as Bull from 'bull'
import { readdir, remove } from 'fs-extra' import { readdir, remove } from 'fs-extra'
import { join } from 'path' 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 { getHLSDirectory } from '@server/lib/video-paths'
import { generateHlsPlaylist } from '@server/lib/video-transcoding' import { generateHlsPlaylist } from '@server/lib/video-transcoding'
import { VideoModel } from '@server/models/video/video' 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 playlistFiles = files.filter(f => f.endsWith('.m3u8') && f !== 'master.m3u8')
const resolutions: number[] = [] const resolutions: number[] = []
let duration: number
for (const playlistFile of playlistFiles) { for (const playlistFile of playlistFiles) {
const playlistPath = join(hlsDirectory, playlistFile) 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')) const segmentFiles = files.filter(f => f.startsWith(shouldStartWith) && f.endsWith('.ts'))
await hlsPlaylistToFragmentedMP4(hlsDirectory, segmentFiles, mp4TmpName) await hlsPlaylistToFragmentedMP4(hlsDirectory, segmentFiles, mp4TmpName)
if (!duration) {
duration = await getDurationFromVideoFile(mp4TmpName)
}
resolutions.push(videoFileResolution) resolutions.push(videoFileResolution)
} }
@ -67,6 +73,8 @@ async function saveLive (video: MVideo, live: MVideoLive) {
video.isLive = false video.isLive = false
video.state = VideoState.TO_TRANSCODE video.state = VideoState.TO_TRANSCODE
video.duration = duration
await video.save() await video.save()
const videoWithFiles = await VideoModel.loadWithFiles(video.id) const videoWithFiles = await VideoModel.loadWithFiles(video.id)
@ -86,6 +94,8 @@ async function saveLive (video: MVideo, live: MVideoLive) {
video.state = VideoState.PUBLISHED video.state = VideoState.PUBLISHED
await video.save() await video.save()
await publishAndFederateIfNeeded(video)
} }
async function cleanupLive (video: MVideo, streamingPlaylist: MStreamingPlaylist) { async function cleanupLive (video: MVideo, streamingPlaylist: MStreamingPlaylist) {

View file

@ -1,4 +1,5 @@
import * as Bull from 'bull' import * as Bull from 'bull'
import { publishAndFederateIfNeeded } from '@server/lib/video'
import { getVideoFilePath } from '@server/lib/video-paths' import { getVideoFilePath } from '@server/lib/video-paths'
import { MVideoFullLight, MVideoUUID, MVideoWithFile } from '@server/types/models' import { MVideoFullLight, MVideoUUID, MVideoWithFile } from '@server/types/models'
import { import {
@ -174,25 +175,3 @@ function createHlsJobIfEnabled (payload?: { videoUUID: string, resolution: numbe
return JobQueue.Instance.createJob({ type: 'video-transcoding', payload: hlsTranscodingPayload }) 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)
}
}

View file

@ -1,9 +1,12 @@
import { Transaction } from 'sequelize/types' import { Transaction } from 'sequelize/types'
import { sequelizeTypescript } from '@server/initializers/database'
import { TagModel } from '@server/models/video/tag' import { TagModel } from '@server/models/video/tag'
import { VideoModel } from '@server/models/video/video' import { VideoModel } from '@server/models/video/video'
import { FilteredModelAttributes } from '@server/types' 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 { ThumbnailType, VideoCreate, VideoPrivacy } from '@shared/models'
import { federateVideoIfNeeded } from './activitypub/videos'
import { Notifier } from './notifier'
import { createVideoMiniatureFromExisting } from './thumbnail' import { createVideoMiniatureFromExisting } from './thumbnail'
function buildLocalVideoFromReq (videoInfo: VideoCreate, channelId: number): FilteredModelAttributes<VideoModel> { 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 { export {
buildLocalVideoFromReq, buildLocalVideoFromReq,
publishAndFederateIfNeeded,
buildVideoThumbnailsFromReq, buildVideoThumbnailsFromReq,
setVideoTags setVideoTags
} }