From 202f6b6c9dcc9b0aec4b0c1b15e455c22a7952a7 Mon Sep 17 00:00:00 2001 From: Chocobozzz Date: Fri, 1 Dec 2017 18:56:26 +0100 Subject: Begin videos of an account --- client/src/app/shared/shared.module.ts | 12 +- .../src/app/shared/video/abstract-video-list.html | 19 +++ .../src/app/shared/video/abstract-video-list.scss | 0 client/src/app/shared/video/abstract-video-list.ts | 121 +++++++++++++++ client/src/app/shared/video/sort-field.type.ts | 5 + client/src/app/shared/video/video-details.model.ts | 84 ++++++++++ client/src/app/shared/video/video-edit.model.ts | 50 ++++++ .../src/app/shared/video/video-pagination.model.ts | 5 + .../shared/video/video-thumbnail.component.html | 10 ++ .../shared/video/video-thumbnail.component.scss | 28 ++++ .../app/shared/video/video-thumbnail.component.ts | 12 ++ client/src/app/shared/video/video.model.ts | 90 +++++++++++ client/src/app/shared/video/video.service.ts | 170 +++++++++++++++++++++ 13 files changed, 604 insertions(+), 2 deletions(-) create mode 100644 client/src/app/shared/video/abstract-video-list.html create mode 100644 client/src/app/shared/video/abstract-video-list.scss create mode 100644 client/src/app/shared/video/abstract-video-list.ts create mode 100644 client/src/app/shared/video/sort-field.type.ts create mode 100644 client/src/app/shared/video/video-details.model.ts create mode 100644 client/src/app/shared/video/video-edit.model.ts create mode 100644 client/src/app/shared/video/video-pagination.model.ts create mode 100644 client/src/app/shared/video/video-thumbnail.component.html create mode 100644 client/src/app/shared/video/video-thumbnail.component.scss create mode 100644 client/src/app/shared/video/video-thumbnail.component.ts create mode 100644 client/src/app/shared/video/video.model.ts create mode 100644 client/src/app/shared/video/video.service.ts (limited to 'client/src/app/shared') diff --git a/client/src/app/shared/shared.module.ts b/client/src/app/shared/shared.module.ts index 7618748e9..e76f7636a 100644 --- a/client/src/app/shared/shared.module.ts +++ b/client/src/app/shared/shared.module.ts @@ -20,6 +20,9 @@ import { SearchComponent, SearchService } from './search' import { UserService } from './users' import { VideoAbuseService } from './video-abuse' import { VideoBlacklistService } from './video-blacklist' +import { VideoThumbnailComponent } from './video/video-thumbnail.component' +import { VideoService } from './video/video.service' +import { InfiniteScrollModule } from 'ngx-infinite-scroll' @NgModule({ imports: [ @@ -34,7 +37,8 @@ import { VideoBlacklistService } from './video-blacklist' ProgressbarModule.forRoot(), DataTableModule, - PrimeSharedModule + PrimeSharedModule, + InfiniteScrollModule ], declarations: [ @@ -42,6 +46,7 @@ import { VideoBlacklistService } from './video-blacklist' KeysPipe, SearchComponent, LoaderComponent, + VideoThumbnailComponent, NumberFormatterPipe, FromNowPipe ], @@ -58,11 +63,13 @@ import { VideoBlacklistService } from './video-blacklist' ProgressbarModule, DataTableModule, PrimeSharedModule, + InfiniteScrollModule, BytesPipe, KeysPipe, SearchComponent, LoaderComponent, + VideoThumbnailComponent, NumberFormatterPipe, FromNowPipe @@ -75,7 +82,8 @@ import { VideoBlacklistService } from './video-blacklist' SearchService, VideoAbuseService, VideoBlacklistService, - UserService + UserService, + VideoService ] }) export class SharedModule { } diff --git a/client/src/app/shared/video/abstract-video-list.html b/client/src/app/shared/video/abstract-video-list.html new file mode 100644 index 000000000..bd4f6b1f8 --- /dev/null +++ b/client/src/app/shared/video/abstract-video-list.html @@ -0,0 +1,19 @@ +
+
+ {{ titlePage }} +
+ +
+ + +
+
diff --git a/client/src/app/shared/video/abstract-video-list.scss b/client/src/app/shared/video/abstract-video-list.scss new file mode 100644 index 000000000..e69de29bb diff --git a/client/src/app/shared/video/abstract-video-list.ts b/client/src/app/shared/video/abstract-video-list.ts new file mode 100644 index 000000000..cf717cf4c --- /dev/null +++ b/client/src/app/shared/video/abstract-video-list.ts @@ -0,0 +1,121 @@ +import { OnDestroy, OnInit } from '@angular/core' +import { ActivatedRoute, Router } from '@angular/router' +import { NotificationsService } from 'angular2-notifications' +import { Observable } from 'rxjs/Observable' +import { Subscription } from 'rxjs/Subscription' +import { SortField } from './sort-field.type' +import { VideoPagination } from './video-pagination.model' +import { Video } from './video.model' + +export abstract class AbstractVideoList implements OnInit, OnDestroy { + pagination: VideoPagination = { + currentPage: 1, + itemsPerPage: 25, + totalItems: null + } + sort: SortField = '-createdAt' + videos: Video[] = [] + + protected notificationsService: NotificationsService + protected router: Router + protected route: ActivatedRoute + protected subActivatedRoute: Subscription + + protected abstract currentRoute: string + + abstract titlePage: string + private loadedPages: { [ id: number ]: boolean } = {} + + abstract getVideosObservable (): Observable<{ videos: Video[], totalVideos: number}> + + ngOnInit () { + // Subscribe to route changes + const routeParams = this.route.snapshot.params + this.loadRouteParams(routeParams) + this.loadMoreVideos('after') + } + + ngOnDestroy () { + if (this.subActivatedRoute) { + this.subActivatedRoute.unsubscribe() + } + } + + onNearOfTop () { + if (this.pagination.currentPage > 1) { + this.previousPage() + } + } + + onNearOfBottom () { + if (this.hasMoreVideos()) { + this.nextPage() + } + } + + loadMoreVideos (where: 'before' | 'after') { + if (this.loadedPages[this.pagination.currentPage] === true) return + + const observable = this.getVideosObservable() + + observable.subscribe( + ({ videos, totalVideos }) => { + this.loadedPages[this.pagination.currentPage] = true + this.pagination.totalItems = totalVideos + + if (where === 'before') { + this.videos = videos.concat(this.videos) + } else { + this.videos = this.videos.concat(videos) + } + }, + error => this.notificationsService.error('Error', error.text) + ) + } + + protected hasMoreVideos () { + if (!this.pagination.totalItems) return true + + const maxPage = this.pagination.totalItems / this.pagination.itemsPerPage + return maxPage > this.pagination.currentPage + } + + protected previousPage () { + this.pagination.currentPage-- + + this.setNewRouteParams() + this.loadMoreVideos('before') + } + + protected nextPage () { + this.pagination.currentPage++ + + this.setNewRouteParams() + this.loadMoreVideos('after') + } + + protected buildRouteParams () { + // There is always a sort and a current page + const params = { + sort: this.sort, + page: this.pagination.currentPage + } + + return params + } + + protected loadRouteParams (routeParams: { [ key: string ]: any }) { + this.sort = routeParams['sort'] as SortField || '-createdAt' + + if (routeParams['page'] !== undefined) { + this.pagination.currentPage = parseInt(routeParams['page'], 10) + } else { + this.pagination.currentPage = 1 + } + } + + protected setNewRouteParams () { + const routeParams = this.buildRouteParams() + this.router.navigate([ this.currentRoute, routeParams ]) + } +} diff --git a/client/src/app/shared/video/sort-field.type.ts b/client/src/app/shared/video/sort-field.type.ts new file mode 100644 index 000000000..776f360f8 --- /dev/null +++ b/client/src/app/shared/video/sort-field.type.ts @@ -0,0 +1,5 @@ +export type SortField = 'name' | '-name' + | 'duration' | '-duration' + | 'createdAt' | '-createdAt' + | 'views' | '-views' + | 'likes' | '-likes' diff --git a/client/src/app/shared/video/video-details.model.ts b/client/src/app/shared/video/video-details.model.ts new file mode 100644 index 000000000..93c380b73 --- /dev/null +++ b/client/src/app/shared/video/video-details.model.ts @@ -0,0 +1,84 @@ +import { Video } from '../../shared/video/video.model' +import { AuthUser } from '../../core' +import { + VideoDetails as VideoDetailsServerModel, + VideoFile, + VideoChannel, + VideoResolution, + UserRight, + VideoPrivacy +} from '../../../../../shared' + +export class VideoDetails extends Video implements VideoDetailsServerModel { + account: string + by: string + createdAt: Date + updatedAt: Date + categoryLabel: string + category: number + licenceLabel: string + licence: number + languageLabel: string + language: number + description: string + duration: number + durationLabel: string + id: number + uuid: string + isLocal: boolean + name: string + serverHost: string + tags: string[] + thumbnailPath: string + thumbnailUrl: string + previewPath: string + previewUrl: string + embedPath: string + embedUrl: string + views: number + likes: number + dislikes: number + nsfw: boolean + descriptionPath: string + files: VideoFile[] + channel: VideoChannel + privacy: VideoPrivacy + privacyLabel: string + + constructor (hash: VideoDetailsServerModel) { + super(hash) + + this.privacy = hash.privacy + this.privacyLabel = hash.privacyLabel + this.descriptionPath = hash.descriptionPath + this.files = hash.files + this.channel = hash.channel + } + + getAppropriateMagnetUri (actualDownloadSpeed = 0) { + if (this.files === undefined || this.files.length === 0) return '' + if (this.files.length === 1) return this.files[0].magnetUri + + // Find first video that is good for our download speed (remember they are sorted) + let betterResolutionFile = this.files.find(f => actualDownloadSpeed > (f.size / this.duration)) + + // If the download speed is too bad, return the lowest resolution we have + if (betterResolutionFile === undefined) { + betterResolutionFile = this.files.find(f => f.resolution === VideoResolution.H_240P) + } + + return betterResolutionFile.magnetUri + } + + isRemovableBy (user: AuthUser) { + return user && this.isLocal === true && (this.account === user.username || user.hasRight(UserRight.REMOVE_ANY_VIDEO)) + } + + isBlackistableBy (user: AuthUser) { + return user && user.hasRight(UserRight.MANAGE_VIDEO_BLACKLIST) === true && this.isLocal === false + } + + isUpdatableBy (user: AuthUser) { + return user && this.isLocal === true && user.username === this.account + } +} diff --git a/client/src/app/shared/video/video-edit.model.ts b/client/src/app/shared/video/video-edit.model.ts new file mode 100644 index 000000000..88d23a59f --- /dev/null +++ b/client/src/app/shared/video/video-edit.model.ts @@ -0,0 +1,50 @@ +import { VideoDetails } from './video-details.model' +import { VideoPrivacy } from '../../../../../shared/models/videos/video-privacy.enum' + +export class VideoEdit { + category: number + licence: number + language: number + description: string + name: string + tags: string[] + nsfw: boolean + channel: number + privacy: VideoPrivacy + uuid?: string + id?: number + + constructor (videoDetails: VideoDetails) { + this.id = videoDetails.id + this.uuid = videoDetails.uuid + this.category = videoDetails.category + this.licence = videoDetails.licence + this.language = videoDetails.language + this.description = videoDetails.description + this.name = videoDetails.name + this.tags = videoDetails.tags + this.nsfw = videoDetails.nsfw + this.channel = videoDetails.channel.id + this.privacy = videoDetails.privacy + } + + patch (values: Object) { + Object.keys(values).forEach((key) => { + this[key] = values[key] + }) + } + + toJSON () { + return { + category: this.category, + licence: this.licence, + language: this.language, + description: this.description, + name: this.name, + tags: this.tags, + nsfw: this.nsfw, + channel: this.channel, + privacy: this.privacy + } + } +} diff --git a/client/src/app/shared/video/video-pagination.model.ts b/client/src/app/shared/video/video-pagination.model.ts new file mode 100644 index 000000000..9e71769cb --- /dev/null +++ b/client/src/app/shared/video/video-pagination.model.ts @@ -0,0 +1,5 @@ +export interface VideoPagination { + currentPage: number + itemsPerPage: number + totalItems: number +} diff --git a/client/src/app/shared/video/video-thumbnail.component.html b/client/src/app/shared/video/video-thumbnail.component.html new file mode 100644 index 000000000..5c698e8f6 --- /dev/null +++ b/client/src/app/shared/video/video-thumbnail.component.html @@ -0,0 +1,10 @@ + +video thumbnail + +
+ {{ video.durationLabel }} +
+
diff --git a/client/src/app/shared/video/video-thumbnail.component.scss b/client/src/app/shared/video/video-thumbnail.component.scss new file mode 100644 index 000000000..ab4f9bcb1 --- /dev/null +++ b/client/src/app/shared/video/video-thumbnail.component.scss @@ -0,0 +1,28 @@ +.video-thumbnail { + display: inline-block; + position: relative; + border-radius: 4px; + overflow: hidden; + + &:hover { + text-decoration: none !important; + } + + img.blur-filter { + filter: blur(5px); + transform : scale(1.03); + } + + .video-thumbnail-overlay { + position: absolute; + right: 5px; + bottom: 5px; + display: inline-block; + background-color: rgba(0, 0, 0, 0.7); + color: #fff; + font-size: 12px; + font-weight: $font-bold; + border-radius: 3px; + padding: 0 5px; + } +} diff --git a/client/src/app/shared/video/video-thumbnail.component.ts b/client/src/app/shared/video/video-thumbnail.component.ts new file mode 100644 index 000000000..e543e9903 --- /dev/null +++ b/client/src/app/shared/video/video-thumbnail.component.ts @@ -0,0 +1,12 @@ +import { Component, Input } from '@angular/core' +import { Video } from './video.model' + +@Component({ + selector: 'my-video-thumbnail', + styleUrls: [ './video-thumbnail.component.scss' ], + templateUrl: './video-thumbnail.component.html' +}) +export class VideoThumbnailComponent { + @Input() video: Video + @Input() nsfw = false +} diff --git a/client/src/app/shared/video/video.model.ts b/client/src/app/shared/video/video.model.ts new file mode 100644 index 000000000..6929c8755 --- /dev/null +++ b/client/src/app/shared/video/video.model.ts @@ -0,0 +1,90 @@ +import { Video as VideoServerModel } from '../../../../../shared' +import { User } from '../' + +export class Video implements VideoServerModel { + account: string + by: string + createdAt: Date + updatedAt: Date + categoryLabel: string + category: number + licenceLabel: string + licence: number + languageLabel: string + language: number + description: string + duration: number + durationLabel: string + id: number + uuid: string + isLocal: boolean + name: string + serverHost: string + tags: string[] + thumbnailPath: string + thumbnailUrl: string + previewPath: string + previewUrl: string + embedPath: string + embedUrl: string + views: number + likes: number + dislikes: number + nsfw: boolean + + private static createByString (account: string, serverHost: string) { + return account + '@' + serverHost + } + + private static createDurationString (duration: number) { + const minutes = Math.floor(duration / 60) + const seconds = duration % 60 + const minutesPadding = minutes >= 10 ? '' : '0' + const secondsPadding = seconds >= 10 ? '' : '0' + + return minutesPadding + minutes.toString() + ':' + secondsPadding + seconds.toString() + } + + constructor (hash: VideoServerModel) { + let absoluteAPIUrl = API_URL + if (!absoluteAPIUrl) { + // The API is on the same domain + absoluteAPIUrl = window.location.origin + } + + this.account = hash.account + this.createdAt = new Date(hash.createdAt.toString()) + this.categoryLabel = hash.categoryLabel + this.category = hash.category + this.licenceLabel = hash.licenceLabel + this.licence = hash.licence + this.languageLabel = hash.languageLabel + this.language = hash.language + this.description = hash.description + this.duration = hash.duration + this.durationLabel = Video.createDurationString(hash.duration) + this.id = hash.id + this.uuid = hash.uuid + this.isLocal = hash.isLocal + this.name = hash.name + this.serverHost = hash.serverHost + this.tags = hash.tags + this.thumbnailPath = hash.thumbnailPath + this.thumbnailUrl = absoluteAPIUrl + hash.thumbnailPath + this.previewPath = hash.previewPath + this.previewUrl = absoluteAPIUrl + hash.previewPath + this.embedPath = hash.embedPath + this.embedUrl = absoluteAPIUrl + hash.embedPath + this.views = hash.views + this.likes = hash.likes + this.dislikes = hash.dislikes + this.nsfw = hash.nsfw + + this.by = Video.createByString(hash.account, hash.serverHost) + } + + isVideoNSFWForUser (user: User) { + // If the video is NSFW and the user is not logged in, or the user does not want to display NSFW videos... + return (this.nsfw && (!user || user.displayNSFW === false)) + } +} diff --git a/client/src/app/shared/video/video.service.ts b/client/src/app/shared/video/video.service.ts new file mode 100644 index 000000000..b2a26417c --- /dev/null +++ b/client/src/app/shared/video/video.service.ts @@ -0,0 +1,170 @@ +import { HttpClient, HttpParams, HttpRequest } from '@angular/common/http' +import { Injectable } from '@angular/core' +import 'rxjs/add/operator/catch' +import 'rxjs/add/operator/map' +import { Observable } from 'rxjs/Observable' +import { Video as VideoServerModel, VideoDetails as VideoDetailsServerModel } from '../../../../../shared' +import { ResultList } from '../../../../../shared/models/result-list.model' +import { UserVideoRateUpdate } from '../../../../../shared/models/videos/user-video-rate-update.model' +import { UserVideoRate } from '../../../../../shared/models/videos/user-video-rate.model' +import { VideoRateType } from '../../../../../shared/models/videos/video-rate.type' +import { VideoUpdate } from '../../../../../shared/models/videos/video-update.model' +import { RestExtractor } from '../rest/rest-extractor.service' +import { RestService } from '../rest/rest.service' +import { Search } from '../search/search.model' +import { UserService } from '../users/user.service' +import { SortField } from './sort-field.type' +import { VideoDetails } from './video-details.model' +import { VideoEdit } from './video-edit.model' +import { VideoPagination } from './video-pagination.model' +import { Video } from './video.model' + +@Injectable() +export class VideoService { + private static BASE_VIDEO_URL = API_URL + '/api/v1/videos/' + + constructor ( + private authHttp: HttpClient, + private restExtractor: RestExtractor, + private restService: RestService + ) {} + + getVideo (uuid: string): Observable { + return this.authHttp.get(VideoService.BASE_VIDEO_URL + uuid) + .map(videoHash => new VideoDetails(videoHash)) + .catch((res) => this.restExtractor.handleError(res)) + } + + viewVideo (uuid: string): Observable { + return this.authHttp.post(VideoService.BASE_VIDEO_URL + uuid + '/views', {}) + .map(this.restExtractor.extractDataBool) + .catch(this.restExtractor.handleError) + } + + updateVideo (video: VideoEdit) { + const language = video.language ? video.language : null + + const body: VideoUpdate = { + name: video.name, + category: video.category, + licence: video.licence, + language, + description: video.description, + privacy: video.privacy, + tags: video.tags, + nsfw: video.nsfw + } + + return this.authHttp.put(VideoService.BASE_VIDEO_URL + video.id, body) + .map(this.restExtractor.extractDataBool) + .catch(this.restExtractor.handleError) + } + + uploadVideo (video: FormData) { + const req = new HttpRequest('POST', VideoService.BASE_VIDEO_URL + 'upload', video, { reportProgress: true }) + + return this.authHttp + .request(req) + .catch(this.restExtractor.handleError) + } + + getMyVideos (videoPagination: VideoPagination, sort: SortField): Observable<{ videos: Video[], totalVideos: number}> { + const pagination = this.videoPaginationToRestPagination(videoPagination) + + let params = new HttpParams() + params = this.restService.addRestGetParams(params, pagination, sort) + + return this.authHttp.get(UserService.BASE_USERS_URL + '/me/videos', { params }) + .map(this.extractVideos) + .catch((res) => this.restExtractor.handleError(res)) + } + + getVideos (videoPagination: VideoPagination, sort: SortField): Observable<{ videos: Video[], totalVideos: number}> { + const pagination = this.videoPaginationToRestPagination(videoPagination) + + let params = new HttpParams() + params = this.restService.addRestGetParams(params, pagination, sort) + + return this.authHttp + .get(VideoService.BASE_VIDEO_URL, { params }) + .map(this.extractVideos) + .catch((res) => this.restExtractor.handleError(res)) + } + + searchVideos (search: Search, videoPagination: VideoPagination, sort: SortField): Observable<{ videos: Video[], totalVideos: number}> { + const url = VideoService.BASE_VIDEO_URL + 'search/' + encodeURIComponent(search.value) + + const pagination = this.videoPaginationToRestPagination(videoPagination) + + let params = new HttpParams() + params = this.restService.addRestGetParams(params, pagination, sort) + + if (search.field) params.set('field', search.field) + + return this.authHttp + .get>(url, { params }) + .map(this.extractVideos) + .catch((res) => this.restExtractor.handleError(res)) + } + + removeVideo (id: number) { + return this.authHttp + .delete(VideoService.BASE_VIDEO_URL + id) + .map(this.restExtractor.extractDataBool) + .catch((res) => this.restExtractor.handleError(res)) + } + + loadCompleteDescription (descriptionPath: string) { + return this.authHttp + .get(API_URL + descriptionPath) + .map(res => res['description']) + .catch((res) => this.restExtractor.handleError(res)) + } + + setVideoLike (id: number) { + return this.setVideoRate(id, 'like') + } + + setVideoDislike (id: number) { + return this.setVideoRate(id, 'dislike') + } + + getUserVideoRating (id: number): Observable { + const url = UserService.BASE_USERS_URL + 'me/videos/' + id + '/rating' + + return this.authHttp + .get(url) + .catch(res => this.restExtractor.handleError(res)) + } + + private videoPaginationToRestPagination (videoPagination: VideoPagination) { + const start: number = (videoPagination.currentPage - 1) * videoPagination.itemsPerPage + const count: number = videoPagination.itemsPerPage + + return { start, count } + } + + private setVideoRate (id: number, rateType: VideoRateType) { + const url = VideoService.BASE_VIDEO_URL + id + '/rate' + const body: UserVideoRateUpdate = { + rating: rateType + } + + return this.authHttp + .put(url, body) + .map(this.restExtractor.extractDataBool) + .catch(res => this.restExtractor.handleError(res)) + } + + private extractVideos (result: ResultList) { + const videosJson = result.data + const totalVideos = result.total + const videos = [] + + for (const videoJson of videosJson) { + videos.push(new Video(videoJson)) + } + + return { videos, totalVideos } + } +} -- cgit v1.2.3