1
0
Fork 0
mirror of https://github.com/Chocobozzz/PeerTube.git synced 2025-10-03 09:49:20 +02:00

Redesign manage my videos

* Use a table to list my videos for a clearer overview and so it's
   easier to add bulk actions in the future
 * Use a "Manage" video page with a proper URL navigation
 * Add "Stats" and "Studio" in this "Manage" page
This commit is contained in:
Chocobozzz 2025-03-06 09:45:10 +01:00
parent f0f44e1704
commit b295dd5820
No known key found for this signature in database
GPG key ID: 583A612D890159BE
342 changed files with 9452 additions and 6376 deletions

View file

@ -61,7 +61,8 @@
"exportDeclaration.forceMultiLine": "never",
"importDeclaration.forceMultiLine": "never",
"arrayExpression.spaceAround": true,
"arrayPattern.spaceAround": true
"arrayPattern.spaceAround": true,
"importDeclaration.preferSingleLine": true
},
"json": {},
"markdown": {},

View file

@ -1,19 +1,26 @@
import { browserSleep, getCheckbox, go, isCheckboxSelected } from '../utils'
import { browserSleep, go, setCheckboxEnabled } from '../utils'
export class AdminConfigPage {
async navigateTo (tab: 'instance-homepage' | 'basic-configuration' | 'instance-information') {
async navigateTo (tab: 'instance-homepage' | 'basic-configuration' | 'instance-information' | 'live') {
const waitTitles = {
'instance-homepage': 'INSTANCE HOMEPAGE',
'basic-configuration': 'APPEARANCE',
'instance-information': 'INSTANCE'
'instance-information': 'INSTANCE',
'live': 'LIVE'
}
const url = '/admin/settings/config/edit-custom#' + tab
if (await browser.getUrl() !== url) {
await go('/admin/settings/config/edit-custom#' + tab)
}
await go('/admin/settings/config/edit-custom#' + tab)
await $('h2=' + waitTitles[tab]).waitForDisplayed()
}
async updateNSFWSetting (newValue: 'do_not_list' | 'blur' | 'display') {
await this.navigateTo('instance-information')
const elem = $('#instanceDefaultNSFWPolicy')
await elem.waitForDisplayed()
@ -23,35 +30,34 @@ export class AdminConfigPage {
return elem.selectByAttribute('value', newValue)
}
updateHomepage (newValue: string) {
async updateHomepage (newValue: string) {
await this.navigateTo('instance-homepage')
return $('#instanceCustomHomepageContent').setValue(newValue)
}
async toggleSignup (enabled: boolean) {
if (await isCheckboxSelected('signupEnabled') === enabled) return
await this.navigateTo('basic-configuration')
const checkbox = await getCheckbox('signupEnabled')
await checkbox.waitForClickable()
await checkbox.click()
return setCheckboxEnabled('signupEnabled', enabled)
}
async toggleSignupApproval (required: boolean) {
if (await isCheckboxSelected('signupRequiresApproval') === required) return
await this.navigateTo('basic-configuration')
const checkbox = await getCheckbox('signupRequiresApproval')
await checkbox.waitForClickable()
await checkbox.click()
return setCheckboxEnabled('signupRequiresApproval', required)
}
async toggleSignupEmailVerification (required: boolean) {
if (await isCheckboxSelected('signupRequiresEmailVerification') === required) return
await this.navigateTo('basic-configuration')
const checkbox = await getCheckbox('signupRequiresEmailVerification')
return setCheckboxEnabled('signupRequiresEmailVerification', required)
}
await checkbox.waitForClickable()
await checkbox.click()
async toggleLive (enabled: boolean) {
await this.navigateTo('live')
return setCheckboxEnabled('liveEnabled', enabled)
}
async save () {

View file

@ -1,7 +1,6 @@
import { getCheckbox, go, selectCustomSelect } from '../utils'
export class MyAccountPage {
navigateToMyVideos () {
return $('a[href="/my-library/videos"]').click()
}
@ -54,7 +53,7 @@ export class MyAccountPage {
// My account Videos
async removeVideo (name: string) {
const container = await this.getVideoElement(name)
const container = await this.getVideoRow(name)
await container.$('my-action-dropdown .dropdown-toggle').click()
@ -76,8 +75,8 @@ export class MyAccountPage {
}
async countVideos (names: string[]) {
const elements = await $$('.video').filter(async e => {
const t = await e.$('.video-name').getText()
const elements = await $$('.video-cell-name .name').filter(async e => {
const t = await e.getText()
return names.some(n => t.includes(n))
})
@ -137,22 +136,16 @@ export class MyAccountPage {
// My account Videos
private async getVideoElement (name: string) {
const video = async () => {
const videos = await $$('.video').filter(async e => {
const t = await e.$('.video-name').getText()
private async getVideoRow (name: string) {
let el = $('.name*=' + name)
return t.includes(name)
})
await el.waitForDisplayed()
return videos[0]
while (await el.getTagName() !== 'tr') {
el = el.parentElement()
}
await browser.waitUntil(async () => {
return (await video()).isDisplayed()
})
return video()
return el
}
// My account playlists

View file

@ -0,0 +1,130 @@
import { clickOnRadio, getCheckbox, go, isRadioSelected, selectCustomSelect } from '../utils'
export abstract class VideoManage {
async clickOnSave () {
const button = this.getSaveButton()
await button.waitForClickable()
await button.click()
await this.waitForSaved()
}
async clickOnWatch () {
// Simulate the click, because the button opens a new tab
const button = $('.watch-save > my-button[icon=external-link] a')
await button.waitForClickable()
await go(await button.getAttribute('href'))
}
// ---------------------------------------------------------------------------
async setAsNSFW () {
await this.goOnPage('Moderation')
const checkbox = await getCheckbox('nsfw')
await checkbox.waitForClickable()
return checkbox.click()
}
// ---------------------------------------------------------------------------
async setAsPublic () {
await this.goOnPage('Main information')
return selectCustomSelect('privacy', 'Public')
}
async setAsPrivate () {
await this.goOnPage('Main information')
return selectCustomSelect('privacy', 'Private')
}
async setAsPasswordProtected (videoPassword: string) {
await this.goOnPage('Main information')
selectCustomSelect('privacy', 'Password protected')
const videoPasswordInput = $('input#videoPassword')
await videoPasswordInput.waitForClickable()
await videoPasswordInput.clearValue()
return videoPasswordInput.setValue(videoPassword)
}
// ---------------------------------------------------------------------------
async scheduleUpload () {
await this.goOnPage('Main information')
selectCustomSelect('privacy', 'Scheduled')
const input = this.getScheduleInput()
await input.waitForClickable()
await input.click()
const nextDay = $('.p-datepicker-today + td > span')
await nextDay.waitForClickable()
await nextDay.click()
await nextDay.waitForDisplayed({ reverse: true })
}
getScheduleInput () {
return $('#schedulePublicationAt input')
}
// ---------------------------------------------------------------------------
async setNormalLive () {
await this.goOnPage('Live settings')
await clickOnRadio('permanentLiveFalse')
}
async setPermanentLive () {
await this.goOnPage('Live settings')
await clickOnRadio('permanentLiveTrue')
}
async getLiveState () {
await this.goOnPage('Live settings')
if (await isRadioSelected('#permanentLiveTrue')) return 'permanent'
return 'normal'
}
// ---------------------------------------------------------------------------
async refresh (videoName: string) {
await browser.refresh()
await browser.waitUntil(async () => {
const url = await browser.getUrl()
return url.includes('/videos/manage')
})
await browser.waitUntil(async () => {
return await $('#name').getValue() === videoName
})
}
// ---------------------------------------------------------------------------
protected getSaveButton () {
return $('.save-button > button:not([disabled])')
}
protected waitForSaved () {
return $('.save-button > button[disabled], my-manage-errors').waitForDisplayed()
}
protected async goOnPage (page: 'Main information' | 'Moderation' | 'Live settings') {
const el = $('my-video-manage-container .menu').$('*=' + page)
await el.waitForClickable()
await el.click()
}
}

View file

@ -0,0 +1,61 @@
import { join } from 'path'
import { VideoManage } from './video-manage'
export class VideoPublishPage extends VideoManage {
async navigateTo (tab?: 'Go live') {
const publishButton = await $('.publish-button > a')
await publishButton.waitForClickable()
await publishButton.click()
await $('.upload-video-container').waitForDisplayed()
if (tab) {
const el = $(`.nav-link*=${tab}`)
await el.waitForClickable()
await el.click()
}
}
// ---------------------------------------------------------------------------
async uploadVideo (fixtureName: 'video.mp4' | 'video2.mp4' | 'video3.mp4') {
const fileToUpload = join(__dirname, '../../fixtures/' + fixtureName)
const fileInputSelector = '.upload-video-container input[type=file]'
const parentFileInput = '.upload-video-container .button-file'
// Avoid sending keys on non visible element
await browser.execute(`document.querySelector('${fileInputSelector}').style.opacity = 1`)
await browser.execute(`document.querySelector('${parentFileInput}').style.overflow = 'initial'`)
await browser.pause(1000)
const elem = await $(fileInputSelector)
await elem.chooseFile(fileToUpload)
// Wait for the upload to finish
await this.getSaveButton().waitForClickable()
}
async publishLive () {
await $('#permanentLiveTrue').parentElement().click()
const submit = $('.upload-video-container .primary-button:not([disabled])')
await submit.waitForClickable()
await submit.click()
await this.getSaveButton().waitForClickable()
}
// ---------------------------------------------------------------------------
async validSecondStep (videoName: string) {
await this.goOnPage('Main information')
const nameInput = $('input#name')
await nameInput.clearValue()
await nameInput.setValue(videoName)
await this.clickOnSave()
}
}

View file

@ -1,5 +1,6 @@
export class VideoUpdatePage {
import { VideoManage } from './video-manage'
export class VideoUpdatePage extends VideoManage {
async updateName (videoName: string) {
const nameInput = $('input#name')
@ -7,14 +8,4 @@ export class VideoUpdatePage {
await nameInput.clearValue()
await nameInput.setValue(videoName)
}
async validUpdate () {
const submitButton = await this.getSubmitButton()
return submitButton.click()
}
private getSubmitButton () {
return $('.submit-container .action-button')
}
}

View file

@ -1,80 +0,0 @@
import { join } from 'path'
import { getCheckbox, selectCustomSelect } from '../utils'
export class VideoUploadPage {
async navigateTo () {
const publishButton = await $('.publish-button > a')
await publishButton.waitForClickable()
await publishButton.click()
await $('.upload-video-container').waitForDisplayed()
}
async uploadVideo (fixtureName: 'video.mp4' | 'video2.mp4' | 'video3.mp4') {
const fileToUpload = join(__dirname, '../../fixtures/' + fixtureName)
const fileInputSelector = '.upload-video-container input[type=file]'
const parentFileInput = '.upload-video-container .button-file'
// Avoid sending keys on non visible element
await browser.execute(`document.querySelector('${fileInputSelector}').style.opacity = 1`)
await browser.execute(`document.querySelector('${parentFileInput}').style.overflow = 'initial'`)
await browser.pause(1000)
const elem = await $(fileInputSelector)
await elem.chooseFile(fileToUpload)
// Wait for the upload to finish
await browser.waitUntil(async () => {
const warning = await $('=Publish will be available when upload is finished').isDisplayed()
const progress = await $('.progress-container=100%').isDisplayed()
return !warning && progress
})
}
async setAsNSFW () {
const checkbox = await getCheckbox('nsfw')
await checkbox.waitForClickable()
return checkbox.click()
}
async validSecondUploadStep (videoName: string) {
const nameInput = $('input#name')
await nameInput.clearValue()
await nameInput.setValue(videoName)
const button = this.getSecondStepSubmitButton()
await button.waitForClickable()
await button.click()
return browser.waitUntil(async () => {
return (await browser.getUrl()).includes('/w/')
})
}
setAsPublic () {
return selectCustomSelect('privacy', 'Public')
}
setAsPrivate () {
return selectCustomSelect('privacy', 'Private')
}
async setAsPasswordProtected (videoPassword: string) {
selectCustomSelect('privacy', 'Password protected')
const videoPasswordInput = $('input#videoPassword')
await videoPasswordInput.waitForClickable()
await videoPasswordInput.clearValue()
return videoPasswordInput.setValue(videoPassword)
}
private getSecondStepSubmitButton () {
return $('.submit-container my-button')
}
}

View file

@ -1,24 +1,16 @@
import { browserSleep, FIXTURE_URLS, go } from '../utils'
export class VideoWatchPage {
constructor (private isMobileDevice: boolean, private isSafari: boolean) {
}
waitWatchVideoName (videoName: string) {
waitWatchVideoName (videoName: string, maxTime?: number) {
if (this.isSafari) return browserSleep(5000)
// On mobile we display the first node, on desktop the second one
const index = this.isMobileDevice ? 0 : 1
return browser.waitUntil(async () => {
if (!await $('.video-info .video-info-name').isExisting()) return false
const elem = await $$('.video-info .video-info-name')[index]
return (await elem.getText()).includes(videoName) && elem.isDisplayed()
})
return (await this.getVideoName()) === videoName
}, { timeout: maxTime })
}
getVideoName () {
@ -82,16 +74,17 @@ export class VideoWatchPage {
return go(FIXTURE_URLS.HLS_PLAYLIST_EMBED)
}
async clickOnUpdate () {
async clickOnManage () {
await this.clickOnMoreDropdownIcon()
const items = await $$('.dropdown-menu.show .dropdown-item')
for (const item of items) {
const href = await item.getAttribute('href')
const content = await item.getText()
if (href?.includes('/update/')) {
if (content.includes('Manage')) {
await item.click()
await $('#name').waitForClickable()
return
}
}
@ -123,12 +116,6 @@ export class VideoWatchPage {
return playlist().click()
}
waitUntilVideoName (name: string, maxTime: number) {
return browser.waitUntil(async () => {
return (await this.getVideoName()) === name
}, { timeout: maxTime })
}
async clickOnMoreDropdownIcon () {
const dropdown = $('my-video-actions-dropdown .action-button')
await dropdown.click()

View file

@ -3,7 +3,7 @@ import { MyAccountPage } from '../po/my-account.po'
import { PlayerPage } from '../po/player.po'
import { VideoListPage } from '../po/video-list.po'
import { VideoUpdatePage } from '../po/video-update.po'
import { VideoUploadPage } from '../po/video-upload.po'
import { VideoPublishPage } from '../po/video-publish.po'
import { VideoWatchPage } from '../po/video-watch.po'
import { FIXTURE_URLS, go, isIOS, isMobileDevice, isSafari, waitServerUp } from '../utils'
@ -19,7 +19,7 @@ function isUploadUnsupported () {
describe('Videos all workflow', () => {
let videoWatchPage: VideoWatchPage
let videoListPage: VideoListPage
let videoUploadPage: VideoUploadPage
let videoPublishPage: VideoPublishPage
let videoUpdatePage: VideoUpdatePage
let myAccountPage: MyAccountPage
let loginPage: LoginPage
@ -46,7 +46,7 @@ describe('Videos all workflow', () => {
beforeEach(async () => {
videoWatchPage = new VideoWatchPage(isMobileDevice(), isSafari())
videoUploadPage = new VideoUploadPage()
videoPublishPage = new VideoPublishPage()
videoUpdatePage = new VideoUpdatePage()
myAccountPage = new MyAccountPage()
loginPage = new LoginPage(isMobileDevice())
@ -70,10 +70,10 @@ describe('Videos all workflow', () => {
it('Should upload a video', async () => {
if (isUploadUnsupported()) return
await videoUploadPage.navigateTo()
await videoPublishPage.navigateTo()
await videoUploadPage.uploadVideo('video.mp4')
return videoUploadPage.validSecondUploadStep(videoName)
await videoPublishPage.uploadVideo('video.mp4')
await videoPublishPage.validSecondStep(videoName)
})
it('Should list videos', async () => {
@ -124,12 +124,12 @@ describe('Videos all workflow', () => {
await go(videoWatchUrl)
await videoWatchPage.clickOnUpdate()
await videoWatchPage.clickOnManage()
videoName += ' updated'
await videoUpdatePage.updateName(videoName)
await videoUpdatePage.validUpdate()
await videoUpdatePage.clickOnSave()
await videoUpdatePage.clickOnWatch()
const name = await videoWatchPage.getVideoName()
expect(name).toEqual(videoName)
@ -145,10 +145,11 @@ describe('Videos all workflow', () => {
await videoWatchPage.saveToPlaylist(playlistName)
await browser.pause(5000)
await videoUploadPage.navigateTo()
await videoPublishPage.navigateTo()
await videoUploadPage.uploadVideo('video2.mp4')
await videoUploadPage.validSecondUploadStep(video2Name)
await videoPublishPage.uploadVideo('video2.mp4')
await videoPublishPage.validSecondStep(video2Name)
await videoPublishPage.clickOnWatch()
await videoWatchPage.clickOnSave()
await videoWatchPage.saveToPlaylist(playlistName)
@ -173,7 +174,7 @@ describe('Videos all workflow', () => {
await myAccountPage.playPlaylist()
await videoWatchPage.waitUntilVideoName(video2Name, 40 * 1000)
await videoWatchPage.waitWatchVideoName(video2Name, 40 * 1000)
})
it('Should watch the Web Video playlist in the embed', async () => {

View file

@ -1,10 +1,10 @@
import { LoginPage } from '../po/login.po'
import { VideoUploadPage } from '../po/video-upload.po'
import { VideoPublishPage } from '../po/video-publish.po'
import { VideoWatchPage } from '../po/video-watch.po'
import { getScreenshotPath, go, isMobileDevice, isSafari, waitServerUp } from '../utils'
describe('Custom server defaults', () => {
let videoUploadPage: VideoUploadPage
let videoPublishPage: VideoPublishPage
let loginPage: LoginPage
let videoWatchPage: VideoWatchPage
@ -12,7 +12,7 @@ describe('Custom server defaults', () => {
await waitServerUp()
loginPage = new LoginPage(isMobileDevice())
videoUploadPage = new VideoUploadPage()
videoPublishPage = new VideoPublishPage()
videoWatchPage = new VideoWatchPage(isMobileDevice(), isSafari())
await browser.maximizeWindow()
@ -24,10 +24,11 @@ describe('Custom server defaults', () => {
})
it('Should upload a video with custom default values', async function () {
await videoUploadPage.navigateTo()
await videoUploadPage.uploadVideo('video.mp4')
await videoUploadPage.validSecondUploadStep('video')
await videoPublishPage.navigateTo()
await videoPublishPage.uploadVideo('video.mp4')
await videoPublishPage.validSecondStep('video')
await videoPublishPage.clickOnWatch()
await videoWatchPage.waitWatchVideoName('video')
const videoUrl = await browser.getUrl()
@ -66,11 +67,12 @@ describe('Custom server defaults', () => {
before(async () => {
await loginPage.loginAsRootUser()
await videoUploadPage.navigateTo()
await videoUploadPage.uploadVideo('video2.mp4')
await videoUploadPage.setAsPublic()
await videoUploadPage.validSecondUploadStep('video')
await videoPublishPage.navigateTo()
await videoPublishPage.uploadVideo('video2.mp4')
await videoPublishPage.setAsPublic()
await videoPublishPage.validSecondStep('video')
await videoPublishPage.clickOnWatch()
await videoWatchPage.waitWatchVideoName('video')
videoUrl = await browser.getUrl()

View file

@ -1,10 +1,10 @@
import { AdminPluginPage } from '../po/admin-plugin.po'
import { LoginPage } from '../po/login.po'
import { VideoUploadPage } from '../po/video-upload.po'
import { getCheckbox, isMobileDevice, waitServerUp } from '../utils'
import { VideoPublishPage } from '../po/video-publish.po'
import { getCheckbox, getScreenshotPath, isMobileDevice, waitServerUp } from '../utils'
describe('Plugins', () => {
let videoUploadPage: VideoUploadPage
let videoPublishPage: VideoPublishPage
let loginPage: LoginPage
let adminPluginPage: AdminPluginPage
@ -12,11 +12,11 @@ describe('Plugins', () => {
return getCheckbox('hello-world-field-4')
}
async function expectSubmitState ({ disabled }: { disabled: boolean }) {
const disabledSubmit = await $('my-button [disabled]')
async function expectSubmitError (hasError: boolean) {
await videoPublishPage.clickOnSave()
if (disabled) expect(await disabledSubmit.isDisplayed()).toBeTruthy()
else expect(await disabledSubmit.isDisplayed()).toBeFalsy()
await $('.form-error*=Should be enabled').waitForDisplayed({ reverse: !hasError })
await $('li*=Should be enabled').waitForDisplayed({ reverse: !hasError })
}
before(async () => {
@ -25,7 +25,7 @@ describe('Plugins', () => {
beforeEach(async () => {
loginPage = new LoginPage(isMobileDevice())
videoUploadPage = new VideoUploadPage()
videoPublishPage = new VideoPublishPage()
adminPluginPage = new AdminPluginPage()
await browser.maximizeWindow()
@ -41,15 +41,23 @@ describe('Plugins', () => {
})
it('Should have checkbox in video edit page', async () => {
await videoUploadPage.navigateTo()
await videoUploadPage.uploadVideo('video.mp4')
await videoPublishPage.navigateTo()
await videoPublishPage.uploadVideo('video.mp4')
await $('span=Super field 4 in main tab').waitForDisplayed()
const el = () => $('span=Super field 4 in main tab')
await el().waitForDisplayed()
// Only displayed if the video is public
await videoPublishPage.setAsPrivate()
await el().waitForDisplayed({ reverse: true })
await videoPublishPage.setAsPublic()
await el().waitForDisplayed()
const checkbox = await getPluginCheckbox()
expect(await checkbox.isDisplayed()).toBeTruthy()
await expectSubmitState({ disabled: true })
await expectSubmitError(true)
})
it('Should check the checkbox and be able to submit the video', async function () {
@ -58,7 +66,7 @@ describe('Plugins', () => {
await checkbox.waitForClickable()
await checkbox.click()
await expectSubmitState({ disabled: false })
await expectSubmitError(false)
})
it('Should uncheck the checkbox and not be able to submit the video', async function () {
@ -67,16 +75,16 @@ describe('Plugins', () => {
await checkbox.waitForClickable()
await checkbox.click()
await expectSubmitState({ disabled: true })
const error = await $('.form-error*=Should be enabled')
expect(await error.isDisplayed()).toBeTruthy()
await expectSubmitError(true)
})
it('Should change the privacy and should hide the checkbox', async function () {
await videoUploadPage.setAsPrivate()
await videoPublishPage.setAsPrivate()
await expectSubmitState({ disabled: false })
await expectSubmitError(false)
})
after(async () => {
await browser.saveScreenshot(getScreenshotPath('after-test.png'))
})
})

View file

@ -0,0 +1,61 @@
import { AdminConfigPage } from '../po/admin-config.po'
import { LoginPage } from '../po/login.po'
import { VideoPublishPage } from '../po/video-publish.po'
import { VideoWatchPage } from '../po/video-watch.po'
import { getScreenshotPath, isMobileDevice, isSafari, waitServerUp } from '../utils'
describe('Publish live', function () {
let videoPublishPage: VideoPublishPage
let loginPage: LoginPage
let adminConfigPage: AdminConfigPage
let videoWatchPage: VideoWatchPage
before(async () => {
await waitServerUp()
loginPage = new LoginPage(isMobileDevice())
videoPublishPage = new VideoPublishPage()
adminConfigPage = new AdminConfigPage()
videoWatchPage = new VideoWatchPage(isMobileDevice(), isSafari())
await browser.maximizeWindow()
await loginPage.loginAsRootUser()
})
it('Should enable live', async function () {
await adminConfigPage.toggleLive(true)
await adminConfigPage.save()
})
it('Should create a classic permanent live', async function () {
await videoPublishPage.navigateTo('Go live')
await videoPublishPage.publishLive()
await videoPublishPage.validSecondStep('Permanent live test')
expect(await videoPublishPage.getLiveState()).toEqual('permanent')
await videoPublishPage.clickOnWatch()
await videoWatchPage.waitWatchVideoName('Permanent live test')
})
it('Should create a permanent live and update it to a normal live', async function () {
await videoPublishPage.navigateTo('Go live')
await videoPublishPage.publishLive()
await videoPublishPage.setNormalLive()
await videoPublishPage.validSecondStep('Normal live test')
await videoPublishPage.clickOnWatch()
await videoWatchPage.waitWatchVideoName('Normal live test')
await videoWatchPage.clickOnManage()
expect(await videoPublishPage.getLiveState()).toEqual('normal')
})
after(async () => {
await browser.saveScreenshot(getScreenshotPath('after-test.png'))
})
})

View file

@ -0,0 +1,56 @@
import { LoginPage } from '../po/login.po'
import { VideoPublishPage } from '../po/video-publish.po'
import { getScreenshotPath, isMobileDevice, waitServerUp } from '../utils'
describe('Publish video', () => {
let videoPublishPage: VideoPublishPage
let loginPage: LoginPage
before(async () => {
await waitServerUp()
loginPage = new LoginPage(isMobileDevice())
videoPublishPage = new VideoPublishPage()
await browser.maximizeWindow()
})
describe('Common', function () {
before(async function () {
await loginPage.loginAsRootUser()
})
it('Should upload a video and on refresh being redirected to the manage page', async function () {
await videoPublishPage.navigateTo()
await videoPublishPage.uploadVideo('video.mp4')
await videoPublishPage.validSecondStep('first video')
await videoPublishPage.refresh('first video')
})
it('Should upload a video and schedule upload date', async function () {
await videoPublishPage.navigateTo()
await videoPublishPage.uploadVideo('video.mp4')
await videoPublishPage.scheduleUpload()
await videoPublishPage.validSecondStep('scheduled')
await videoPublishPage.refresh('scheduled')
// check if works? screenshot
expect(videoPublishPage.getScheduleInput()).toBeDisplayed()
const nextDay = new Date()
nextDay.setDate(nextDay.getDate() + 1)
const inputDate = new Date(await videoPublishPage.getScheduleInput().getValue())
expect(nextDay.getDate()).toEqual(inputDate.getDate())
expect(nextDay.getMonth()).toEqual(inputDate.getMonth())
expect(nextDay.getFullYear()).toEqual(inputDate.getFullYear())
})
})
after(async () => {
await browser.saveScreenshot(getScreenshotPath('after-test.png'))
})
})

View file

@ -75,7 +75,6 @@ describe('Signup', () => {
}) {
await loginPage.loginAsRootUser()
await adminConfigPage.navigateTo('basic-configuration')
await adminConfigPage.toggleSignup(options.enabled)
if (options.enabled) {
@ -116,9 +115,7 @@ describe('Signup', () => {
})
describe('Email verification disabled', function () {
describe('Direct registration', function () {
it('Should enable signup without approval', async () => {
await prepareSignup({ enabled: true, requiresApproval: false, requiresEmailVerification: false })
@ -171,7 +168,6 @@ describe('Signup', () => {
})
describe('Registration with approval', function () {
it('Should enable signup with approval', async () => {
await prepareSignup({ enabled: true, requiresApproval: true, requiresEmailVerification: false })
@ -252,7 +248,6 @@ describe('Signup', () => {
})
describe('Direct registration', function () {
it('Should enable signup without approval', async () => {
await prepareSignup({ enabled: true, requiresApproval: false, requiresEmailVerification: true })
@ -320,7 +315,6 @@ describe('Signup', () => {
})
describe('Registration with approval', function () {
it('Should enable signup without approval', async () => {
await prepareSignup({ enabled: true, requiresApproval: true, requiresEmailVerification: true })

View file

@ -1,12 +1,12 @@
import { AnonymousSettingsPage } from '../po/anonymous-settings.po'
import { LoginPage } from '../po/login.po'
import { MyAccountPage } from '../po/my-account.po'
import { VideoUploadPage } from '../po/video-upload.po'
import { VideoPublishPage } from '../po/video-publish.po'
import { VideoWatchPage } from '../po/video-watch.po'
import { go, isMobileDevice, isSafari, waitServerUp } from '../utils'
describe('User settings', () => {
let videoUploadPage: VideoUploadPage
let videoPublishPage: VideoPublishPage
let loginPage: LoginPage
let videoWatchPage: VideoWatchPage
let myAccountPage: MyAccountPage
@ -16,7 +16,7 @@ describe('User settings', () => {
await waitServerUp()
loginPage = new LoginPage(isMobileDevice())
videoUploadPage = new VideoUploadPage()
videoPublishPage = new VideoPublishPage()
videoWatchPage = new VideoWatchPage(isMobileDevice(), isSafari())
myAccountPage = new MyAccountPage()
anonymousSettingsPage = new AnonymousSettingsPage()
@ -42,10 +42,11 @@ describe('User settings', () => {
before(async () => {
await loginPage.loginAsRootUser()
await videoUploadPage.navigateTo()
await videoUploadPage.uploadVideo('video.mp4')
await videoUploadPage.validSecondUploadStep('video')
await videoPublishPage.navigateTo()
await videoPublishPage.uploadVideo('video.mp4')
await videoPublishPage.validSecondStep('video')
await videoPublishPage.clickOnWatch()
await videoWatchPage.waitWatchVideoName('video')
videoUrl = await browser.getUrl()

View file

@ -2,12 +2,12 @@ import { LoginPage } from '../po/login.po'
import { MyAccountPage } from '../po/my-account.po'
import { PlayerPage } from '../po/player.po'
import { SignupPage } from '../po/signup.po'
import { VideoUploadPage } from '../po/video-upload.po'
import { VideoPublishPage } from '../po/video-publish.po'
import { VideoWatchPage } from '../po/video-watch.po'
import { getScreenshotPath, go, isMobileDevice, isSafari, waitServerUp } from '../utils'
describe('Password protected videos', () => {
let videoUploadPage: VideoUploadPage
let videoPublishPage: VideoPublishPage
let loginPage: LoginPage
let videoWatchPage: VideoWatchPage
let signupPage: SignupPage
@ -44,7 +44,7 @@ describe('Password protected videos', () => {
await waitServerUp()
loginPage = new LoginPage(isMobileDevice())
videoUploadPage = new VideoUploadPage()
videoPublishPage = new VideoPublishPage()
videoWatchPage = new VideoWatchPage(isMobileDevice(), isSafari())
signupPage = new SignupPage()
playerPage = new PlayerPage()
@ -59,9 +59,12 @@ describe('Password protected videos', () => {
})
it('Should login, upload a public video and save it to a playlist', async () => {
await videoUploadPage.navigateTo()
await videoUploadPage.uploadVideo('video.mp4')
await videoUploadPage.validSecondUploadStep(publicVideoName1)
await videoPublishPage.navigateTo()
await videoPublishPage.uploadVideo('video.mp4')
await videoPublishPage.validSecondStep(publicVideoName1)
await videoPublishPage.clickOnWatch()
await videoWatchPage.waitWatchVideoName(publicVideoName1)
await videoWatchPage.clickOnSave()
@ -69,15 +72,15 @@ describe('Password protected videos', () => {
await videoWatchPage.saveToPlaylist(playlistName)
await browser.pause(5000)
})
it('Should upload a password protected video', async () => {
await videoUploadPage.navigateTo()
await videoUploadPage.uploadVideo('video2.mp4')
await videoUploadPage.setAsPasswordProtected(videoPassword)
await videoUploadPage.validSecondUploadStep(passwordProtectedVideoName)
await videoPublishPage.navigateTo()
await videoPublishPage.uploadVideo('video2.mp4')
await videoPublishPage.setAsPasswordProtected(videoPassword)
await videoPublishPage.validSecondStep(passwordProtectedVideoName)
await videoPublishPage.clickOnWatch()
await videoWatchPage.waitWatchVideoName(passwordProtectedVideoName)
passwordProtectedVideoUrl = await browser.getUrl()
@ -89,11 +92,13 @@ describe('Password protected videos', () => {
})
it('Should upload a second public video and save it to playlist', async () => {
await videoUploadPage.navigateTo()
await videoPublishPage.navigateTo()
await videoUploadPage.uploadVideo('video3.mp4')
await videoUploadPage.validSecondUploadStep(publicVideoName2)
await videoPublishPage.uploadVideo('video3.mp4')
await videoPublishPage.validSecondStep(publicVideoName2)
await videoPublishPage.clickOnWatch()
await videoWatchPage.waitWatchVideoName(publicVideoName2)
await videoWatchPage.clickOnSave()
await videoWatchPage.saveToPlaylist(playlistName)
})
@ -143,11 +148,11 @@ describe('Password protected videos', () => {
await myAccountPage.clickOnPlaylist(playlistName)
await myAccountPage.playPlaylist()
await videoWatchPage.waitUntilVideoName(publicVideoName1, 40 * 1000)
await videoWatchPage.waitWatchVideoName(publicVideoName1, 40 * 1000)
playlistUrl = await browser.getUrl()
await videoWatchPage.waitUntilVideoName(passwordProtectedVideoName, 40 * 1000)
await videoWatchPage.waitUntilVideoName(publicVideoName2, 40 * 1000)
await videoWatchPage.waitWatchVideoName(passwordProtectedVideoName, 40 * 1000)
await videoWatchPage.waitWatchVideoName(publicVideoName2, 40 * 1000)
})
after(async () => {
@ -156,7 +161,6 @@ describe('Password protected videos', () => {
})
describe('Regular users', function () {
before(async () => {
await signupPage.fullSignup({
accountInfo: {
@ -192,7 +196,7 @@ describe('Password protected videos', () => {
it('Should watch the playlist without password protected video', async () => {
await go(playlistUrl)
await playerPage.playVideo()
await videoWatchPage.waitUntilVideoName(publicVideoName2, 40 * 1000)
await videoWatchPage.waitWatchVideoName(publicVideoName2, 40 * 1000)
})
after(async () => {
@ -222,7 +226,7 @@ describe('Password protected videos', () => {
it('Should watch the playlist without password protected video', async () => {
await go(playlistUrl)
await playerPage.playVideo()
await videoWatchPage.waitUntilVideoName(publicVideoName2, 40 * 1000)
await videoWatchPage.waitWatchVideoName(publicVideoName2, 40 * 1000)
})
})

View file

@ -3,14 +3,14 @@ import { LoginPage } from '../po/login.po'
import { MyAccountPage } from '../po/my-account.po'
import { VideoListPage } from '../po/video-list.po'
import { VideoSearchPage } from '../po/video-search.po'
import { VideoUploadPage } from '../po/video-upload.po'
import { VideoPublishPage } from '../po/video-publish.po'
import { VideoWatchPage } from '../po/video-watch.po'
import { NSFWPolicy } from '../types/common'
import { isMobileDevice, isSafari, waitServerUp } from '../utils'
describe('Videos list', () => {
let videoListPage: VideoListPage
let videoUploadPage: VideoUploadPage
let videoPublishPage: VideoPublishPage
let adminConfigPage: AdminConfigPage
let loginPage: LoginPage
let myAccountPage: MyAccountPage
@ -87,7 +87,6 @@ describe('Videos list', () => {
}
async function updateAdminNSFW (nsfw: NSFWPolicy) {
await adminConfigPage.navigateTo('instance-information')
await adminConfigPage.updateNSFWSetting(nsfw)
await adminConfigPage.save()
}
@ -105,7 +104,7 @@ describe('Videos list', () => {
videoListPage = new VideoListPage(isMobileDevice(), isSafari())
adminConfigPage = new AdminConfigPage()
loginPage = new LoginPage(isMobileDevice())
videoUploadPage = new VideoUploadPage()
videoPublishPage = new VideoPublishPage()
myAccountPage = new MyAccountPage()
videoSearchPage = new VideoSearchPage()
videoWatchPage = new VideoWatchPage(isMobileDevice(), isSafari())
@ -119,20 +118,19 @@ describe('Videos list', () => {
})
it('Should set the homepage', async () => {
await adminConfigPage.navigateTo('instance-homepage')
await adminConfigPage.updateHomepage('<peertube-videos-list data-sort="-publishedAt"></peertube-videos-list>')
await adminConfigPage.save()
})
it('Should upload 2 videos (NSFW and classic videos)', async () => {
await videoUploadPage.navigateTo()
await videoUploadPage.uploadVideo('video.mp4')
await videoUploadPage.setAsNSFW()
await videoUploadPage.validSecondUploadStep(nsfwVideo)
await videoPublishPage.navigateTo()
await videoPublishPage.uploadVideo('video.mp4')
await videoPublishPage.setAsNSFW()
await videoPublishPage.validSecondStep(nsfwVideo)
await videoUploadPage.navigateTo()
await videoUploadPage.uploadVideo('video2.mp4')
await videoUploadPage.validSecondUploadStep(normalVideo)
await videoPublishPage.navigateTo()
await videoPublishPage.uploadVideo('video2.mp4')
await videoPublishPage.validSecondStep(normalVideo)
})
it('Should logout', async function () {
@ -140,7 +138,6 @@ describe('Videos list', () => {
})
describe('Anonymous users', function () {
it('Should correctly handle do not list', async () => {
await loginPage.loginAsRootUser()
await updateAdminNSFW('do_not_list')
@ -170,7 +167,6 @@ describe('Videos list', () => {
})
describe('Logged in users', function () {
before(async () => {
await loginPage.loginAsRootUser()
})
@ -199,13 +195,13 @@ describe('Videos list', () => {
})
describe('Default upload values', function () {
it('Should have default video values', async function () {
await loginPage.loginAsRootUser()
await videoUploadPage.navigateTo()
await videoUploadPage.uploadVideo('video3.mp4')
await videoUploadPage.validSecondUploadStep('video')
await videoPublishPage.navigateTo()
await videoPublishPage.uploadVideo('video3.mp4')
await videoPublishPage.validSecondStep('video')
await videoPublishPage.clickOnWatch()
await videoWatchPage.waitWatchVideoName('video')
expect(await videoWatchPage.getPrivacy()).toBe('Public')

View file

@ -1,15 +1,41 @@
async function getCheckbox (name: string) {
export async function getCheckbox (name: string) {
const input = $(`my-peertube-checkbox input[id=${name}]`)
await input.waitForExist()
return input.parentElement()
}
function isCheckboxSelected (name: string) {
export function isCheckboxSelected (name: string) {
return $(`input[id=${name}]`).isSelected()
}
async function selectCustomSelect (id: string, valueLabel: string) {
export async function setCheckboxEnabled (name: string, enabled: boolean) {
if (await isCheckboxSelected(name) === enabled) return
const checkbox = await getCheckbox(name)
await checkbox.waitForClickable()
await checkbox.click()
}
// ---------------------------------------------------------------------------
export async function isRadioSelected (name: string) {
await $(`input[id=${name}] + label`).waitForClickable()
return $(`input[id=${name}]`).isSelected()
}
export async function clickOnRadio (name: string) {
const label = $(`input[id=${name}] + label`)
await label.waitForClickable()
await label.click()
}
// ---------------------------------------------------------------------------
export async function selectCustomSelect (id: string, valueLabel: string) {
const wrapper = $(`[formcontrolname=${id}] span[role=combobox]`)
await wrapper.waitForClickable()
@ -26,7 +52,7 @@ async function selectCustomSelect (id: string, valueLabel: string) {
return option.click()
}
async function findParentElement (
export async function findParentElement (
el: WebdriverIO.Element,
finder: (el: WebdriverIO.Element) => Promise<boolean>
) {
@ -34,10 +60,3 @@ async function findParentElement (
return findParentElement(await el.parentElement(), finder)
}
export {
getCheckbox,
isCheckboxSelected,
selectCustomSelect,
findParentElement
}

View file

@ -32,6 +32,15 @@ module.exports = {
prefs
}
}
// {
// 'browserName': 'firefox',
// 'moz:firefoxOptions': {
// binary: '/usr/bin/firefox-developer-edition',
// args: [ '--headless', windowSizeArg ],
// prefs
// }
// }
],
services: [ 'shared-store' ],

View file

@ -117,6 +117,7 @@
"tinykeys": "^2.1.0",
"ts-node": "^10.9.2",
"tslib": "^2.4.0",
"type-fest": "^4.37.0",
"typescript": "~5.7.3",
"video.js": "^7.19.2",
"vite": "^6.0.11",

View file

@ -439,23 +439,8 @@ export class EditCustomConfigComponent extends FormReactive implements OnInit {
this.router.navigate([], { fragment: this.activeNav })
}
grabAllErrors (errorObjectArg?: any) {
const errorObject = errorObjectArg || this.formErrors
let acc: string[] = []
for (const key of Object.keys(errorObject)) {
const value = errorObject[key]
if (!value) continue
if (typeof value === 'string') {
acc.push(value)
} else {
acc = acc.concat(this.grabAllErrors(value))
}
}
return acc
grabAllErrors () {
return this.formReactiveService.grabAllErrors(this.formErrors)
}
private updateForm () {

View file

@ -19,7 +19,7 @@
inputId="instanceCustomHomepageContent" formControlName="content"
[customMarkdownRenderer]="getCustomMarkdownRenderer()" [debounceTime]="500"
[formError]="formErrors()['instanceCustomHomepage.content']"
dir="ltr"
dir="ltr" monospace="true"
></my-markdown-textarea>
<div *ngIf="formErrors().instanceCustomHomepage.content" class="form-error" role="alert">{{ formErrors().instanceCustomHomepage.content }}</div>

View file

@ -46,10 +46,13 @@
</td>
<td>
<my-video-cell [video]="videoBlock.video">
<span name>
<my-global-icon *ngIf="videoBlock.type === 2" i18n-title title="The video was blocked due to automatic blocking of new videos" iconName="robot"></my-global-icon>
</span>
<my-video-cell [video]="videoBlock.video" size="small">
<div>
<span
*ngIf="videoBlock.type === 2"
i18n-title title="The video was blocked due to automatic blocking of new videos" class="pt-badge badge-info" i18n
>Auto block</span>
</div>
</my-video-cell>
</td>

View file

@ -14,7 +14,6 @@ import { TableModule } from 'primeng/table'
import { switchMap } from 'rxjs/operators'
import { environment } from 'src/environments/environment'
import { AdvancedInputFilter, AdvancedInputFilterComponent } from '../../../shared/shared-forms/advanced-input-filter.component'
import { GlobalIconComponent } from '../../../shared/shared-icons/global-icon.component'
import { ActionDropdownComponent, DropdownAction } from '../../../shared/shared-main/buttons/action-dropdown.component'
import { AutoColspanDirective } from '../../../shared/shared-main/common/auto-colspan.directive'
import { EmbedComponent } from '../../../shared/shared-main/video/embed.component'
@ -26,7 +25,6 @@ import { VideoCellComponent } from '../../../shared/shared-tables/video-cell.com
templateUrl: './video-block-list.component.html',
styleUrls: [ '../../../shared/shared-moderation/moderation.scss' ],
imports: [
GlobalIconComponent,
TableModule,
SharedModule,
AdvancedInputFilterComponent,

View file

@ -12,7 +12,7 @@ import {
RestPagination,
RestTable
} from '@app/core'
import { formatICU, getAPIHost } from '@app/helpers'
import { formatICU, getBackendHost } from '@app/helpers'
import { Actor } from '@app/shared/shared-main/account/actor.model'
import { PTDatePipe } from '@app/shared/shared-main/common/date.pipe'
import { ProgressBarComponent } from '@app/shared/shared-main/common/progress-bar.component'
@ -373,10 +373,10 @@ export class UserListComponent extends RestTable<User> implements OnInit, OnDest
}
private loadMutedStatus () {
this.blocklist.getStatus({ accounts: this.users.map(u => u.username + '@' + getAPIHost()) })
this.blocklist.getStatus({ accounts: this.users.map(u => u.username + '@' + getBackendHost()) })
.subscribe(blockStatus => {
for (const user of this.users) {
user.accountMutedStatus.mutedByInstance = blockStatus.accounts[user.username + '@' + getAPIHost()].blockedByServer
user.accountMutedStatus.mutedByInstance = blockStatus.accounts[user.username + '@' + getBackendHost()].blockedByServer
}
})
}

View file

@ -61,7 +61,7 @@
</td>
<td>
<my-video-cell [video]="video"></my-video-cell>
<my-video-cell [video]="video" size="small"></my-video-cell>
</td>
<td>
@ -72,7 +72,7 @@
<span class="pt-badge badge-purple" i18n>Remote</span>
}
<span [ngClass]="getPrivacyBadgeClass(video)" class="pt-badge">{{ video.privacy.label }}</span>
<my-video-privacy-badge [video]="video"></my-video-privacy-badge>
<span *ngIf="video.nsfw" class="pt-badge badge-red" i18n>NSFW</span>

View file

@ -7,7 +7,8 @@ my-embed {
width: 50%;
}
.pt-badge {
.pt-badge,
my-video-privacy-badge {
@include margin-right(5px);
}

View file

@ -13,7 +13,7 @@ import { VideoBlockComponent } from '@app/shared/shared-moderation/video-block.c
import { VideoBlockService } from '@app/shared/shared-moderation/video-block.service'
import { NgbTooltip } from '@ng-bootstrap/ng-bootstrap'
import { getAllFiles } from '@peertube/peertube-core-utils'
import { FileStorage, UserRight, VideoFile, VideoPrivacy, VideoState, VideoStreamingPlaylistType } from '@peertube/peertube-models'
import { FileStorage, UserRight, VideoFile, VideoState, VideoStreamingPlaylistType } from '@peertube/peertube-models'
import { videoRequiresFileToken } from '@root-helpers/video'
import { SharedModule, SortMeta } from 'primeng/api'
import { TableModule, TableRowExpandEvent } from 'primeng/table'
@ -31,6 +31,7 @@ import {
VideoActionsDisplayType,
VideoActionsDropdownComponent
} from '../../../shared/shared-video-miniature/video-actions-dropdown.component'
import { VideoPrivacyBadgeComponent } from '../../../shared/shared-video/video-privacy-badge.component'
import { VideoAdminService } from './video-admin.service'
@Component({
@ -56,7 +57,8 @@ import { VideoAdminService } from './video-admin.service'
VideoBlockComponent,
PTDatePipe,
RouterLink,
BytesPipe
BytesPipe,
VideoPrivacyBadgeComponent
]
})
export class VideoListComponent extends RestTable<Video> implements OnInit {
@ -180,12 +182,6 @@ export class VideoListComponent extends RestTable<Video> implements OnInit {
return 'VideoListComponent'
}
getPrivacyBadgeClass (video: Video) {
if (video.privacy.id === VideoPrivacy.PUBLIC) return 'badge-green'
return 'badge-yellow'
}
isUnpublished (video: Video) {
return video.state.id !== VideoState.LIVE_ENDED && video.state.id !== VideoState.PUBLISHED
}

View file

@ -6,7 +6,7 @@
</h2>
<form *ngIf="hasRegisteredSettings()" (ngSubmit)="formValidated()" [formGroup]="form">
<div class="form-group" *ngFor="let setting of registeredSettings" [id]="getWrapperId(setting)">
<div *ngFor="let setting of registeredSettings" [id]="getWrapperId(setting)">
<my-dynamic-form-field [hidden]="isSettingHidden(setting)" [form]="form" [setting]="setting" [formErrors]="formErrors"></my-dynamic-form-field>
</div>

View file

@ -30,7 +30,7 @@
<my-help helpType="markdownText" supportRelMe="true"></my-help>
<my-markdown-textarea
inputId="description" formControlName="description" class="form-control"
inputId="description" formControlName="description"
markdownType="enhanced" [formError]="formErrors['description']" withEmoji="true" withHtml="true"
></my-markdown-textarea>

View file

@ -0,0 +1,12 @@
<div class="root">
<div class="margin-content">
<a i18n class="visually-hidden-focusable skip-to-content-sub-menu" href="#my-channel-space-content" (click)="$event.preventDefault(); myChannelSpaceContent.focus()">Skip the sub menu</a>
<my-horizontal-menu h1Icon="channel" i18n-h1 h1="My channels" [menuEntries]="menuEntries"></my-horizontal-menu>
</div>
<div #myChannelSpaceContent tabindex="-1" id="my-channel-space-content" class="margin-content pb-5 outline-0">
<router-outlet></router-outlet>
</div>
</div>

View file

@ -0,0 +1,48 @@
import { Component, OnInit, inject } from '@angular/core'
import { RouterOutlet } from '@angular/router'
import { ServerService } from '@app/core'
import { HorizontalMenuComponent, HorizontalMenuEntry } from '@app/shared/shared-main/menu/horizontal-menu.component'
import { HTMLServerConfig } from '@peertube/peertube-models'
@Component({
templateUrl: './my-channel-space.component.html',
imports: [ RouterOutlet, HorizontalMenuComponent ]
})
export class MyChannelSpaceComponent implements OnInit {
private serverService = inject(ServerService)
menuEntries: HorizontalMenuEntry[] = []
private serverConfig: HTMLServerConfig
ngOnInit (): void {
this.serverConfig = this.serverService.getHTMLConfig()
this.buildMenu()
}
private buildMenu () {
this.menuEntries = [
{
label: $localize`Manage my channels`,
routerLink: '/my-library/video-channels'
},
{
label: $localize`Followers`,
routerLink: '/my-library/followers'
},
{
label: $localize`Synchronizations`,
routerLink: '/my-library/video-channel-syncs',
isDisplayed: () => this.isChannelSyncEnabled()
}
]
}
private isChannelSyncEnabled () {
const config = this.serverConfig.import
return this.serverConfig.import.videoChannelSynchronization.enabled &&
(config.videos.http.enabled || config.videos.torrent.enabled)
}
}

View file

@ -36,7 +36,7 @@
</td>
<td>
<my-video-cell [video]="videoChangeOwnership.video"></my-video-cell>
<my-video-cell [video]="videoChangeOwnership.video" size="small"></my-video-cell>
</td>
<td>{{ videoChangeOwnership.createdAt | ptDate: 'short' }}</td>

View file

@ -3,7 +3,7 @@
<div class="margin-content">
<a i18n class="visually-hidden-focusable skip-to-content-sub-menu" href="#my-video-space-content" (click)="$event.preventDefault(); myVideoSpaceContent.focus()">Skip the sub menu</a>
<my-horizontal-menu i18n-h1 h1="My video space" [menuEntries]="menuEntries"></my-horizontal-menu>
<my-horizontal-menu h1Icon="videos" i18n-h1 h1="My videos" [menuEntries]="menuEntries"></my-horizontal-menu>
</div>
<div #myVideoSpaceContent tabindex="-1" id="my-video-space-content" class="margin-content pb-5 outline-0">

View file

@ -21,55 +21,19 @@ export class MyVideoSpaceComponent implements OnInit {
this.buildMenu()
}
isVideoImportEnabled () {
const importConfig = this.serverConfig.import.videos
return importConfig.http.enabled || importConfig.torrent.enabled
}
private buildMenu () {
this.menuEntries = [
{
label: $localize`Channels`,
routerLink: '/my-library/video-channels',
children: [
{
label: $localize`Manage`,
routerLink: '/my-library/video-channels'
},
{
label: $localize`Followers`,
routerLink: '/my-library/followers'
},
{
label: $localize`Synchronizations`,
routerLink: '/my-library/video-channel-syncs'
}
]
label: $localize`Manage my videos`,
routerLink: '/my-library/videos'
},
{
label: $localize`Videos`,
routerLink: '/my-library/videos',
label: $localize`Comments`,
routerLink: '/my-library/comments-on-my-videos',
children: [
{
label: $localize`Manage`,
routerLink: '/my-library/videos'
},
{
label: $localize`Imports`,
routerLink: '/my-library/video-imports',
isDisplayed: () => this.isVideoImportEnabled()
},
{
label: $localize`Ownership changes`,
routerLink: '/my-library/ownership'
},
{
label: $localize`Comments`,
label: $localize`Comments on my videos`,
routerLink: '/my-library/comments-on-my-videos'
},
{
@ -81,7 +45,29 @@ export class MyVideoSpaceComponent implements OnInit {
routerLink: '/my-library/auto-tag-policies'
}
]
},
{
label: $localize`Settings`,
routerLink: '/my-library/video-imports',
children: [
{
label: $localize`Imports`,
routerLink: '/my-library/video-imports',
isDisplayed: () => this.isVideoImportEnabled()
},
{
label: $localize`Ownership changes`,
routerLink: '/my-library/ownership'
}
]
}
]
}
private isVideoImportEnabled () {
const config = this.serverConfig.import.videos
return config.http.enabled || config.torrent.enabled
}
}

View file

@ -1,45 +1,199 @@
<div class="videos-header d-flex justify-content-between align-items-end gap-2 flex-wrap">
<my-advanced-input-filter [emitOnInit]="false" [filters]="inputFilters" (search)="onSearch($event)"></my-advanced-input-filter>
<div class="channel-filters">
<div class="channels-label" i18n>Per channel:</div>
<span class="total-items" *ngIf="pagination.totalItems"> {{ getTotalTitle() }}</span>
<div class="peertube-select-container">
<select [(ngModel)]="sort" (ngModelChange)="onChangeSortColumn()" class="form-control" i18n-ariaLabel aria-label="Sort by">
<option value="-publishedAt" i18n>Last published first</option>
<option value="-createdAt" i18n>Last created first</option>
<option value="-views" i18n>Most viewed first</option>
<option value="-likes" i18n>Most liked first</option>
<option value="-duration" i18n>Longest first</option>
</select>
</div>
@for (channel of channels; track channel) {
<my-channel-toggle [channel]="channel" [(ngModel)]="channel.selected" (ngModelChange)="onFilter()"></my-channel-toggle>
}
</div>
<my-videos-selection
[videosContainedInPlaylists]="videosContainedInPlaylists"
[pagination]="pagination"
[(selection)]="selection"
[(videos)]="videos"
[miniatureDisplayOptions]="miniatureDisplayOptions"
[titlePage]="titlePage"
[getVideosObservableFunction]="getVideosObservableFunction"
[user]="user"
[disabled]="disabled"
#videosSelection
<p-table
[value]="videos" [paginator]="totalRecords > 0" [totalRecords]="totalRecords" [rows]="rowsPerPage" [first]="pagination.start"
[rowsPerPageOptions]="rowsPerPageOptions" [sortField]="sort.field" [sortOrder]="sort.order" dataKey="id" [resizableColumns]="true"
[(selection)]="selectedRows" [lazy]="true" (onLazyLoad)="loadLazy($event)" [lazyLoadOnInit]="false" [selectionPageOnly]="true"
[showCurrentPageReport]="true" [currentPageReportTemplate]="getPaginationTemplate()"
[expandedRowKeys]="expandedRows" [ngClass]="{ loading: loading }"
class="new-p-table sticky-p-table cell-wrap"
>
<ng-template ptTemplate="globalButtons">
<my-delete-button class="delete-selection" (click)="deleteSelectedVideos()"></my-delete-button>
</ng-template>
<ng-template pTemplate="caption">
<div class="caption">
<div class="left-buttons">
@if (isInSelectionMode()) {
<my-action-dropdown
i18n-label label="Batch actions" theme="primary"
[actions]="bulkActions" [entry]="selectedRows"
>
</my-action-dropdown>
} @else {
<strong i18n [hidden]="loading">
{ totalRecords, plural, =0 {No videos} =1 {1 video} other {{{ totalRecords | myNumberFormatter }} videos}}
</strong>
}
</div>
<ng-template ptTemplate="rowButtons" let-video>
<div class="action-button">
<my-edit-button label [ptRouterLink]="[ '/videos', 'update', video.shortUUID ]"></my-edit-button>
<div class="ms-auto right-form">
<div class="d-flex align-items-center">
<label class="label-inline d-none d-inline-block-mw" for="table-search" i18n>Search</label>
<my-advanced-input-filter inputId="table-search" icon="true" i18n-placeholder placeholder="Search your videos" (search)="onSearch($event)"></my-advanced-input-filter>
</div>
<div class="d-flex align-items-center">
<label class="label-inline" for="table-filter" i18n>Filter</label>
<my-select-checkbox
inputId="table-filter"
showToggleAll="false"
showClear="true"
[availableItems]="filterItems"
[selectableGroup]="false"
[(ngModel)]="selectedFilterItems"
i18n-placeholder placeholder="All videos"
(ngModelChange)="onFilter()"
>
</my-select-checkbox>
</div>
</div>
<my-video-actions-dropdown
[video]="video" [displayOptions]="videoDropdownDisplayOptions" [moreActions]="moreVideoActions"
buttonStyled="true" buttonDirection="horizontal" (videoRemoved)="onVideoRemoved(video)"
></my-video-actions-dropdown>
</div>
</ng-template>
</my-videos-selection>
<ng-template pTemplate="sorticon" let-sortOrder>
@if (sortOrder === 1) {
<my-button class="ms-2" small="true" active="true" rounded="true" theme="tertiary" icon="chevron-up"></my-button>
} @else if (sortOrder === -1) {
<my-button class="ms-2" small="true" active="true" rounded="true" theme="tertiary" icon="chevron-down"></my-button>
} @else if (sortOrder === 0) {
<my-button class="ms-2" small="true" rounded="true" theme="tertiary" icon="chevron-down"></my-button>
}
</ng-template>
<ng-template pTemplate="paginatorright" let-item>
<ng-container i18n>videos per page</ng-container>
</ng-template>
<ng-template pTemplate="header">
<tr>
<th scope="col" width="25px" class="checkbox-cell">
<p-tableHeaderCheckbox ariaLabel="Select all rows" i18n-ariaLabel></p-tableHeaderCheckbox>
</th>
<th scope="col" *ngIf="isSelected('duration')" [ngbTooltip]="sortTooltip" container="body" pSortableColumn="duration" i18n>{{ getColumn('duration').label }} <p-sortIcon field="duration"></p-sortIcon></th>
<th scope="col" *ngIf="isSelected('name')" [ngbTooltip]="sortTooltip" container="body" pSortableColumn="name" i18n>{{ getColumn('name').label }} <p-sortIcon field="name"></p-sortIcon></th>
<th scope="col" *ngIf="isSelected('privacy')" i18n>{{ getColumn('privacy').label }}</th>
<th scope="col" *ngIf="isSelected('sensitive')" i18n>{{ getColumn('sensitive').label }}</th>
<th scope="col" *ngIf="isSelected('playlists')" i18n>{{ getColumn('playlists').label }}</th>
<th scope="col" *ngIf="isSelected('insights')" [ngbTooltip]="sortTooltip" container="body" pSortableColumn="views" i18n>{{ getColumn('insights').label }} <p-sortIcon field="views"></p-sortIcon></th>
<th scope="col" *ngIf="isSelected('published')" i18n [ngbTooltip]="sortTooltip" container="body" pSortableColumn="publishedAt">{{ getColumn('published').label }} <p-sortIcon field="publishedAt"></p-sortIcon></th>
<th scope="col" *ngIf="isSelected('state')" i18n>{{ getColumn('state').label }}</th>
<th scope="col" width="250px" class="action-head">
<div class="d-flex align-items-center justify-content-between">
<span i18>Actions</span>
<span class="c-hand column-toggle" >
<div ngbDropdown placement="bottom-left auto" container="body" autoClose="outside">
<my-button theme="tertiary" icon="columns" ngbDropdownToggle i18n-title title="Open table configuration"></my-button>
<div ngbDropdownMenu class="p-3">
<div class="form-group">
<div class="muted small mb-2" i18n>Column displayed:</div>
@for (column of columns; track column) {
<div class="ms-1 mb-1">
<my-peertube-checkbox
[inputName]="'column-' + column.id" [(ngModel)]="column.selected"
i18n-labelText [labelText]="column.label"
(ngModelChange)="saveSelectedColumns()"
></my-peertube-checkbox>
</div>
}
</div>
</div>
</div>
</span>
</div>
</th>
</tr>
</ng-template>
<ng-template pTemplate="body" let-video>
<tr [pSelectableRow]="video">
<td class="checkbox-cell">
<p-tableCheckbox [value]="video" ariaLabel="Select this row" i18n-ariaLabel></p-tableCheckbox>
</td>
<td *ngIf="isSelected('duration')">
<my-video-cell [video]="video" thumbnail="true" title="false"></my-video-cell>
</td>
<td *ngIf="isSelected('name')">
<my-video-cell class="video-cell-name" [video]="video" thumbnail="false" title="true"></my-video-cell>
</td>
<td *ngIf="isSelected('privacy')">
<my-video-privacy-badge [video]="video"></my-video-privacy-badge>
<span *ngIf="video.blacklisted" class="pt-badge badge-red ms-1" i18n>Blocked</span>
</td>
<td *ngIf="isSelected('sensitive')">
<span *ngIf="video.nsfw" class="pt-badge badge-yellow" i18n>NSFW</span>
</td>
<td *ngIf="isSelected('playlists')">
<div class="badges">
<a *ngFor="let playlist of (videosContainedInPlaylists[video.id] || [])" class="pt-badge badge-secondary" [routerLink]="['/w/p/', playlist.playlistShortUUID]">
{{ playlist.playlistDisplayName }}
</a>
</div>
</td>
<td *ngIf="isSelected('insights')">
<a class="pt" [routerLink]="[ '/videos/manage', video.shortUUID, 'stats' ]" i18n-title title="Navigate to the video stats page">
<ng-container i18n>{video.views, plural, =0 {No views} =1 {1 view} other {{{ video.views | myNumberFormatter }} views}}</ng-container>
<br />
@if (video.isLive) {
<ng-container i18n>{video.viewers, plural, =0 {No viewers} =1 {1 viewer} other {{{ video.views | myNumberFormatter }} viewers}}</ng-container>
}
</a>
</td>
<td *ngIf="isSelected('published')">
{{ video.publishedAt | ptDate: 'short' }}
</td>
<td *ngIf="isSelected('state')">
<my-video-state-badge [video]="video"></my-video-state-badge>
</td>
<td class="action-cell">
<div class="d-flex justify-content-end">
<my-button class="me-3" i18n-label label="Manage" [ptRouterLink]="[ '/videos', 'manage', video.shortUUID ]" theme="secondary" icon="film" responsiveLabel="true"></my-button>
<my-video-actions-dropdown
placement="bottom auto" buttonDirection="horizontal" buttonStyled="true" [video]="video" [displayOptions]="videoActionsOptions"
(videoRemoved)="reloadData()" (videoFilesRemoved)="reloadData()" (transcodingCreated)="reloadData()"
></my-video-actions-dropdown>
</div>
</td>
</tr>
</ng-template>
<ng-template pTemplate="emptymessage">
<tr>
<td myAutoColspan>
<div class="no-results">
<ng-container *ngIf="search" i18n>No video found matching current filters.</ng-container>
<ng-container *ngIf="!search" i18n>You don't have any videos published yet.</ng-container>
</div>
</td>
</tr>
</ng-template>
</p-table>
<my-video-change-ownership #videoChangeOwnershipModal></my-video-change-ownership>

View file

@ -1,30 +1,71 @@
@use '_variables' as *;
@use '_mixins' as *;
@use '_form-mixins' as *;
input[type=text] {
@include peertube-input-text(300px);
my-select-checkbox {
min-width: 250px;
}
.peertube-select-container {
@include peertube-select-container(auto);
.pt-badge {
@include margin-right(5px);
}
.action-button {
.video-info > div {
display: flex;
align-self: flex-end;
min-width: 200px;
justify-content: flex-end;
@include margin-left(10px);
}
my-global-icon {
position: relative;
top: -2px;
my-edit-button {
@include margin-right(10px);
}
@include on-small-main-col {
.action-button {
margin: 1rem auto 0;
@include global-icon-size(16px);
@include margin-left(3px);
}
}
.right-form {
display: flex;
flex-wrap: wrap;
gap: 1.5rem;
}
td {
padding-top: 0.75rem;
padding-bottom: 0.75rem;
}
.channel-filters {
display: flex;
align-items: center;
padding-bottom: 1rem;
border-bottom: 1px solid $separator-border-color;
margin-bottom: 1rem;
gap: 1rem;
overflow: hidden;
&:hover {
overflow: auto;
}
.channels-label {
color: pvar(--fg-200);
font-weight: $font-bold;
white-space: nowrap;
}
}
p-table {
max-height: calc(100vh - #{pvar(--header-height)} - 300px);
}
@media screen and (min-width: $small-view) {
.video-cell-name {
width: 300px;
display: block;
}
}
@media screen and (max-width: $small-view) {
my-video-cell {
max-width: 120px;
display: block;
}
}

View file

@ -1,91 +1,118 @@
import { NgIf } from '@angular/common'
import { Component, OnInit, inject, viewChild } from '@angular/core'
import { CommonModule, NgClass, NgIf } from '@angular/common'
import { Component, inject, OnDestroy, OnInit, viewChild } from '@angular/core'
import { FormsModule } from '@angular/forms'
import { ActivatedRoute, Router } from '@angular/router'
import { ActivatedRoute, Router, RouterLink } from '@angular/router'
import {
AuthService,
ComponentPagination,
AuthUser,
ConfirmService,
LocalStorageService,
Notifier,
ScreenService,
ServerService,
updatePaginationOnDelete,
User
PeerTubeRouterService,
RestPagination,
RestTable,
ServerService
} from '@app/core'
import { DisableForReuseHook } from '@app/core/routing/disable-for-reuse-hook'
import { formatICU, immutableAssign } from '@app/helpers'
import { DropdownAction } from '@app/shared/shared-main/buttons/action-dropdown.component'
import { DeleteButtonComponent } from '@app/shared/shared-main/buttons/delete-button.component'
import { HeaderService } from '@app/header/header.service'
import { formatICU } from '@app/helpers'
import { AutoColspanDirective } from '@app/shared/shared-main/common/auto-colspan.directive'
import { Video } from '@app/shared/shared-main/video/video.model'
import { VideoService } from '@app/shared/shared-main/video/video.service'
import { LiveStreamInformationComponent } from '@app/shared/shared-video-live/live-stream-information.component'
import { MiniatureDisplayOptions } from '@app/shared/shared-video-miniature/video-miniature.component'
import { SelectionType, VideosSelectionComponent } from '@app/shared/shared-video-miniature/videos-selection.component'
import { VideoPlaylistService } from '@app/shared/shared-video-playlist/video-playlist.service'
import { VideoChannel, VideoExistInPlaylist, VideosExistInPlaylists, VideoSortField } from '@peertube/peertube-models'
import { uniqBy } from 'lodash-es'
import { concat, Observable } from 'rxjs'
import { tap, toArray } from 'rxjs/operators'
import { AdvancedInputFilter, AdvancedInputFilterComponent } from '../../shared/shared-forms/advanced-input-filter.component'
import { EditButtonComponent } from '../../shared/shared-main/buttons/edit-button.component'
import { PeerTubeTemplateDirective } from '../../shared/shared-main/common/peertube-template.directive'
import { ChannelToggleComponent } from '@app/shared/standalone-channels/channel-toggle.component'
import { NgbDropdownModule, NgbTooltip } from '@ng-bootstrap/ng-bootstrap'
import { arrayify } from '@peertube/peertube-core-utils'
import {
UserRight,
VideoChannel,
VideoExistInPlaylist,
VideoPrivacy,
VideoPrivacyType,
VideosExistInPlaylists
} from '@peertube/peertube-models'
import { logger } from '@root-helpers/logger'
import uniqBy from 'lodash-es/uniqBy'
import { SharedModule, SortMeta } from 'primeng/api'
import { TableModule } from 'primeng/table'
import { finalize } from 'rxjs/operators'
import { SelectOptionsItem } from 'src/types'
import { AdvancedInputFilterComponent } from '../../shared/shared-forms/advanced-input-filter.component'
import { PeertubeCheckboxComponent } from '../../shared/shared-forms/peertube-checkbox.component'
import { SelectCheckboxComponent } from '../../shared/shared-forms/select/select-checkbox.component'
import { ActionDropdownComponent, DropdownAction } from '../../shared/shared-main/buttons/action-dropdown.component'
import { ButtonComponent } from '../../shared/shared-main/buttons/button.component'
import { PTDatePipe } from '../../shared/shared-main/common/date.pipe'
import { NumberFormatterPipe } from '../../shared/shared-main/common/number-formatter.pipe'
import { VideoCellComponent } from '../../shared/shared-tables/video-cell.component'
import {
VideoActionsDisplayType,
VideoActionsDropdownComponent
} from '../../shared/shared-video-miniature/video-actions-dropdown.component'
import { VideoPrivacyBadgeComponent } from '../../shared/shared-video/video-privacy-badge.component'
import { VideoStateBadgeComponent } from '../../shared/shared-video/video-state-badge.component'
import { VideoChangeOwnershipComponent } from './modals/video-change-ownership.component'
type Column = 'duration' | 'name' | 'privacy' | 'sensitive' | 'playlists' | 'insights' | 'published' | 'state'
type CommonFilter = 'live' | 'vod' | 'private' | 'internal' | 'unlisted' | 'password-protected' | 'public'
@Component({
selector: 'my-videos',
templateUrl: './my-videos.component.html',
styleUrls: [ './my-videos.component.scss' ],
imports: [
DeleteButtonComponent,
NgIf,
AdvancedInputFilterComponent,
CommonModule,
FormsModule,
VideosSelectionComponent,
PeerTubeTemplateDirective,
EditButtonComponent,
TableModule,
NgClass,
SharedModule,
NgIf,
ActionDropdownComponent,
AdvancedInputFilterComponent,
ButtonComponent,
NgbTooltip,
VideoActionsDropdownComponent,
VideoChangeOwnershipComponent
VideoCellComponent,
RouterLink,
NumberFormatterPipe,
VideoChangeOwnershipComponent,
VideoPrivacyBadgeComponent,
VideoStateBadgeComponent,
NgbDropdownModule,
PeertubeCheckboxComponent,
ChannelToggleComponent,
AutoColspanDirective,
SelectCheckboxComponent,
PTDatePipe
]
})
export class MyVideosComponent implements OnInit, DisableForReuseHook {
protected router = inject(Router)
protected serverService = inject(ServerService)
export class MyVideosComponent extends RestTable<Video> implements OnInit, OnDestroy {
protected route = inject(ActivatedRoute)
protected authService = inject(AuthService)
protected notifier = inject(Notifier)
protected screenService = inject(ScreenService)
protected router = inject(Router)
private confirmService = inject(ConfirmService)
private auth = inject(AuthService)
private notifier = inject(Notifier)
private videoService = inject(VideoService)
private playlistService = inject(VideoPlaylistService)
private server = inject(ServerService)
private peertubeLocalStorage = inject(LocalStorageService)
private peertubeRouter = inject(PeerTubeRouterService)
private headerService = inject(HeaderService)
private static readonly LS_SELECTED_COLUMNS_KEY = 'user-my-videos-selected-columns'
readonly videosSelection = viewChild<VideosSelectionComponent>('videosSelection')
readonly videoChangeOwnershipModal = viewChild<VideoChangeOwnershipComponent>('videoChangeOwnershipModal')
readonly liveStreamInformationModal = viewChild<LiveStreamInformationComponent>('liveStreamInformationModal')
videos: Video[] = []
totalRecords = 0
sort: SortMeta = { field: 'publishedAt', order: -1 }
pagination: RestPagination = { count: this.rowsPerPage, start: 0 }
videosContainedInPlaylists: VideosExistInPlaylists = {}
titlePage: string
selection: SelectionType = {}
pagination: ComponentPagination = {
currentPage: 1,
itemsPerPage: 10,
totalItems: null
}
miniatureDisplayOptions: MiniatureDisplayOptions = {
date: true,
views: true,
by: true,
privacyLabel: false,
privacyText: true,
state: true,
blacklistInfo: true,
forceChannelInBy: true,
nsfw: true
}
videoDropdownDisplayOptions: VideoActionsDisplayType = {
bulkActions: DropdownAction<Video[]>[][] = []
videoActionsOptions: VideoActionsDisplayType = {
playlist: true,
download: true,
update: false,
@ -102,97 +129,214 @@ export class MyVideosComponent implements OnInit, DisableForReuseHook {
}
moreVideoActions: DropdownAction<{ video: Video }>[][] = []
loading = true
videos: Video[] = []
getVideosObservableFunction = this.getVideosObservable.bind(this)
user: AuthUser
channels: (VideoChannel & { selected: boolean })[] = []
sort: VideoSortField = '-publishedAt'
filterItems: SelectOptionsItem<CommonFilter>[] = []
selectedFilterItems: CommonFilter[] = []
user: User
columns: { id: Column, label: string, selected: boolean }[] = []
inputFilters: AdvancedInputFilter[] = []
disabled = false
private search: string
private userChannels: VideoChannel[] = []
constructor () {
this.titlePage = $localize`My videos`
get serverConfig () {
return this.server.getHTMLConfig()
}
ngOnInit () {
this.buildActions()
this.initialize()
this.user = this.authService.getUser()
this.headerService.setSearchHidden(true)
if (this.route.snapshot.queryParams['search']) {
this.search = this.route.snapshot.queryParams['search']
}
this.user = this.auth.getUser()
this.user = this.authService.getUser()
this.userChannels = this.user.videoChannels
this.columns = [
{ id: 'duration', label: $localize`Duration`, selected: true },
{ id: 'name', label: $localize`Name`, selected: true },
{ id: 'privacy', label: $localize`Privacy`, selected: true },
{ id: 'sensitive', label: $localize`Sensitive`, selected: true },
{ id: 'playlists', label: $localize`Playlists`, selected: true },
{ id: 'insights', label: $localize`Insights`, selected: true },
{ id: 'published', label: $localize`Published`, selected: true },
{ id: 'state', label: $localize`State`, selected: true }
]
const channelFilters = [ ...this.userChannels ]
.sort((a, b) => a.displayName.localeCompare(b.displayName))
.map(c => {
return {
value: 'channel:' + c.name,
label: c.displayName
}
})
this.inputFilters = [
this.filterItems = [
{
title: $localize`Advanced filters`,
children: [
{
value: 'isLive:true',
label: $localize`Only live videos`
}
]
id: 'live',
label: $localize`Lives`
},
{
title: $localize`Channel filters`,
children: channelFilters
id: 'vod',
label: $localize`VOD`
},
{
id: 'public',
label: $localize`Public videos`
},
{
id: 'internal',
label: $localize`Internal videos`
},
{
id: 'unlisted',
label: $localize`Unlisted videos`
},
{
id: 'password-protected',
label: $localize`Password protected videos`
},
{
id: 'private',
label: $localize`Private videos`
}
]
this.parseQueryParams()
this.buildActions()
this.loadSelectedColumns()
}
onSearch (search: string) {
this.search = search
this.reloadData()
ngOnDestroy () {
this.headerService.setSearchHidden(false)
}
reloadData () {
this.videosSelection().reloadVideos()
private parseQueryParams () {
const queryParams = this.route.snapshot.queryParams as {
channelNameOneOf?: string[]
privacyOneOf?: string[]
isLive: string
}
{
const enabledChannels = queryParams.channelNameOneOf
? new Set(arrayify(queryParams.channelNameOneOf))
: new Set<string>()
this.user = this.auth.getUser()
this.channels = this.user.videoChannels.map(c => ({
...c,
selected: enabledChannels.has(c.name)
}))
}
{
if (queryParams.isLive === 'true') this.selectedFilterItems.push('live')
if (queryParams.isLive === 'false') this.selectedFilterItems.push('vod')
const enabledPrivacies = queryParams.privacyOneOf
? new Set(arrayify(queryParams.privacyOneOf).map(t => parseInt(t) as VideoPrivacyType))
: new Set<VideoPrivacyType>()
if (enabledPrivacies.has(VideoPrivacy.PUBLIC)) this.selectedFilterItems.push('public')
if (enabledPrivacies.has(VideoPrivacy.INTERNAL)) this.selectedFilterItems.push('internal')
if (enabledPrivacies.has(VideoPrivacy.UNLISTED)) this.selectedFilterItems.push('unlisted')
if (enabledPrivacies.has(VideoPrivacy.PASSWORD_PROTECTED)) this.selectedFilterItems.push('password-protected')
if (enabledPrivacies.has(VideoPrivacy.PRIVATE)) this.selectedFilterItems.push('private')
}
}
onChangeSortColumn () {
this.videosSelection().reloadVideos()
getIdentifier () {
return 'MyVideosComponent'
}
disableForReuse () {
this.disabled = true
// ---------------------------------------------------------------------------
isSelected (id: Column) {
return this.columns.some(c => c.id === id && c.selected)
}
enabledForReuse () {
this.disabled = false
getColumn (id: Column) {
return this.columns.find(c => c.id === id)
}
getVideosObservable (page: number) {
const newPagination = immutableAssign(this.pagination, { currentPage: page })
private loadSelectedColumns () {
const enabledString = this.peertubeLocalStorage.getItem(MyVideosComponent.LS_SELECTED_COLUMNS_KEY)
return this.videoService.getMyVideos({
videoPagination: newPagination,
if (!enabledString) return
try {
const enabled = JSON.parse(enabledString)
for (const column of this.columns) {
column.selected = enabled.includes(column.id)
}
} catch (err) {
logger.error('Cannot load selected columns.', err)
}
}
saveSelectedColumns () {
const enabled = this.columns.filter(c => c.selected).map(c => c.id)
this.peertubeLocalStorage.setItem(MyVideosComponent.LS_SELECTED_COLUMNS_KEY, JSON.stringify(enabled))
}
onFilter () {
const channelNameOneOf = this.channels.filter(c => c.selected).map(c => c.name)
const newParams = {
...this.route.snapshot.queryParams,
...this.buildCommonVideoFilters(),
channelNameOneOf
}
this.peertubeRouter.silentNavigate([ '.' ], newParams, this.route)
this.resetAndReload()
}
private buildCommonVideoFilters () {
const selectedFilterSet = new Set(this.selectedFilterItems)
let isLive: boolean
if (selectedFilterSet.has('live') && !selectedFilterSet.has('vod')) {
isLive = true
} else if (!selectedFilterSet.has('live') && selectedFilterSet.has('vod')) {
isLive = false
}
const privacyOneOf: VideoPrivacyType[] = []
if (selectedFilterSet.has('public')) privacyOneOf.push(VideoPrivacy.PUBLIC)
if (selectedFilterSet.has('internal')) privacyOneOf.push(VideoPrivacy.INTERNAL)
if (selectedFilterSet.has('unlisted')) privacyOneOf.push(VideoPrivacy.UNLISTED)
if (selectedFilterSet.has('password-protected')) privacyOneOf.push(VideoPrivacy.PASSWORD_PROTECTED)
if (selectedFilterSet.has('private')) privacyOneOf.push(VideoPrivacy.PRIVATE)
return {
isLive,
privacyOneOf
}
}
// ---------------------------------------------------------------------------
protected reloadDataInternal () {
this.loading = true
const channelNameOneOf = this.channels.filter(c => c.selected).map(c => c.name)
return this.videoService.listMyVideos({
restPagination: this.pagination,
sort: this.sort,
userChannels: this.userChannels,
search: this.search
}).pipe(
tap(res => this.pagination.totalItems = res.total),
tap(({ data }) => this.fetchVideosContainedInPlaylists(data))
)
search: this.search,
channelNameOneOf: channelNameOneOf.length !== 0
? channelNameOneOf
: undefined,
...this.buildCommonVideoFilters()
}).pipe(finalize(() => this.loading = false))
.subscribe({
next: resultList => {
this.videos = resultList.data
this.totalRecords = resultList.total
this.fetchVideosContainedInPlaylists(resultList.data)
},
error: err => this.notifier.error(err.message)
})
}
private fetchVideosContainedInPlaylists (videos: Video[]) {
@ -205,67 +349,36 @@ export class MyVideosComponent implements OnInit, DisableForReuseHook {
})
}
async deleteSelectedVideos () {
const toDeleteVideosIds = Object.entries(this.selection)
.filter(([ _k, v ]) => v === true)
.map(([ k, _v ]) => parseInt(k, 10))
const res = await this.confirmService.confirm(
formatICU(
$localize`Do you really want to delete {length, plural, =1 {this video} other {{length} videos}}?`,
{ length: toDeleteVideosIds.length }
),
$localize`Delete`
async removeVideos (videos: Video[]) {
const message = formatICU(
$localize`Are you sure you want to delete {count, plural, =1 {this video} other {these {count} videos}}?`,
{ count: videos.length }
)
const res = await this.confirmService.confirm(message, $localize`Delete`)
if (res === false) return
const observables: Observable<any>[] = []
for (const videoId of toDeleteVideosIds) {
const o = this.videoService.removeVideo(videoId)
.pipe(tap(() => this.removeVideoFromArray(videoId)))
observables.push(o)
}
concat(...observables)
.pipe(toArray())
this.videoService.removeVideo(videos.map(v => v.id))
.subscribe({
next: () => {
this.notifier.success(
formatICU(
$localize`{length, plural, =1 {Video has been deleted} other {{length} videos have been deleted}}`,
{ length: toDeleteVideosIds.length }
$localize`Deleted {count, plural, =1 {1 video} other {{count} videos}}.`,
{ count: videos.length }
)
)
this.selection = {}
this.reloadData()
},
error: err => this.notifier.error(err.message)
})
}
onVideoRemoved (video: Video) {
this.removeVideoFromArray(video.id)
}
changeOwnership (video: Video) {
this.videoChangeOwnershipModal().show(video)
}
getTotalTitle () {
return formatICU(
$localize`${this.pagination.totalItems} {total, plural, =1 {video} other {videos}}`,
{ total: this.pagination.totalItems }
)
}
private removeVideoFromArray (id: number) {
this.videos = this.videos.filter(v => v.id !== id)
updatePaginationOnDelete(this.pagination)
}
private buildActions () {
this.moreVideoActions = [
[
@ -276,5 +389,16 @@ export class MyVideosComponent implements OnInit, DisableForReuseHook {
}
]
]
this.bulkActions = [
[
{
label: $localize`Delete`,
handler: videos => this.removeVideos(videos),
isDisplayed: () => this.user.hasRight(UserRight.REMOVE_ANY_VIDEO),
iconName: 'delete'
}
]
]
}
}

View file

@ -2,6 +2,7 @@ import { Route, Routes, UrlSegment } from '@angular/router'
import { userResolver } from '@app/core/routing/user.resolver'
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 { UserSubscriptionService } from '@app/shared/shared-user-subscription/user-subscription.service'
import { VideoCommentService } from '@app/shared/shared-video-comment/video-comment.service'
@ -27,7 +28,7 @@ import { MyVideoPlaylistsComponent } from './my-video-playlists/my-video-playlis
import { MyVideoSpaceComponent } from './my-video-space.component'
import { MyVideosComponent } from './my-videos/my-videos.component'
import { MyWatchedWordsListComponent } from './my-watched-words-list/my-watched-words-list.component'
import { BulkService } from '@app/shared/shared-moderation/bulk.service'
import { MyChannelSpaceComponent } from './my-channel-space.component'
const commonConfig = {
path: '',
@ -50,55 +51,6 @@ const commonConfig = {
}
const videoSpaceRoutes = [
// ---------------------------------------------------------------------------
// Channels
// ---------------------------------------------------------------------------
{
path: '',
redirectTo: 'video-channels',
pathMatch: 'full'
},
{
path: 'video-channels',
loadChildren: () => import('./+my-video-channels/routes')
},
{
path: 'followers',
component: MyFollowersComponent,
data: {
meta: {
title: $localize`My followers`
}
}
},
{
path: 'video-channel-syncs',
component: MyVideoChannelSyncsComponent,
data: {
meta: {
title: $localize`My synchronizations`
}
}
},
{
path: 'video-channel-syncs/create',
component: VideoChannelSyncEditComponent,
data: {
meta: {
title: $localize`Create new synchronization`
}
}
},
// ---------------------------------------------------------------------------
// Videos
// ---------------------------------------------------------------------------
{
path: 'videos/comments',
redirectTo: 'comments-on-my-videos',
@ -168,8 +120,50 @@ const videoSpaceRoutes = [
}
] satisfies Routes
const libraryRoutes = [
const channelSpaceRoutes = [
{
path: '',
redirectTo: 'video-channels',
pathMatch: 'full'
},
{
path: 'video-channels',
loadChildren: () => import('./+my-video-channels/routes')
},
{
path: 'followers',
component: MyFollowersComponent,
data: {
meta: {
title: $localize`My followers`
}
}
},
{
path: 'video-channel-syncs',
component: MyVideoChannelSyncsComponent,
data: {
meta: {
title: $localize`My synchronizations`
}
}
},
{
path: 'video-channel-syncs/create',
component: VideoChannelSyncEditComponent,
data: {
meta: {
title: $localize`Create new synchronization`
}
}
}
] satisfies Routes
const libraryRoutes = [
// ---------------------------------------------------------------------------
// Playlists
// ---------------------------------------------------------------------------
@ -248,6 +242,14 @@ function isVideoSpaceRoute (segments: UrlSegment[]) {
return videoSpaceRoutes.some(r => r.path === rootPath || r.path.startsWith(`${rootPath}/`))
}
function isChannelSpaceRoute (segments: UrlSegment[]) {
if (segments.length === 0) return false
const rootPath = segments[0].path
return channelSpaceRoutes.some(r => r.path === rootPath || r.path.startsWith(`${rootPath}/`))
}
export default [
{
...commonConfig,
@ -264,10 +266,22 @@ export default [
{
...commonConfig,
component: MyChannelSpaceComponent,
canMatch: [
(_route: Route, segments: UrlSegment[]) => {
return isChannelSpaceRoute(segments)
}
],
children: channelSpaceRoutes
},
{
...commonConfig,
component: MyLibraryComponent,
canMatch: [
(_route: Route, segments: UrlSegment[]) => {
return !isVideoSpaceRoute(segments)
return !isVideoSpaceRoute(segments) && !isChannelSpaceRoute(segments)
}
],
children: libraryRoutes

View file

@ -1,26 +0,0 @@
import { Routes } from '@angular/router'
import { LoginGuard } from '@app/core'
import { VideoStatsComponent, VideoStatsService } from './video'
import { VideoResolver } from '@app/shared/shared-main/video/video.resolver'
import { LiveVideoService } from '@app/shared/shared-video-live/live-video.service'
export default [
{
path: 'videos/:videoId',
canActivate: [ LoginGuard ],
component: VideoStatsComponent,
data: {
meta: {
title: $localize`Video stats`
}
},
providers: [
LiveVideoService,
VideoStatsService,
VideoResolver
],
resolve: {
video: VideoResolver
}
}
] satisfies Routes

View file

@ -1,2 +0,0 @@
export * from './video-stats.component'
export * from './video-stats.service'

View file

@ -1,75 +0,0 @@
<div class="margin-content">
<h1 class="title-page">
<my-global-icon iconName="stats"></my-global-icon>
<ng-container i18n>{{ video.name }} statistics</ng-container>
</h1>
<div class="stats-embed">
<div class="global-stats">
<div *ngFor="let card of globalStatsCards" class="card stats-card">
<div class="label">
{{ card.label }}
<my-help *ngIf="card.help">
<ng-template ptTemplate="customHtml">{{ card.help }}</ng-template>
</my-help>
</div>
<div class="value">{{ card.value }}</div>
<div *ngIf="card.moreInfo" class="more-info">{{ card.moreInfo }}</div>
</div>
</div>
<my-embed [video]="video"></my-embed>
</div>
<div class="stats-with-date">
<div class="overall-stats">
<h2>{{ getViewersStatsTitle() }}</h2>
<div class="date-filter-wrapper">
<label class="visually-hidden" for="date-filter">Filter viewers stats by date</label>
<my-select-options inputId="date-filter" [(ngModel)]="currentDateFilter" (ngModelChange)="onDateFilterChange()" [items]="dateFilters"></my-select-options>
</div>
<div class="cards">
<div *ngFor="let card of overallStatCards" class="card stats-card">
<div class="label">{{ card.label }}</div>
<div class="value">{{ card.value }}</div>
<div *ngIf="card.moreInfo" class="more-info">{{ card.moreInfo }}</div>
</div>
</div>
</div>
<div class="timeserie">
<div ngbNav #nav="ngbNav" [activeId]="activeGraphId" (activeIdChange)="onChartChange($event)" class="nav-tabs">
<ng-container *ngFor="let availableChart of availableCharts" [ngbNavItem]="availableChart.id">
<a ngbNavLink>
<span>{{ availableChart.label }}</span>
</a>
<ng-template ngbNavContent>
<div class="chart-container">
<p-chart
*ngIf="chartOptions[availableChart.id]"
[height]="chartHeight" [width]="chartWidth"
[type]="chartOptions[availableChart.id].type" [options]="chartOptions[availableChart.id].options" [data]="chartOptions[availableChart.id].data"
[plugins]="chartPlugins"
></p-chart>
</div>
<div class="zoom-container">
<span *ngIf="!hasZoom() && availableChart.zoomEnabled" i18n class="description">You can select a part of the graph to zoom in</span>
<my-button i18n *ngIf="hasZoom()" (click)="resetZoom()">Reset zoom</my-button>
</div>
</ng-template>
</ng-container>
</div>
<div [ngbNavOutlet]="nav"></div>
</div>
</div>
</div>

View file

@ -1,14 +1,14 @@
import { forkJoin, Observable, of } from 'rxjs'
import { catchError, map, switchMap, tap } from 'rxjs/operators'
import { HttpClient, HttpParams } from '@angular/common/http'
import { Injectable, inject } from '@angular/core'
import { inject, Injectable } from '@angular/core'
import { RestExtractor, ServerService } from '@app/core'
import { immutableAssign } from '@app/helpers'
import { VideoService } from '@app/shared/shared-main/video/video.service'
import { objectKeysTyped, peertubeTranslate } from '@peertube/peertube-core-utils'
import { VideosOverview as VideosOverviewServer } from '@peertube/peertube-models'
import { environment } from '../../../../environments/environment'
import { forkJoin, Observable, of } from 'rxjs'
import { catchError, map, switchMap, tap } from 'rxjs/operators'
import { environment } from '../../../environments/environment'
import { VideosOverview } from './videos-overview.model'
import { VideoService } from '@app/shared/shared-main/video/video.service'
@Injectable()
export class OverviewService {

View file

@ -4,10 +4,10 @@ import { RouterLink } from '@angular/router'
import { DisableForReuseHook, Notifier, User, UserService } from '@app/core'
import { ActorAvatarComponent, ActorAvatarInput } from '@app/shared/shared-actor-image/actor-avatar.component'
import { ButtonComponent } from '@app/shared/shared-main/buttons/button.component'
import { InfiniteScrollerDirective } from '@app/shared/shared-main/common/infinite-scroller.directive'
import { Video } from '@app/shared/shared-main/video/video.model'
import { VideoMiniatureComponent } from '@app/shared/shared-video-miniature/video-miniature.component'
import { Subject, Subscription, switchMap } from 'rxjs'
import { InfiniteScrollerDirective } from '../../../shared/shared-main/common/infinite-scroller.directive'
import { VideoMiniatureComponent } from '../../../shared/shared-video-miniature/video-miniature.component'
import { OverviewService } from './overview.service'
@Component({

View file

@ -6,9 +6,9 @@ import { BlocklistService } from '@app/shared/shared-moderation/blocklist.servic
import { VideoBlockService } from '@app/shared/shared-moderation/video-block.service'
import { UserSubscriptionService } from '@app/shared/shared-user-subscription/user-subscription.service'
import { VideoPlaylistService } from '@app/shared/shared-video-playlist/video-playlist.service'
import { OverviewService, VideosListAllComponent } from './video-list'
import { VideoOverviewComponent } from './video-list/overview/video-overview.component'
import { VideoUserSubscriptionsComponent } from './video-list/video-user-subscriptions.component'
import { OverviewService, VideosListAllComponent } from '.'
import { VideoOverviewComponent } from './overview/video-overview.component'
import { VideoUserSubscriptionsComponent } from './video-user-subscriptions.component'
export default [
{

View file

@ -1,13 +1,13 @@
import { firstValueFrom } from 'rxjs'
import { switchMap, tap } from 'rxjs/operators'
import { Component, inject } from '@angular/core'
import { AuthService, ComponentPaginationLight, DisableForReuseHook, ScopedTokensService } from '@app/core'
import { HooksService } from '@app/core/plugins/hooks.service'
import { VideoSortField } from '@peertube/peertube-models'
import { VideosListComponent } from '../../shared/shared-video-miniature/videos-list.component'
import { VideoService } from '@app/shared/shared-main/video/video.service'
import { UserSubscriptionService } from '@app/shared/shared-user-subscription/user-subscription.service'
import { VideoFilters } from '@app/shared/shared-video-miniature/video-filters.model'
import { VideosListComponent } from '@app/shared/shared-video-miniature/videos-list.component'
import { VideoSortField } from '@peertube/peertube-models'
import { firstValueFrom } from 'rxjs'
import { switchMap, tap } from 'rxjs/operators'
@Component({
selector: 'my-videos-user-subscriptions',

View file

@ -4,9 +4,9 @@ import { ComponentPaginationLight, DisableForReuseHook, MetaService } from '@app
import { HooksService } from '@app/core/plugins/hooks.service'
import { VideoService } from '@app/shared/shared-main/video/video.service'
import { VideoFilterScope, VideoFilters } from '@app/shared/shared-video-miniature/video-filters.model'
import { VideosListComponent } from '@app/shared/shared-video-miniature/videos-list.component'
import { VideoSortField } from '@peertube/peertube-models'
import { Subscription } from 'rxjs'
import { VideosListComponent } from '../../shared/shared-video-miniature/videos-list.component'
@Component({
templateUrl: './videos-list-all.component.html',

View file

@ -1 +0,0 @@
export * from './video-studio-edit.component'

View file

@ -1,221 +0,0 @@
import { Component, OnInit, inject } from '@angular/core'
import { ActivatedRoute, Router } from '@angular/router'
import { ConfirmService, Notifier, ServerService } from '@app/core'
import { FormReactive } from '@app/shared/shared-forms/form-reactive'
import { FormReactiveService } from '@app/shared/shared-forms/form-reactive.service'
import { LoadingBarService } from '@ngx-loading-bar/core'
import { logger } from '@root-helpers/logger'
import { secondsToTime } from '@peertube/peertube-core-utils'
import { VideoStudioTask, VideoStudioTaskCut } from '@peertube/peertube-models'
import { VideoStudioService } from '../shared'
import { NgIf, NgFor } from '@angular/common'
import { EmbedComponent } from '../../shared/shared-main/video/embed.component'
import { ButtonComponent } from '../../shared/shared-main/buttons/button.component'
import { ReactiveFileComponent } from '../../shared/shared-forms/reactive-file.component'
import { TimestampInputComponent } from '../../shared/shared-forms/timestamp-input.component'
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
import { VideoDetails } from '@app/shared/shared-main/video/video-details.model'
import { GlobalIconComponent } from '@app/shared/shared-icons/global-icon.component'
@Component({
selector: 'my-video-studio-edit',
templateUrl: './video-studio-edit.component.html',
styleUrls: [ './video-studio-edit.component.scss' ],
imports: [
FormsModule,
ReactiveFormsModule,
TimestampInputComponent,
ReactiveFileComponent,
ButtonComponent,
EmbedComponent,
NgIf,
NgFor,
GlobalIconComponent
]
})
export class VideoStudioEditComponent extends FormReactive implements OnInit {
protected formReactiveService = inject(FormReactiveService)
private serverService = inject(ServerService)
private notifier = inject(Notifier)
private router = inject(Router)
private route = inject(ActivatedRoute)
private videoStudioService = inject(VideoStudioService)
private loadingBar = inject(LoadingBarService)
private confirmService = inject(ConfirmService)
isRunningEdit = false
video: VideoDetails
ngOnInit () {
this.video = this.route.snapshot.data.video
const defaultValues = {
cut: {
start: 0,
end: this.video.duration
}
}
this.buildForm({
'cut': {
start: null,
end: null
},
'add-intro': {
file: null
},
'add-outro': {
file: null
},
'add-watermark': {
file: null
}
}, defaultValues)
}
get videoExtensions () {
return this.serverService.getHTMLConfig().video.file.extensions
}
get imageExtensions () {
return this.serverService.getHTMLConfig().video.image.extensions
}
async runEdit () {
if (this.isRunningEdit) return
if (!this.form.valid) return
if (this.noEdit()) return
const title = $localize`Are you sure you want to edit "${this.video.name}"?`
const listHTML = this.getTasksSummary().map(t => `<li>${t}</li>`).join('')
const confirmHTML =
// eslint-disable-next-line max-len
$localize`The current video will be overwritten by this edited video and <strong>you won't be able to recover it</strong>.<br /><br />` +
$localize`As a reminder, the following tasks will be executed: <ol>${listHTML}</ol>`
if (await this.confirmService.confirm(confirmHTML, title) !== true) return
this.isRunningEdit = true
const tasks = this.buildTasks()
this.loadingBar.useRef().start()
return this.videoStudioService.editVideo(this.video.uuid, tasks)
.subscribe({
next: () => {
this.notifier.success($localize`Editing tasks created.`)
// Don't redirect to old video version watch page that could be confusing for users
this.router.navigateByUrl('/my-library/videos')
},
error: err => {
this.loadingBar.useRef().complete()
this.isRunningEdit = false
this.notifier.error(err.message)
logger.error(err)
}
})
}
getIntroOutroTooltip () {
return $localize`(extensions: ${this.videoExtensions.join(', ')})`
}
getWatermarkTooltip () {
return $localize`(extensions: ${this.imageExtensions.join(', ')})`
}
noEdit () {
return this.buildTasks().length === 0
}
getTasksSummary () {
const tasks = this.buildTasks()
return tasks.map(t => {
if (t.name === 'add-intro') {
return $localize`"${this.getFilename(t.options.file)}" will be added at the beginning of the video`
}
if (t.name === 'add-outro') {
return $localize`"${this.getFilename(t.options.file)}" will be added at the end of the video`
}
if (t.name === 'add-watermark') {
return $localize`"${this.getFilename(t.options.file)}" image watermark will be added to the video`
}
if (t.name === 'cut') {
const { start, end } = t.options
if (start !== undefined && end !== undefined) {
return $localize`Video will begin at ${secondsToTime(start)} and stop at ${secondsToTime(end)}`
}
if (start !== undefined) {
return $localize`Video will begin at ${secondsToTime(start)}`
}
if (end !== undefined) {
return $localize`Video will stop at ${secondsToTime(end)}`
}
}
return ''
})
}
private getFilename (obj: any) {
return obj.name
}
private buildTasks () {
const tasks: VideoStudioTask[] = []
const value = this.form.value
const cut = value['cut']
if (cut['start'] !== 0 || cut['end'] !== this.video.duration) {
const options: VideoStudioTaskCut['options'] = {}
if (cut['start'] !== 0) options.start = cut['start']
if (cut['end'] !== this.video.duration) options.end = cut['end']
tasks.push({
name: 'cut',
options
})
}
if (value['add-intro']?.['file']) {
tasks.push({
name: 'add-intro',
options: {
file: value['add-intro']['file']
}
})
}
if (value['add-outro']?.['file']) {
tasks.push({
name: 'add-outro',
options: {
file: value['add-outro']['file']
}
})
}
if (value['add-watermark']?.['file']) {
tasks.push({
name: 'add-watermark',
options: {
file: value['add-watermark']['file']
}
})
}
return tasks
}
}

View file

@ -1,27 +0,0 @@
import { Routes } from '@angular/router'
import { LoginGuard } from '@app/core'
import { VideoStudioEditComponent } from './edit'
import { VideoStudioService } from './shared'
import { VideoResolver } from '@app/shared/shared-main/video/video.resolver'
export default [
{
path: '',
canActivateChild: [ LoginGuard ],
providers: [ VideoStudioService ],
children: [
{
path: 'edit/:videoId',
component: VideoStudioEditComponent,
data: {
meta: {
title: $localize`Studio`
}
},
resolve: {
video: VideoResolver
}
}
]
}
] satisfies Routes

View file

@ -1 +0,0 @@
export * from './video-studio.service'

View file

@ -0,0 +1 @@
@use '../../standalone/player/build/peertube-player.css';

View file

@ -8,7 +8,7 @@ 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 { OverviewService } from '../video-list'
import { OverviewService } from '../+video-list'
import { VideoRecommendationService } from './shared'
import { VideoWatchComponent } from './video-watch.component'
import { BulkService } from '@app/shared/shared-moderation/bulk.service'

View file

@ -1,19 +1,16 @@
import { NgClass, NgIf, NgStyle } from '@angular/common'
import { Component, OnChanges, inject, input, output, viewChild } from '@angular/core'
import { RedirectService, ScreenService } from '@app/core'
import { GlobalIconComponent } from '@app/shared/shared-icons/global-icon.component'
import { VideoDetails } from '@app/shared/shared-main/video/video-details.model'
import { VideoShareComponent } from '@app/shared/shared-share-modal/video-share.component'
import { SupportModalComponent } from '@app/shared/shared-support-modal/support-modal.component'
import { VideoDownloadComponent } from '@app/shared/shared-video-miniature/download/video-download.component'
import { VideoActionsDisplayType, VideoActionsDropdownComponent } from '@app/shared/shared-video-miniature/video-actions-dropdown.component'
import { VideoAddToPlaylistComponent } from '@app/shared/shared-video-playlist/video-add-to-playlist.component'
import { VideoPlaylist } from '@app/shared/shared-video-playlist/video-playlist.model'
import { NgbDropdown, NgbDropdownMenu, NgbDropdownToggle, NgbTooltip } from '@ng-bootstrap/ng-bootstrap'
import { UserVideoRateType, VideoCaption, VideoPrivacy } from '@peertube/peertube-models'
import { GlobalIconComponent } from '../../../../shared/shared-icons/global-icon.component'
import {
VideoActionsDisplayType,
VideoActionsDropdownComponent
} from '../../../../shared/shared-video-miniature/video-actions-dropdown.component'
import { VideoAddToPlaylistComponent } from '../../../../shared/shared-video-playlist/video-add-to-playlist.component'
import { VideoRateComponent } from './video-rate.component'
@Component({

View file

@ -1,12 +1,12 @@
import { Observable } from 'rxjs'
import { Component, OnChanges, OnDestroy, OnInit, inject, input, output } from '@angular/core'
import { Notifier, ScreenService, Hotkey, HotkeysService } from '@app/core'
import { UserVideoRateType } from '@peertube/peertube-models'
import { GlobalIconComponent } from '../../../../shared/shared-icons/global-icon.component'
import { NgClass, NgIf } from '@angular/common'
import { NgbPopover, NgbTooltip } from '@ng-bootstrap/ng-bootstrap'
import { VideoService } from '@app/shared/shared-main/video/video.service'
import { Component, OnChanges, OnDestroy, OnInit, inject, input, output } from '@angular/core'
import { Hotkey, HotkeysService, Notifier, ScreenService } from '@app/core'
import { GlobalIconComponent } from '@app/shared/shared-icons/global-icon.component'
import { VideoDetails } from '@app/shared/shared-main/video/video-details.model'
import { VideoService } from '@app/shared/shared-main/video/video.service'
import { NgbPopover, NgbTooltip } from '@ng-bootstrap/ng-bootstrap'
import { UserVideoRateType } from '@peertube/peertube-models'
import { Observable } from 'rxjs'
@Component({
selector: 'my-video-rate',

View file

@ -1,23 +1,23 @@
import { getLocaleDirection, NgClass, NgFor, NgIf } from '@angular/common'
import { Component, ElementRef, LOCALE_ID, OnChanges, OnInit, SimpleChanges, inject, input, output, viewChild } from '@angular/core'
import { Component, ElementRef, inject, input, LOCALE_ID, OnChanges, OnInit, output, SimpleChanges, viewChild } from '@angular/core'
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
import { Notifier, User } from '@app/core'
import { VIDEO_COMMENT_TEXT_VALIDATOR } from '@app/shared/form-validators/video-comment-validators'
import { ActorAvatarComponent } from '@app/shared/shared-actor-image/actor-avatar.component'
import { FormReactive } from '@app/shared/shared-forms/form-reactive'
import { FormReactiveService } from '@app/shared/shared-forms/form-reactive.service'
import { TextareaAutoResizeDirective } from '@app/shared/shared-forms/textarea-autoresize.directive'
import { GlobalIconComponent } from '@app/shared/shared-icons/global-icon.component'
import { HelpComponent } from '@app/shared/shared-main/buttons/help.component'
import { PeerTubeTemplateDirective } from '@app/shared/shared-main/common/peertube-template.directive'
import { LoginLinkComponent } from '@app/shared/shared-main/users/login-link.component'
import { Video } from '@app/shared/shared-main/video/video.model'
import { RemoteSubscribeComponent } from '@app/shared/shared-user-subscription/remote-subscribe.component'
import { VideoComment } from '@app/shared/shared-video-comment/video-comment.model'
import { VideoCommentService } from '@app/shared/shared-video-comment/video-comment.service'
import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
import { VideoCommentCreate } from '@peertube/peertube-models'
import { Observable } from 'rxjs'
import { ActorAvatarComponent } from '../../../../shared/shared-actor-image/actor-avatar.component'
import { TextareaAutoResizeDirective } from '../../../../shared/shared-forms/textarea-autoresize.directive'
import { GlobalIconComponent } from '../../../../shared/shared-icons/global-icon.component'
import { HelpComponent } from '../../../../shared/shared-main/buttons/help.component'
import { PeerTubeTemplateDirective } from '../../../../shared/shared-main/common/peertube-template.directive'
import { RemoteSubscribeComponent } from '../../../../shared/shared-user-subscription/remote-subscribe.component'
@Component({
selector: 'my-video-comment-add',

View file

@ -3,16 +3,16 @@ import { Component, OnChanges, OnInit, inject, input, model, output, viewChild }
import { RouterLink } from '@angular/router'
import { MarkdownService, Notifier, UserService } from '@app/core'
import { AuthService } from '@app/core/auth'
import { ActorAvatarComponent } from '@app/shared/shared-actor-image/actor-avatar.component'
import { Account } from '@app/shared/shared-main/account/account.model'
import { DropdownAction } from '@app/shared/shared-main/buttons/action-dropdown.component'
import { FromNowPipe } from '@app/shared/shared-main/date/from-now.pipe'
import { Video } from '@app/shared/shared-main/video/video.model'
import { CommentReportComponent } from '@app/shared/shared-moderation/report-modals/comment-report.component'
import { UserModerationDropdownComponent } from '@app/shared/shared-moderation/user-moderation-dropdown.component'
import { VideoCommentThreadTree } from '@app/shared/shared-video-comment/video-comment-thread-tree.model'
import { VideoComment } from '@app/shared/shared-video-comment/video-comment.model'
import { User, UserRight } from '@peertube/peertube-models'
import { ActorAvatarComponent } from '../../../../shared/shared-actor-image/actor-avatar.component'
import { FromNowPipe } from '../../../../shared/shared-main/date/from-now.pipe'
import { UserModerationDropdownComponent } from '../../../../shared/shared-moderation/user-moderation-dropdown.component'
import { TimestampRouteTransformerDirective } from '../timestamp-route-transformer.directive'
import { VideoCommentAddComponent } from './video-comment-add.component'

View file

@ -1,8 +1,11 @@
import { NgFor, NgIf } from '@angular/common'
import { Component, ElementRef, OnChanges, OnDestroy, OnInit, SimpleChanges, inject, input, output, viewChild } from '@angular/core'
import { Component, ElementRef, inject, input, OnChanges, OnDestroy, OnInit, output, SimpleChanges, viewChild } from '@angular/core'
import { ActivatedRoute } from '@angular/router'
import { AuthService, ComponentPagination, ConfirmService, hasMoreItems, Notifier, PluginService, User } from '@app/core'
import { HooksService } from '@app/core/plugins/hooks.service'
import { InfiniteScrollerDirective } from '@app/shared/shared-main/common/infinite-scroller.directive'
import { LoaderComponent } from '@app/shared/shared-main/common/loader.component'
import { FeedComponent } from '@app/shared/shared-main/feeds/feed.component'
import { Syndication } from '@app/shared/shared-main/feeds/syndication.model'
import { VideoDetails } from '@app/shared/shared-main/video/video-details.model'
import { VideoCommentThreadTree } from '@app/shared/shared-video-comment/video-comment-thread-tree.model'
@ -11,9 +14,6 @@ import { VideoCommentService } from '@app/shared/shared-video-comment/video-comm
import { NgbDropdown, NgbDropdownButtonItem, NgbDropdownItem, NgbDropdownMenu, NgbDropdownToggle } from '@ng-bootstrap/ng-bootstrap'
import { PeerTubeProblemDocument, ServerErrorCode, VideoCommentPolicy } from '@peertube/peertube-models'
import { lastValueFrom, Subject, Subscription } from 'rxjs'
import { InfiniteScrollerDirective } from '../../../../shared/shared-main/common/infinite-scroller.directive'
import { LoaderComponent } from '../../../../shared/shared-main/common/loader.component'
import { FeedComponent } from '../../../../shared/shared-main/feeds/feed.component'
import { VideoCommentAddComponent } from './video-comment-add.component'
import { VideoCommentComponent } from './video-comment.component'

View file

@ -2,10 +2,10 @@ import { NgFor, NgIf } from '@angular/common'
import { Component, OnChanges, inject, input } from '@angular/core'
import { RouterLink } from '@angular/router'
import { HooksService } from '@app/core'
import { GlobalIconComponent } from '@app/shared/shared-icons/global-icon.component'
import { PTDatePipe } from '@app/shared/shared-main/common/date.pipe'
import { TimeDurationFormatterPipe } from '@app/shared/shared-main/date/time-duration-formatter.pipe'
import { VideoDetails } from '@app/shared/shared-main/video/video-details.model'
import { GlobalIconComponent } from '../../../../shared/shared-icons/global-icon.component'
type PluginMetadata = {
label: string

View file

@ -1,6 +1,6 @@
import { Component, OnInit, input } from '@angular/core'
import { ActorAvatarComponent } from '../../../../shared/shared-actor-image/actor-avatar.component'
import { NgIf } from '@angular/common'
import { Component, OnInit, input } from '@angular/core'
import { ActorAvatarComponent } from '@app/shared/shared-actor-image/actor-avatar.component'
import { Video } from '@app/shared/shared-main/video/video.model'
@Component({

View file

@ -1,9 +1,11 @@
import { NgClass, NgFor } from '@angular/common'
import { Component, ElementRef, HostListener, OnChanges, OnInit, SimpleChanges, inject, input, output, viewChild } from '@angular/core'
import { Component, ElementRef, HostListener, inject, input, OnChanges, OnInit, output, SimpleChanges, viewChild } from '@angular/core'
import { FormsModule } from '@angular/forms'
import { Notifier } from '@app/core'
import { durationToString, isInViewport } from '@app/helpers'
import { SelectOptionsComponent } from '@app/shared/shared-forms/select/select-options.component'
import { GlobalIconComponent } from '@app/shared/shared-icons/global-icon.component'
import { Nl2BrPipe } from '@app/shared/shared-main/common/nl2br.pipe'
import { VideoCaptionService } from '@app/shared/shared-main/video-caption/video-caption.service'
import { NgbCollapse } from '@ng-bootstrap/ng-bootstrap'
import { Video, VideoCaption } from '@peertube/peertube-models'
@ -11,8 +13,6 @@ import { parse } from '@plussub/srt-vtt-parser'
import debug from 'debug'
import { debounceTime, distinctUntilChanged, Subject } from 'rxjs'
import { SelectOptionsItem } from 'src/types'
import { GlobalIconComponent } from '../../../../shared/shared-icons/global-icon.component'
import { Nl2BrPipe } from '../../../../shared/shared-main/common/nl2br.pipe'
const debugLogger = debug('peertube:watch:VideoTranscriptionComponent')

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