mirror of
https://github.com/zoriya/Kyoo.git
synced 2025-05-24 02:02:36 -04:00
WebApp: Merging the webapp repository with the main repository
This commit is contained in:
commit
e15c1e9eca
14
front/.editorconfig
Normal file
14
front/.editorconfig
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
# Editor configuration, see https://editorconfig.org
|
||||||
|
root = true
|
||||||
|
|
||||||
|
[*]
|
||||||
|
charset = utf-8
|
||||||
|
indent_style = tab
|
||||||
|
indent_size = 4
|
||||||
|
insert_final_newline = true
|
||||||
|
max_line_length = 120
|
||||||
|
trim_trailing_whitespace = true
|
||||||
|
|
||||||
|
[*.md]
|
||||||
|
max_line_length = off
|
||||||
|
trim_trailing_whitespace = false
|
46
front/.gitignore
vendored
Normal file
46
front/.gitignore
vendored
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
# See http://help.github.com/ignore-files/ for more about ignoring files.
|
||||||
|
|
||||||
|
# compiled output
|
||||||
|
/dist
|
||||||
|
/tmp
|
||||||
|
/out-tsc
|
||||||
|
# Only exists if Bazel was run
|
||||||
|
/bazel-out
|
||||||
|
|
||||||
|
# dependencies
|
||||||
|
/node_modules
|
||||||
|
|
||||||
|
# profiling files
|
||||||
|
chrome-profiler-events*.json
|
||||||
|
speed-measure-plugin*.json
|
||||||
|
|
||||||
|
# IDEs and editors
|
||||||
|
/.idea
|
||||||
|
.project
|
||||||
|
.classpath
|
||||||
|
.c9/
|
||||||
|
*.launch
|
||||||
|
.settings/
|
||||||
|
*.sublime-workspace
|
||||||
|
|
||||||
|
# IDE - VSCode
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/settings.json
|
||||||
|
!.vscode/tasks.json
|
||||||
|
!.vscode/launch.json
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.history/*
|
||||||
|
|
||||||
|
# misc
|
||||||
|
/.sass-cache
|
||||||
|
/connect.lock
|
||||||
|
/coverage
|
||||||
|
/libpeerconnection.log
|
||||||
|
npm-debug.log
|
||||||
|
yarn-error.log
|
||||||
|
testem.log
|
||||||
|
/typings
|
||||||
|
|
||||||
|
# System Files
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
2
front/README.md
Normal file
2
front/README.md
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
# Kyoo.WebApp
|
||||||
|
The Angular web app for Kyoo.
|
113
front/angular.json
Normal file
113
front/angular.json
Normal file
@ -0,0 +1,113 @@
|
|||||||
|
{
|
||||||
|
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
|
||||||
|
"version": 1,
|
||||||
|
"newProjectRoot": "projects",
|
||||||
|
"projects": {
|
||||||
|
"kyoo": {
|
||||||
|
"projectType": "application",
|
||||||
|
"schematics": {
|
||||||
|
"@schematics/angular:component": {
|
||||||
|
"style": "scss"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"root": "",
|
||||||
|
"sourceRoot": "src",
|
||||||
|
"prefix": "app",
|
||||||
|
"architect": {
|
||||||
|
"build": {
|
||||||
|
"builder": "@angular-devkit/build-angular:browser",
|
||||||
|
"options": {
|
||||||
|
"outputPath": "dist",
|
||||||
|
"index": "src/index.html",
|
||||||
|
"main": "src/main.ts",
|
||||||
|
"polyfills": "src/polyfills.ts",
|
||||||
|
"tsConfig": "tsconfig.json",
|
||||||
|
"aot": false,
|
||||||
|
"buildOptimizer": false,
|
||||||
|
"preserveSymlinks": true,
|
||||||
|
"assets": [
|
||||||
|
"src/assets",
|
||||||
|
{
|
||||||
|
"input": "node_modules/libass-wasm/dist/js",
|
||||||
|
"glob": "subtitles-octopus-worker*",
|
||||||
|
"output": "."
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"styles": [
|
||||||
|
"src/styles.scss"
|
||||||
|
],
|
||||||
|
"scripts": [
|
||||||
|
"./node_modules/jquery/dist/jquery.min.js",
|
||||||
|
"./node_modules/bootstrap/dist/js/bootstrap.bundle.min.js",
|
||||||
|
"./node_modules/hls.js/dist/hls.js"
|
||||||
|
],
|
||||||
|
"stylePreprocessorOptions": {
|
||||||
|
"includePaths": ["src"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"configurations": {
|
||||||
|
"production": {
|
||||||
|
"fileReplacements": [
|
||||||
|
{
|
||||||
|
"replace": "src/environments/environment.ts",
|
||||||
|
"with": "src/environments/environment.prod.ts"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"optimization": true,
|
||||||
|
"outputHashing": "all",
|
||||||
|
"sourceMap": false,
|
||||||
|
"namedChunks": false,
|
||||||
|
"aot": true,
|
||||||
|
"extractLicenses": true,
|
||||||
|
"vendorChunk": false,
|
||||||
|
"buildOptimizer": true,
|
||||||
|
"budgets": [
|
||||||
|
{
|
||||||
|
"type": "initial",
|
||||||
|
"maximumWarning": "3mb",
|
||||||
|
"maximumError": "5mb"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "anyComponentStyle",
|
||||||
|
"maximumWarning": "6kb",
|
||||||
|
"maximumError": "10kb"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"serve": {
|
||||||
|
"builder": "@angular-devkit/build-angular:dev-server",
|
||||||
|
"options": {
|
||||||
|
"browserTarget": "kyoo:build"
|
||||||
|
},
|
||||||
|
"configurations": {
|
||||||
|
"production": {
|
||||||
|
"browserTarget": "kyoo:build:production"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"extract-i18n": {
|
||||||
|
"builder": "@angular-devkit/build-angular:extract-i18n",
|
||||||
|
"options": {
|
||||||
|
"browserTarget": "kyoo:build"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"lint": {
|
||||||
|
"builder": "@angular-devkit/build-angular:tslint",
|
||||||
|
"options": {
|
||||||
|
"tsConfig": "tsconfig.json",
|
||||||
|
"exclude": [
|
||||||
|
"**/node_modules/**",
|
||||||
|
"dist"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"defaultProject": "kyoo",
|
||||||
|
"cli": {
|
||||||
|
"analytics": false
|
||||||
|
}
|
||||||
|
}
|
55
front/package.json
Normal file
55
front/package.json
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
{
|
||||||
|
"name": "kyoo",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"scripts": {
|
||||||
|
"ng": "ng",
|
||||||
|
"start": "ng serve",
|
||||||
|
"build": "ng build",
|
||||||
|
"lint": "ng lint"
|
||||||
|
},
|
||||||
|
"private": true,
|
||||||
|
"browserslist": [
|
||||||
|
"> 0.5%",
|
||||||
|
"last 2 versions",
|
||||||
|
"Firefox ESR",
|
||||||
|
"not dead",
|
||||||
|
"not IE 9-11"
|
||||||
|
],
|
||||||
|
"dependencies": {
|
||||||
|
"@angular/animations": "^12.2.12",
|
||||||
|
"@angular/cdk": "^12.2.12",
|
||||||
|
"@angular/common": "^12.2.12",
|
||||||
|
"@angular/compiler": "^12.2.12",
|
||||||
|
"@angular/core": "^12.2.12",
|
||||||
|
"@angular/forms": "^12.2.12",
|
||||||
|
"@angular/material": "^12.2.12",
|
||||||
|
"@angular/platform-browser": "^12.2.12",
|
||||||
|
"@angular/platform-browser-dynamic": "^12.2.12",
|
||||||
|
"@angular/router": "^12.2.12",
|
||||||
|
"angular-auth-oidc-client": "^12.0.3",
|
||||||
|
"bootstrap": "^4.6.0",
|
||||||
|
"detect-browser": "^5.2.1",
|
||||||
|
"hls.js": "^1.0.12",
|
||||||
|
"jquery": "^3.6.0",
|
||||||
|
"libass-wasm": "AnonymusRaccoon/JavascriptSubtitlesOctopus",
|
||||||
|
"ngx-infinite-scroll": "^10.0.1",
|
||||||
|
"popper.js": "^1.16.1",
|
||||||
|
"rxjs": "^7.4.0",
|
||||||
|
"zone.js": "^0.11.4"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@angular-devkit/build-angular": "^12.2.12",
|
||||||
|
"@angular/cli": "^12.2.12",
|
||||||
|
"@angular/compiler-cli": "^12.2.12",
|
||||||
|
"@angular/language-service": "^12.2.12",
|
||||||
|
"@types/bootstrap": "^5.1.6",
|
||||||
|
"@types/hls.js": "^0.13.3",
|
||||||
|
"@types/jquery": "^3.5.8",
|
||||||
|
"@types/node": "^16.11.6",
|
||||||
|
"@types/video.js": "^7.3.27",
|
||||||
|
"codelyzer": "^6.0.2",
|
||||||
|
"ts-node": "~10.4.0",
|
||||||
|
"tslint": "^6.1.3",
|
||||||
|
"typescript": "4.3.5"
|
||||||
|
}
|
||||||
|
}
|
117
front/src/app/app-routing.module.ts
Normal file
117
front/src/app/app-routing.module.ts
Normal file
@ -0,0 +1,117 @@
|
|||||||
|
import { NgModule } from "@angular/core";
|
||||||
|
import { RouteReuseStrategy, RouterModule, Routes } from "@angular/router";
|
||||||
|
import { ItemsGridComponent } from "./components/items-grid/items-grid.component";
|
||||||
|
import { CustomRouteReuseStrategy } from "./misc/custom-route-reuse-strategy";
|
||||||
|
import { NotFoundComponent } from "./pages/not-found/not-found.component";
|
||||||
|
import { PageResolver } from "./services/page-resolver.service";
|
||||||
|
import { ShowDetailsComponent } from "./pages/show-details/show-details.component";
|
||||||
|
import { AuthGuard } from "./auth/misc/authenticated-guard.service";
|
||||||
|
import { LibraryItem } from "./models/resources/library-item";
|
||||||
|
import {
|
||||||
|
EpisodeService,
|
||||||
|
LibraryItemService,
|
||||||
|
LibraryService,
|
||||||
|
PeopleService,
|
||||||
|
SeasonService,
|
||||||
|
ShowService
|
||||||
|
} from "./services/api.service";
|
||||||
|
import { Show } from "./models/resources/show";
|
||||||
|
import { ItemResolver } from "./services/item-resolver.service";
|
||||||
|
import { CollectionComponent } from "./pages/collection/collection.component";
|
||||||
|
import { Collection } from "./models/resources/collection";
|
||||||
|
import { SearchComponent } from "./pages/search/search.component";
|
||||||
|
import { SearchResult } from "./models/search-result";
|
||||||
|
import { PlayerComponent } from "./pages/player/player.component";
|
||||||
|
import { WatchItem } from "./models/watch-item";
|
||||||
|
|
||||||
|
const routes: Routes = [
|
||||||
|
{path: "browse", component: ItemsGridComponent, pathMatch: "full",
|
||||||
|
resolve: {items: PageResolver.forResource<LibraryItem>("items", ItemsGridComponent.routeMapper)},
|
||||||
|
// canLoad: [AuthGuard.forPermissions("read")],
|
||||||
|
// canActivate: [AuthGuard.forPermissions("read")],
|
||||||
|
runGuardsAndResolvers: "always"
|
||||||
|
},
|
||||||
|
{path: "browse/:slug", component: ItemsGridComponent,
|
||||||
|
resolve: {items: PageResolver.forResource<LibraryItem>("library/:slug/items", ItemsGridComponent.routeMapper)},
|
||||||
|
// canLoad: [AuthGuard.forPermissions("read")],
|
||||||
|
// canActivate: [AuthGuard.forPermissions("read")],
|
||||||
|
runGuardsAndResolvers: "always",
|
||||||
|
},
|
||||||
|
|
||||||
|
{path: "genre/:slug", component: ItemsGridComponent,
|
||||||
|
resolve: {items: PageResolver.forResource<Show>("shows", ItemsGridComponent.routeMapper, "genres=ctn::slug")},
|
||||||
|
// canLoad: [AuthGuard.forPermissions("read")],
|
||||||
|
// canActivate: [AuthGuard.forPermissions("read")],
|
||||||
|
runGuardsAndResolvers: "always"
|
||||||
|
},
|
||||||
|
{path: "studio/:slug", component: ItemsGridComponent,
|
||||||
|
resolve: {items: PageResolver.forResource<Show>("shows", ItemsGridComponent.routeMapper, "studio=:slug")},
|
||||||
|
// canLoad: [AuthGuard.forPermissions("read")],
|
||||||
|
// canActivate: [AuthGuard.forPermissions("read")],
|
||||||
|
runGuardsAndResolvers: "always"
|
||||||
|
},
|
||||||
|
|
||||||
|
{path: "collection/:slug", component: CollectionComponent,
|
||||||
|
resolve:
|
||||||
|
{
|
||||||
|
collection: ItemResolver.forResource<Collection>("collections/:slug"),
|
||||||
|
shows: PageResolver.forResource<Show>("collections/:slug/shows", ItemsGridComponent.routeMapper)
|
||||||
|
},
|
||||||
|
// canLoad: [AuthGuard.forPermissions("read")],
|
||||||
|
// canActivate: [AuthGuard.forPermissions("read")],
|
||||||
|
runGuardsAndResolvers: "always"
|
||||||
|
},
|
||||||
|
{path: "people/:slug", component: CollectionComponent,
|
||||||
|
resolve:
|
||||||
|
{
|
||||||
|
collection: ItemResolver.forResource<Collection>("people/:slug"),
|
||||||
|
shows: PageResolver.forResource<Show>("people/:slug/roles", ItemsGridComponent.routeMapper)
|
||||||
|
},
|
||||||
|
// canLoad: [AuthGuard.forPermissions("read")],
|
||||||
|
// canActivate: [AuthGuard.forPermissions("read")],
|
||||||
|
runGuardsAndResolvers: "always"
|
||||||
|
},
|
||||||
|
|
||||||
|
{path: "show/:slug", component: ShowDetailsComponent,
|
||||||
|
resolve: {show: ItemResolver.forResource<Show>("shows/:slug?fields=studio,genres,seasons,externalIDs")},
|
||||||
|
// canLoad: [AuthGuard.forPermissions("read")],
|
||||||
|
// canActivate: [AuthGuard.forPermissions("read")]
|
||||||
|
},
|
||||||
|
|
||||||
|
{path: "search/:query", component: SearchComponent,
|
||||||
|
resolve: {items: ItemResolver.forResource<SearchResult>("search/:query")},
|
||||||
|
// canLoad: [AuthGuard.forPermissions("read")],
|
||||||
|
// canActivate: [AuthGuard.forPermissions("read")]
|
||||||
|
},
|
||||||
|
|
||||||
|
{path: "watch/:item", component: PlayerComponent,
|
||||||
|
resolve: {item: ItemResolver.forResource<WatchItem>("watch/:item")},
|
||||||
|
// canLoad: [AuthGuard.forPermissions("play")],
|
||||||
|
// canActivate: [AuthGuard.forPermissions("play")]
|
||||||
|
},
|
||||||
|
|
||||||
|
// TODO implement an home page.
|
||||||
|
|
||||||
|
{path: "", pathMatch: "full", redirectTo: "/browse"},
|
||||||
|
{path: "**", component: NotFoundComponent}
|
||||||
|
];
|
||||||
|
|
||||||
|
@NgModule({
|
||||||
|
imports: [RouterModule.forRoot(routes,
|
||||||
|
{
|
||||||
|
scrollPositionRestoration: "enabled"
|
||||||
|
})],
|
||||||
|
exports: [RouterModule],
|
||||||
|
providers: [
|
||||||
|
LibraryService,
|
||||||
|
LibraryItemService,
|
||||||
|
PeopleService,
|
||||||
|
ShowService,
|
||||||
|
SeasonService,
|
||||||
|
EpisodeService,
|
||||||
|
PageResolver.resolvers,
|
||||||
|
ItemResolver.resolvers,
|
||||||
|
{provide: RouteReuseStrategy, useClass: CustomRouteReuseStrategy}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
export class AppRoutingModule { }
|
67
front/src/app/app.component.html
Normal file
67
front/src/app/app.component.html
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
<header id="nav">
|
||||||
|
<div class="fixed-top">
|
||||||
|
<nav id="toolbar" class="navbar navbar-dark bg-secondary flex-nowrap">
|
||||||
|
<button mat-icon-button class="icon p-0 d-sm-none" type="button" data-toggle="collapse" data-target=".mobile-nav">
|
||||||
|
<mat-icon>menu</mat-icon>
|
||||||
|
</button>
|
||||||
|
<a class="navbar-brand nav-item ml-3" routerLink="/">
|
||||||
|
Kyoo
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<ul class="navbar-nav flex-row d-none d-sm-flex">
|
||||||
|
<ng-container *ngTemplateOutlet="links"></ng-container>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<ul class="navbar-nav flex-row flex-nowrap ml-auto">
|
||||||
|
<li class="nav-item icon searchbar">
|
||||||
|
<mat-icon matTooltipPosition="below" matTooltip="Search" (click)="openSearch()">search</mat-icon>
|
||||||
|
<input placeholder="Search" id="search" type="search" (input)="onUpdateValue($any($event))"/>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
<li class="nav-item" *ngIf="!this.isAuthenticated else accountDrop">
|
||||||
|
<a class="icon" (click)="this.authManager.login()" matTooltipPosition="below" matTooltip="Login">
|
||||||
|
<mat-icon>account_circle</mat-icon>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<ng-template #accountDrop>
|
||||||
|
<li #accountParent class="nav-item icon" style="opacity: 1 !important;">
|
||||||
|
<img alt="Account"
|
||||||
|
matRipple
|
||||||
|
[src]="authManager.account.picture"
|
||||||
|
[matMenuTriggerFor]="accountMenu"
|
||||||
|
class="profilePicture"
|
||||||
|
matTooltipPosition="below"
|
||||||
|
[matTooltip]="authManager.account.username"
|
||||||
|
fallback="more.svg"
|
||||||
|
(error)="accountParent.style.removeProperty('opacity');" />
|
||||||
|
</li>
|
||||||
|
<mat-menu #accountMenu="matMenu">
|
||||||
|
<button class="dropButton" mat-menu-item (click)="this.openAccountDialog()">Settings</button>
|
||||||
|
<button class="dropButton" mat-menu-item (click)="this.authManager.logout()">Logout</button>
|
||||||
|
</mat-menu>
|
||||||
|
</ng-template>
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div class="d-sm-none navbar-dark bg-secondary">
|
||||||
|
<ul class="mobile-nav collapse navbar-nav">
|
||||||
|
<ng-container *ngTemplateOutlet="links"></ng-container>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<mat-progress-bar *ngIf="this.isLoading" color="accent" mode="indeterminate"> </mat-progress-bar>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main id="main">
|
||||||
|
<router-outlet></router-outlet>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<ng-template #links>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" routerLink="/browse" routerLinkActive="active" [routerLinkActiveOptions]="{exact: true}">All</a>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item" *ngFor="let library of this.libraries">
|
||||||
|
<a class="nav-link" routerLink="/browse/{{library.slug}}" routerLinkActive="active">{{library.name}}</a>
|
||||||
|
</li>
|
||||||
|
</ng-template>
|
161
front/src/app/app.component.scss
Normal file
161
front/src/app/app.component.scss
Normal file
@ -0,0 +1,161 @@
|
|||||||
|
@import "~bootstrap/scss/functions";
|
||||||
|
@import "~bootstrap/scss/variables";
|
||||||
|
@import "~bootstrap/scss/mixins/breakpoints";
|
||||||
|
@import "variables";
|
||||||
|
|
||||||
|
#toolbar
|
||||||
|
{
|
||||||
|
height: $nav-bar-height;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar
|
||||||
|
{
|
||||||
|
justify-content: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item
|
||||||
|
{
|
||||||
|
outline: none;
|
||||||
|
|
||||||
|
@include media-breakpoint-down(sm)
|
||||||
|
{
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
> a
|
||||||
|
{
|
||||||
|
outline: none;
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.link
|
||||||
|
{
|
||||||
|
outline: none;
|
||||||
|
color: inherit;
|
||||||
|
|
||||||
|
&:hover
|
||||||
|
{
|
||||||
|
text-decoration: none !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-link
|
||||||
|
{
|
||||||
|
padding: 12px;
|
||||||
|
color: rgba(255, 255, 255, 0.7) !important;
|
||||||
|
|
||||||
|
&:host-context(.hoverEnabled) &:hover, &:focus
|
||||||
|
{
|
||||||
|
color: white !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.active
|
||||||
|
{
|
||||||
|
color: var(--accentColor) !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar-brand
|
||||||
|
{
|
||||||
|
&:hover
|
||||||
|
{
|
||||||
|
color: var(--accentColor);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 350px)
|
||||||
|
{
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.searchbar
|
||||||
|
{
|
||||||
|
border-radius: 30px;
|
||||||
|
display: flex !important;
|
||||||
|
flex-flow: row-reverse nowrap;
|
||||||
|
|
||||||
|
> input
|
||||||
|
{
|
||||||
|
background: none !important;
|
||||||
|
color: white;
|
||||||
|
outline: none;
|
||||||
|
border: none;
|
||||||
|
border-bottom: 1px solid #cfcfcf;
|
||||||
|
width: 0;
|
||||||
|
padding: 0;
|
||||||
|
max-width: 20rem;
|
||||||
|
transition: width 0.4s ease-in-out;
|
||||||
|
|
||||||
|
&:focus, &.searching
|
||||||
|
{
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
@include media-breakpoint-up(md)
|
||||||
|
{
|
||||||
|
width: 20rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
input::-webkit-search-cancel-button
|
||||||
|
{
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon
|
||||||
|
{
|
||||||
|
padding: 8px;
|
||||||
|
display: inline-block;
|
||||||
|
opacity: 0.7;
|
||||||
|
outline: none;
|
||||||
|
|
||||||
|
&:host-context(.hoverEnabled) &:hover, &:focus
|
||||||
|
{
|
||||||
|
cursor: pointer;
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
.profilePicture
|
||||||
|
{
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
display: inline-block;
|
||||||
|
vertical-align: middle;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropButton
|
||||||
|
{
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
main
|
||||||
|
{
|
||||||
|
margin-top: $nav-bar-height;
|
||||||
|
padding-top: 4px;
|
||||||
|
max-height: calc(100vh - #{$nav-bar-height});
|
||||||
|
display: block;
|
||||||
|
overflow-y: auto;
|
||||||
|
scrollbar-color: #999 transparent;
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
&::-webkit-scrollbar
|
||||||
|
{
|
||||||
|
width: 8px;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
&::-webkit-scrollbar-thumb
|
||||||
|
{
|
||||||
|
background-color: #999;
|
||||||
|
|
||||||
|
&:host-context(.hoverEnabled) &:hover
|
||||||
|
{
|
||||||
|
background-color: rgb(134, 127, 127);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
100
front/src/app/app.component.ts
Normal file
100
front/src/app/app.component.ts
Normal file
@ -0,0 +1,100 @@
|
|||||||
|
import { Component } from "@angular/core";
|
||||||
|
import {
|
||||||
|
Event,
|
||||||
|
Router,
|
||||||
|
NavigationStart,
|
||||||
|
NavigationEnd,
|
||||||
|
NavigationCancel,
|
||||||
|
NavigationError
|
||||||
|
} from "@angular/router";
|
||||||
|
import { Location } from "@angular/common";
|
||||||
|
import { MatDialog } from "@angular/material/dialog";
|
||||||
|
import { AccountComponent } from "./auth/account/account.component";
|
||||||
|
import { AuthService } from "./auth/auth.service";
|
||||||
|
import { Library } from "./models/resources/library";
|
||||||
|
import { LibraryService } from "./services/api.service";
|
||||||
|
// noinspection ES6UnusedImports
|
||||||
|
import * as $ from "jquery";
|
||||||
|
import ChangeEvent = JQuery.ChangeEvent;
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: "app-root",
|
||||||
|
templateUrl: "./app.component.html",
|
||||||
|
styleUrls: ["./app.component.scss"]
|
||||||
|
})
|
||||||
|
export class AppComponent
|
||||||
|
{
|
||||||
|
static isMobile: boolean = false;
|
||||||
|
libraries: Library[];
|
||||||
|
isLoading: boolean = false;
|
||||||
|
|
||||||
|
|
||||||
|
constructor(private libraryService: LibraryService,
|
||||||
|
private router: Router,
|
||||||
|
private location: Location,
|
||||||
|
public authManager: AuthService,
|
||||||
|
public dialog: MatDialog)
|
||||||
|
{
|
||||||
|
libraryService.getAll().subscribe(result =>
|
||||||
|
{
|
||||||
|
this.libraries = result.items;
|
||||||
|
}, error => console.error(error));
|
||||||
|
|
||||||
|
this.router.events.subscribe((event: Event) =>
|
||||||
|
{
|
||||||
|
switch (true)
|
||||||
|
{
|
||||||
|
case event instanceof NavigationStart:
|
||||||
|
this.isLoading = true;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case event instanceof NavigationEnd:
|
||||||
|
case event instanceof NavigationCancel:
|
||||||
|
case event instanceof NavigationError:
|
||||||
|
this.isLoading = false;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
AppComponent.isMobile = !!navigator.userAgent.match(/Mobi/);
|
||||||
|
if (!AppComponent.isMobile)
|
||||||
|
document.body.classList.add("hoverEnabled");
|
||||||
|
}
|
||||||
|
|
||||||
|
get isAuthenticated(): boolean
|
||||||
|
{
|
||||||
|
return this.authManager.isAuthenticated;
|
||||||
|
}
|
||||||
|
|
||||||
|
openSearch(): void
|
||||||
|
{
|
||||||
|
const input: HTMLInputElement = document.getElementById("search") as HTMLInputElement;
|
||||||
|
|
||||||
|
input.value = "";
|
||||||
|
input.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
onUpdateValue(event: ChangeEvent<HTMLInputElement>): void
|
||||||
|
{
|
||||||
|
const query: string = event.target.value;
|
||||||
|
if (query !== "")
|
||||||
|
{
|
||||||
|
event.target.classList.add("searching");
|
||||||
|
this.router.navigate(["/search", query], {
|
||||||
|
replaceUrl: this.router.url.startsWith("/search")
|
||||||
|
});
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
event.target.classList.remove("searching");
|
||||||
|
this.location.back();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
openAccountDialog(): void
|
||||||
|
{
|
||||||
|
this.dialog.open(AccountComponent, {width: "500px", data: this.authManager.account});
|
||||||
|
}
|
||||||
|
}
|
132
front/src/app/app.module.ts
Normal file
132
front/src/app/app.module.ts
Normal file
@ -0,0 +1,132 @@
|
|||||||
|
import { HTTP_INTERCEPTORS, HttpClientModule } from "@angular/common/http";
|
||||||
|
import { APP_INITIALIZER, NgModule } from "@angular/core";
|
||||||
|
import { MatButtonModule } from "@angular/material/button";
|
||||||
|
import { MatCardModule } from "@angular/material/card";
|
||||||
|
import { MatNativeDateModule, MatRippleModule } from "@angular/material/core";
|
||||||
|
import { MatIconModule } from "@angular/material/icon";
|
||||||
|
import { MatMenuModule } from "@angular/material/menu";
|
||||||
|
import { MatProgressBarModule } from "@angular/material/progress-bar";
|
||||||
|
import { MatSelectModule } from "@angular/material/select";
|
||||||
|
import { MatSliderModule } from "@angular/material/slider";
|
||||||
|
import { MatSnackBarModule } from "@angular/material/snack-bar";
|
||||||
|
import { MatTooltipModule } from "@angular/material/tooltip";
|
||||||
|
import { BrowserModule, HammerModule } from "@angular/platform-browser";
|
||||||
|
import { BrowserAnimationsModule } from "@angular/platform-browser/animations";
|
||||||
|
import { AppRoutingModule } from "./app-routing.module";
|
||||||
|
import { AppComponent } from "./app.component";
|
||||||
|
import { ItemsGridComponent } from "./components/items-grid/items-grid.component";
|
||||||
|
import { CollectionComponent } from "./pages/collection/collection.component";
|
||||||
|
import { EpisodesListComponent } from "./components/episodes-list/episodes-list.component";
|
||||||
|
import { NotFoundComponent } from "./pages/not-found/not-found.component";
|
||||||
|
import { PeopleListComponent } from "./components/people-list/people-list.component";
|
||||||
|
import {
|
||||||
|
BufferToWidthPipe,
|
||||||
|
FormatTimePipe,
|
||||||
|
PlayerComponent, SupportedButtonPipe,
|
||||||
|
VolumeToButtonPipe
|
||||||
|
} from "./pages/player/player.component";
|
||||||
|
import { SearchComponent } from "./pages/search/search.component";
|
||||||
|
import { ShowDetailsComponent } from "./pages/show-details/show-details.component";
|
||||||
|
import { FormsModule , ReactiveFormsModule } from "@angular/forms";
|
||||||
|
import { MatInputModule } from "@angular/material/input";
|
||||||
|
import { MatFormFieldModule } from "@angular/material/form-field";
|
||||||
|
import { MatTabsModule } from "@angular/material/tabs";
|
||||||
|
import { PasswordValidator } from "./misc/password-validator";
|
||||||
|
import { MatCheckboxModule } from "@angular/material/checkbox";
|
||||||
|
import { MatDialogModule } from "@angular/material/dialog";
|
||||||
|
import { FallbackDirective, FallbackPipe } from "./misc/fallback.directive";
|
||||||
|
import { AuthModule } from "./auth/auth.module";
|
||||||
|
import { AuthRoutingModule } from "./auth/auth-routing.module";
|
||||||
|
import { TrailerDialogComponent } from "./pages/trailer-dialog/trailer-dialog.component";
|
||||||
|
import { ItemsListComponent } from "./components/items-list/items-list.component";
|
||||||
|
import { MetadataEditComponent } from "./pages/metadata-edit/metadata-edit.component";
|
||||||
|
import { MatChipsModule } from "@angular/material/chips";
|
||||||
|
import { MatAutocompleteModule } from "@angular/material/autocomplete";
|
||||||
|
import { MatExpansionModule } from "@angular/material/expansion";
|
||||||
|
import { InfiniteScrollModule } from "ngx-infinite-scroll";
|
||||||
|
import { ShowGridComponent } from "./components/show-grid/show-grid.component";
|
||||||
|
import { MatBadgeModule } from "@angular/material/badge";
|
||||||
|
import { StartupService } from "./services/startup.service";
|
||||||
|
import { LongPressDirective } from "./misc/long-press.directive";
|
||||||
|
import { DatetimeInterceptorService } from "./services/datetime-interceptor.service";
|
||||||
|
import { MatDatepickerModule } from "@angular/material/datepicker";
|
||||||
|
|
||||||
|
|
||||||
|
@NgModule({
|
||||||
|
declarations: [
|
||||||
|
AppComponent,
|
||||||
|
NotFoundComponent,
|
||||||
|
ItemsGridComponent,
|
||||||
|
ShowDetailsComponent,
|
||||||
|
EpisodesListComponent,
|
||||||
|
PlayerComponent,
|
||||||
|
CollectionComponent,
|
||||||
|
SearchComponent,
|
||||||
|
PeopleListComponent,
|
||||||
|
PasswordValidator,
|
||||||
|
FallbackDirective,
|
||||||
|
TrailerDialogComponent,
|
||||||
|
ItemsListComponent,
|
||||||
|
MetadataEditComponent,
|
||||||
|
ShowGridComponent,
|
||||||
|
FormatTimePipe,
|
||||||
|
BufferToWidthPipe,
|
||||||
|
VolumeToButtonPipe,
|
||||||
|
SupportedButtonPipe,
|
||||||
|
LongPressDirective,
|
||||||
|
FallbackPipe
|
||||||
|
],
|
||||||
|
imports: [
|
||||||
|
BrowserModule,
|
||||||
|
HttpClientModule,
|
||||||
|
AuthRoutingModule,
|
||||||
|
AppRoutingModule,
|
||||||
|
BrowserAnimationsModule,
|
||||||
|
MatSnackBarModule,
|
||||||
|
MatProgressBarModule,
|
||||||
|
MatButtonModule,
|
||||||
|
MatIconModule,
|
||||||
|
MatSelectModule,
|
||||||
|
MatMenuModule,
|
||||||
|
MatSliderModule,
|
||||||
|
MatTooltipModule,
|
||||||
|
MatRippleModule,
|
||||||
|
MatCardModule,
|
||||||
|
ReactiveFormsModule,
|
||||||
|
MatInputModule,
|
||||||
|
MatFormFieldModule,
|
||||||
|
MatDialogModule,
|
||||||
|
FormsModule,
|
||||||
|
MatTabsModule,
|
||||||
|
MatCheckboxModule,
|
||||||
|
AuthModule,
|
||||||
|
MatChipsModule,
|
||||||
|
MatAutocompleteModule,
|
||||||
|
MatExpansionModule,
|
||||||
|
InfiniteScrollModule,
|
||||||
|
MatBadgeModule,
|
||||||
|
HammerModule,
|
||||||
|
MatDatepickerModule,
|
||||||
|
MatNativeDateModule
|
||||||
|
],
|
||||||
|
bootstrap: [AppComponent],
|
||||||
|
exports: [
|
||||||
|
FallbackDirective,
|
||||||
|
FallbackPipe
|
||||||
|
],
|
||||||
|
providers: [
|
||||||
|
StartupService,
|
||||||
|
{
|
||||||
|
provide: APP_INITIALIZER,
|
||||||
|
useFactory: (startup: StartupService) => () => startup.load(),
|
||||||
|
deps: [StartupService],
|
||||||
|
multi: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: HTTP_INTERCEPTORS,
|
||||||
|
useClass: DatetimeInterceptorService,
|
||||||
|
multi: true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
export class AppModule { }
|
27
front/src/app/auth/account/account.component.html
Normal file
27
front/src/app/auth/account/account.component.html
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
<h1 mat-dialog-title>Account</h1>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-8">
|
||||||
|
<mat-form-field class="w-75">
|
||||||
|
<mat-label>Email</mat-label>
|
||||||
|
<input matInput name="accountEmail" [(ngModel)]="account.email" required email>
|
||||||
|
</mat-form-field>
|
||||||
|
<br/>
|
||||||
|
<mat-form-field class="w-75">
|
||||||
|
<mat-label>Username</mat-label>
|
||||||
|
<input matInput name="accountUsername" [(ngModel)]="account.username" required>
|
||||||
|
</mat-form-field>
|
||||||
|
</div>
|
||||||
|
<div class="col-4">
|
||||||
|
<input type="file" class="d-none" (change)="onPictureSelected($event)" #fileInput/>
|
||||||
|
<div class="w-100 profilePicture">
|
||||||
|
<img [src]="account.picture" alt="Profile picture" fallback="account.svg" #accountImg/>
|
||||||
|
</div>
|
||||||
|
<button mat-icon-button class="upload_picture" matTooltipPosition="above" matTooltip="Upload picture" (click)="fileInput.click()">
|
||||||
|
<mat-icon>photo_camera</mat-icon>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div mat-dialog-actions fxFlexAlign="end" align="end" style="text-align: end">
|
||||||
|
<button mat-button (click)="cancel()">Cancel</button>
|
||||||
|
<button mat-button (click)="finish()" cdkFocusInitial>Ok</button>
|
||||||
|
</div>
|
38
front/src/app/auth/account/account.component.scss
Normal file
38
front/src/app/auth/account/account.component.scss
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
*
|
||||||
|
{
|
||||||
|
box-sizing: border-box;
|
||||||
|
outline: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
*:before, *:after
|
||||||
|
{
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload_picture
|
||||||
|
{
|
||||||
|
position: absolute;
|
||||||
|
bottom: 2%;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
margin: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profilePicture
|
||||||
|
{
|
||||||
|
padding-top: 100%;
|
||||||
|
height: 0;
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
> img
|
||||||
|
{
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
}
|
49
front/src/app/auth/account/account.component.ts
Normal file
49
front/src/app/auth/account/account.component.ts
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
import { Component, ElementRef, Inject, ViewChild } from "@angular/core";
|
||||||
|
import { MAT_DIALOG_DATA, MatDialogRef } from "@angular/material/dialog";
|
||||||
|
import { HttpClient } from "@angular/common/http";
|
||||||
|
import { Account } from "../../models/account";
|
||||||
|
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: "app-account",
|
||||||
|
templateUrl: "./account.component.html",
|
||||||
|
styleUrls: ["./account.component.scss"]
|
||||||
|
})
|
||||||
|
export class AccountComponent
|
||||||
|
{
|
||||||
|
selectedPicture: File;
|
||||||
|
@ViewChild("accountImg") accountImg: ElementRef;
|
||||||
|
|
||||||
|
constructor(public dialogRef: MatDialogRef<AccountComponent>,
|
||||||
|
@Inject(MAT_DIALOG_DATA) public account: Account,
|
||||||
|
private http: HttpClient) {}
|
||||||
|
|
||||||
|
finish(): void
|
||||||
|
{
|
||||||
|
const data: FormData = new FormData();
|
||||||
|
data.append("email", this.account.email);
|
||||||
|
data.append("username", this.account.username);
|
||||||
|
data.append("picture", this.selectedPicture);
|
||||||
|
|
||||||
|
this.http.post("api/account/update", data).subscribe(() =>
|
||||||
|
{
|
||||||
|
this.dialogRef.close(this.account);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
cancel(): void
|
||||||
|
{
|
||||||
|
this.dialogRef.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
onPictureSelected(event: any): void
|
||||||
|
{
|
||||||
|
this.selectedPicture = event.target.files[0];
|
||||||
|
const reader: FileReader = new FileReader();
|
||||||
|
reader.onloadend = () =>
|
||||||
|
{
|
||||||
|
this.accountImg.nativeElement.src = reader.result;
|
||||||
|
};
|
||||||
|
reader.readAsDataURL(this.selectedPicture);
|
||||||
|
}
|
||||||
|
}
|
15
front/src/app/auth/auth-routing.module.ts
Normal file
15
front/src/app/auth/auth-routing.module.ts
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
import { NgModule } from "@angular/core";
|
||||||
|
import { RouterModule, Routes } from "@angular/router";
|
||||||
|
import { UnauthorizedComponent } from "./unauthorized/unauthorized.component";
|
||||||
|
import { LogoutComponent } from "./logout/logout.component";
|
||||||
|
|
||||||
|
const routes: Routes = [
|
||||||
|
{path: "logout", component: LogoutComponent},
|
||||||
|
{path: "unauthorized", component: UnauthorizedComponent},
|
||||||
|
];
|
||||||
|
|
||||||
|
@NgModule({
|
||||||
|
imports: [RouterModule.forChild(routes)],
|
||||||
|
exports: [RouterModule]
|
||||||
|
})
|
||||||
|
export class AuthRoutingModule { }
|
91
front/src/app/auth/auth.module.ts
Normal file
91
front/src/app/auth/auth.module.ts
Normal file
@ -0,0 +1,91 @@
|
|||||||
|
import { CommonModule } from "@angular/common";
|
||||||
|
import { HTTP_INTERCEPTORS, HttpClient } from "@angular/common/http";
|
||||||
|
import { NgModule } from "@angular/core";
|
||||||
|
import { FormsModule } from "@angular/forms";
|
||||||
|
import { MatButtonModule } from "@angular/material/button";
|
||||||
|
import { MatCardModule } from "@angular/material/card";
|
||||||
|
import { MatCheckboxModule } from "@angular/material/checkbox";
|
||||||
|
import { MatRippleModule } from "@angular/material/core";
|
||||||
|
import { MatDialogModule } from "@angular/material/dialog";
|
||||||
|
import { MatFormFieldModule } from "@angular/material/form-field";
|
||||||
|
import { MatIconModule } from "@angular/material/icon";
|
||||||
|
import { MatInputModule } from "@angular/material/input";
|
||||||
|
import { MatMenuModule } from "@angular/material/menu";
|
||||||
|
import { MatSelectModule } from "@angular/material/select";
|
||||||
|
import { MatSliderModule } from "@angular/material/slider";
|
||||||
|
import { MatTabsModule } from "@angular/material/tabs";
|
||||||
|
import { MatTooltipModule } from "@angular/material/tooltip";
|
||||||
|
import { RouterModule } from "@angular/router";
|
||||||
|
import { AuthModule as OidcModule, LogLevel } from "angular-auth-oidc-client";
|
||||||
|
import { tap } from "rxjs/operators";
|
||||||
|
import { AccountComponent } from "./account/account.component";
|
||||||
|
import { LogoutComponent } from "./logout/logout.component";
|
||||||
|
import { AuthPipe } from "./misc/auth.pipe";
|
||||||
|
import { AuthGuard } from "./misc/authenticated-guard.service";
|
||||||
|
import { AuthorizerInterceptor } from "./misc/authorizer-interceptor.service";
|
||||||
|
import { UnauthorizedComponent } from "./unauthorized/unauthorized.component";
|
||||||
|
|
||||||
|
@NgModule({
|
||||||
|
declarations: [
|
||||||
|
AuthPipe,
|
||||||
|
AccountComponent,
|
||||||
|
UnauthorizedComponent,
|
||||||
|
LogoutComponent
|
||||||
|
],
|
||||||
|
imports: [
|
||||||
|
CommonModule,
|
||||||
|
MatButtonModule,
|
||||||
|
MatIconModule,
|
||||||
|
MatSelectModule,
|
||||||
|
MatMenuModule,
|
||||||
|
MatSliderModule,
|
||||||
|
MatTooltipModule,
|
||||||
|
MatRippleModule,
|
||||||
|
MatCardModule,
|
||||||
|
MatInputModule,
|
||||||
|
MatFormFieldModule,
|
||||||
|
MatDialogModule,
|
||||||
|
FormsModule,
|
||||||
|
MatTabsModule,
|
||||||
|
MatCheckboxModule,
|
||||||
|
OidcModule.forRoot({
|
||||||
|
config: {
|
||||||
|
authority: window.location.origin,
|
||||||
|
redirectUrl: `${window.location.origin}/`,
|
||||||
|
postLogoutRedirectUri: `${window.location.origin}/logout`,
|
||||||
|
clientId: "kyoo.webapp",
|
||||||
|
responseType: "code",
|
||||||
|
triggerAuthorizationResultEvent: false,
|
||||||
|
scope: "openid profile offline_access kyoo.read kyoo.write kyoo.play kyoo.admin",
|
||||||
|
silentRenew: true,
|
||||||
|
silentRenewUrl: `${window.location.origin}/silent.html`,
|
||||||
|
useRefreshToken: true,
|
||||||
|
startCheckSession: true,
|
||||||
|
|
||||||
|
forbiddenRoute: `${window.location.origin}/forbidden`,
|
||||||
|
unauthorizedRoute: `${window.location.origin}/unauthorized`,
|
||||||
|
logLevel: LogLevel.Warn
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
RouterModule
|
||||||
|
],
|
||||||
|
entryComponents: [
|
||||||
|
AccountComponent
|
||||||
|
],
|
||||||
|
providers: [
|
||||||
|
AuthGuard.guards,
|
||||||
|
{
|
||||||
|
provide: HTTP_INTERCEPTORS,
|
||||||
|
useClass: AuthorizerInterceptor,
|
||||||
|
multi: true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
export class AuthModule
|
||||||
|
{
|
||||||
|
constructor(http: HttpClient)
|
||||||
|
{
|
||||||
|
AuthGuard.permissionsObservable = http.get<string[]>("/api/account/permissions")
|
||||||
|
.pipe(tap(x => AuthGuard.defaultPermissions = x));
|
||||||
|
}
|
||||||
|
}
|
47
front/src/app/auth/auth.service.ts
Normal file
47
front/src/app/auth/auth.service.ts
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
import { Injectable } from "@angular/core";
|
||||||
|
import { LoginResponse, OidcSecurityService } from "angular-auth-oidc-client";
|
||||||
|
import { Account } from "../models/account";
|
||||||
|
import { HttpClient } from "@angular/common/http";
|
||||||
|
|
||||||
|
@Injectable({
|
||||||
|
providedIn: "root"
|
||||||
|
})
|
||||||
|
export class AuthService
|
||||||
|
{
|
||||||
|
isAuthenticated: boolean = false;
|
||||||
|
account: Account = null;
|
||||||
|
|
||||||
|
constructor(private oidcSecurityService: OidcSecurityService, private http: HttpClient)
|
||||||
|
{
|
||||||
|
this.oidcSecurityService.checkAuth()
|
||||||
|
.subscribe((auth: LoginResponse) => this.isAuthenticated = auth.isAuthenticated);
|
||||||
|
this.oidcSecurityService.userData$.subscribe(x =>
|
||||||
|
{
|
||||||
|
if (x?.userData == null)
|
||||||
|
{
|
||||||
|
this.account = null;
|
||||||
|
this.isAuthenticated = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.account = {
|
||||||
|
email: x.userData.email,
|
||||||
|
username: x.userData.username,
|
||||||
|
picture: x.userData.picture,
|
||||||
|
permissions: x.userData.permissions?.split(",") ?? []
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
login(): void
|
||||||
|
{
|
||||||
|
this.oidcSecurityService.authorize();
|
||||||
|
}
|
||||||
|
|
||||||
|
logout(): void
|
||||||
|
{
|
||||||
|
this.http.get("api/account/logout").subscribe(() =>
|
||||||
|
{
|
||||||
|
this.oidcSecurityService.logoff();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
9
front/src/app/auth/logout/logout.component.html
Normal file
9
front/src/app/auth/logout/logout.component.html
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
<br/>
|
||||||
|
<br/>
|
||||||
|
<br/>
|
||||||
|
<br/>
|
||||||
|
<br/>
|
||||||
|
<div class="text-center">
|
||||||
|
<h1>Successfully logged out.</h1>
|
||||||
|
<p>Go back to the <a href="/" routerLink="/" class="text-white">main page</a></p>
|
||||||
|
</div>
|
0
front/src/app/auth/logout/logout.component.scss
Normal file
0
front/src/app/auth/logout/logout.component.scss
Normal file
8
front/src/app/auth/logout/logout.component.ts
Normal file
8
front/src/app/auth/logout/logout.component.ts
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
import { Component } from "@angular/core";
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: "app-logout",
|
||||||
|
templateUrl: "./logout.component.html",
|
||||||
|
styleUrls: ["./logout.component.scss"]
|
||||||
|
})
|
||||||
|
export class LogoutComponent {}
|
29
front/src/app/auth/misc/auth.pipe.ts
Normal file
29
front/src/app/auth/misc/auth.pipe.ts
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
import { Injector, Pipe, PipeTransform } from "@angular/core";
|
||||||
|
import { HttpClient, HttpHeaders } from "@angular/common/http";
|
||||||
|
import { OidcSecurityService } from "angular-auth-oidc-client";
|
||||||
|
|
||||||
|
@Pipe({
|
||||||
|
name: "auth"
|
||||||
|
})
|
||||||
|
export class AuthPipe implements PipeTransform
|
||||||
|
{
|
||||||
|
private oidcSecurity: OidcSecurityService;
|
||||||
|
|
||||||
|
constructor(private injector: Injector, private http: HttpClient) {}
|
||||||
|
|
||||||
|
async transform(uri: string): Promise<string>
|
||||||
|
{
|
||||||
|
if (this.oidcSecurity === undefined)
|
||||||
|
this.oidcSecurity = this.injector.get(OidcSecurityService);
|
||||||
|
const token: string = this.oidcSecurity.getAccessToken();
|
||||||
|
if (!token)
|
||||||
|
return uri;
|
||||||
|
const headers: HttpHeaders = new HttpHeaders({Authorization: "Bearer " + token});
|
||||||
|
const img: Blob = await this.http.get(uri, {headers, responseType: "blob"}).toPromise();
|
||||||
|
const reader: FileReader = new FileReader();
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
reader.onloadend = () => resolve(reader.result as string);
|
||||||
|
reader.readAsDataURL(img);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
79
front/src/app/auth/misc/authenticated-guard.service.ts
Normal file
79
front/src/app/auth/misc/authenticated-guard.service.ts
Normal file
@ -0,0 +1,79 @@
|
|||||||
|
import { Injectable } from "@angular/core";
|
||||||
|
import { CanActivate, CanLoad, Router } from "@angular/router";
|
||||||
|
import { Observable } from "rxjs";
|
||||||
|
import { AuthService } from "../auth.service";
|
||||||
|
|
||||||
|
@Injectable({providedIn: "root"})
|
||||||
|
export class AuthGuard
|
||||||
|
{
|
||||||
|
public static guards: any[] = [];
|
||||||
|
public static defaultPermissions: string[] = undefined;
|
||||||
|
public static permissionsObservable: Observable<string[]>;
|
||||||
|
|
||||||
|
static forPermissions(...permissions: string[]): any
|
||||||
|
{
|
||||||
|
@Injectable()
|
||||||
|
class AuthenticatedGuard implements CanActivate, CanLoad
|
||||||
|
{
|
||||||
|
constructor(private router: Router, private authManager: AuthService) {}
|
||||||
|
|
||||||
|
async canActivate(): Promise<boolean>
|
||||||
|
{
|
||||||
|
if (!await this.checkPermissions())
|
||||||
|
{
|
||||||
|
await this.router.navigate(["/unauthorized"]);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async canLoad(): Promise<boolean>
|
||||||
|
{
|
||||||
|
if (!await this.checkPermissions())
|
||||||
|
{
|
||||||
|
await this.router.navigate(["/unauthorized"]);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async checkPermissions(): Promise<boolean>
|
||||||
|
{
|
||||||
|
if (this.authManager.isAuthenticated)
|
||||||
|
{
|
||||||
|
const perms: string[] = this.authManager.account.permissions;
|
||||||
|
for (const perm of permissions) {
|
||||||
|
if (!perms.includes(perm))
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
if (AuthGuard.defaultPermissions === undefined)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await AuthGuard.permissionsObservable.toPromise();
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
AuthGuard.defaultPermissions = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!AuthGuard.defaultPermissions)
|
||||||
|
return true;
|
||||||
|
|
||||||
|
for (const perm of permissions)
|
||||||
|
if (!AuthGuard.defaultPermissions.includes(perm))
|
||||||
|
return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
AuthGuard.guards.push(AuthenticatedGuard);
|
||||||
|
return AuthenticatedGuard;
|
||||||
|
}
|
||||||
|
}
|
30
front/src/app/auth/misc/authorizer-interceptor.service.ts
Normal file
30
front/src/app/auth/misc/authorizer-interceptor.service.ts
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
import { Injectable, Injector } from "@angular/core";
|
||||||
|
import {
|
||||||
|
HttpRequest,
|
||||||
|
HttpHandler,
|
||||||
|
HttpEvent,
|
||||||
|
HttpInterceptor
|
||||||
|
} from "@angular/common/http";
|
||||||
|
import { Observable } from "rxjs";
|
||||||
|
import { OidcSecurityService } from "angular-auth-oidc-client";
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AuthorizerInterceptor implements HttpInterceptor
|
||||||
|
{
|
||||||
|
private oidcSecurity: OidcSecurityService;
|
||||||
|
|
||||||
|
|
||||||
|
constructor(private injector: Injector) {}
|
||||||
|
|
||||||
|
intercept(request: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>>
|
||||||
|
{
|
||||||
|
if (request.url.startsWith("http"))
|
||||||
|
return next.handle(request);
|
||||||
|
if (this.oidcSecurity === undefined)
|
||||||
|
this.oidcSecurity = this.injector.get(OidcSecurityService);
|
||||||
|
const token: string = this.oidcSecurity.getAccessToken();
|
||||||
|
if (token)
|
||||||
|
request = request.clone({setHeaders: {Authorization: "Bearer " + token}});
|
||||||
|
return next.handle(request);
|
||||||
|
}
|
||||||
|
}
|
11
front/src/app/auth/unauthorized/unauthorized.component.html
Normal file
11
front/src/app/auth/unauthorized/unauthorized.component.html
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
<br/>
|
||||||
|
<br/>
|
||||||
|
<br/>
|
||||||
|
<br/>
|
||||||
|
<br/>
|
||||||
|
<div class="text-center">
|
||||||
|
<h1>Unauthorized</h1>
|
||||||
|
<p>You don't have enough permissions to view this page.
|
||||||
|
<span *ngIf="!this.isLoggedIn()"><br/>Sign in and try again.</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
17
front/src/app/auth/unauthorized/unauthorized.component.ts
Normal file
17
front/src/app/auth/unauthorized/unauthorized.component.ts
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
import { Component } from "@angular/core";
|
||||||
|
import { AuthService } from "../auth.service";
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: "app-unauthorized",
|
||||||
|
templateUrl: "./unauthorized.component.html",
|
||||||
|
styleUrls: ["./unauthorized.component.scss"]
|
||||||
|
})
|
||||||
|
export class UnauthorizedComponent
|
||||||
|
{
|
||||||
|
constructor(private authManager: AuthService) { }
|
||||||
|
|
||||||
|
isLoggedIn(): boolean
|
||||||
|
{
|
||||||
|
return this.authManager.isAuthenticated;
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,48 @@
|
|||||||
|
<div class="root">
|
||||||
|
<div class="episodes" #scrollView
|
||||||
|
(scroll)="onScroll()" infinite-scroll (scrolled)="this.episodes?.loadNext(this.client)"
|
||||||
|
[horizontal]="true" [scrollWindow]="false">
|
||||||
|
<a *ngFor="let episode of this.episodes?.items; index as i;" draggable="false"
|
||||||
|
routerLink="/watch/{{episode.slug}}" href="/watch/{{episode.slug}}"
|
||||||
|
appLongPress (longPressed)="this.openMenu(i)"
|
||||||
|
class="episode" #itemsDom>
|
||||||
|
<button mat-icon-button class="moreBtn" tabindex="-1"
|
||||||
|
[style.display]="this.openedIndex === i ? 'block' : undefined"
|
||||||
|
[matMenuTriggerFor]="more" [matMenuTriggerData]="{episode: episode}"
|
||||||
|
(menuOpened)="this.openedIndex = i" (menuClosed)="this.openedIndex = undefined"
|
||||||
|
(click)="$event.stopImmediatePropagation(); $event.preventDefault();">
|
||||||
|
<mat-icon>more_vert</mat-icon>
|
||||||
|
</button>
|
||||||
|
<div>
|
||||||
|
<div matRipple class="img" [style.background-image]="sanitize(episode.thumbnail)">
|
||||||
|
<button mat-icon-button class="playBtn" tabindex="-1">
|
||||||
|
<mat-icon class="playIcon">play_circle_outline</mat-icon>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<ng-container *ngIf="displayShowTitle; else noTitle;">
|
||||||
|
<h6 *ngIf="episode.seasonNumber != 0; else elseBlock;" class="title">{{episode.show.title}} - S{{episode.seasonNumber}}:E{{episode.episodeNumber}}</h6>
|
||||||
|
<ng-template #elseBlock><h6 class="title">{{episode.show.title}}</h6></ng-template>
|
||||||
|
<p class="subtitle">{{episode.title}}</p>
|
||||||
|
</ng-container>
|
||||||
|
<ng-template #noTitle>
|
||||||
|
<h6 *ngIf="episode.seasonNumber != 0; else elseBlock;" class="title">S{{episode.seasonNumber}}:E{{episode.episodeNumber}} - {{episode.title}}</h6>
|
||||||
|
<ng-template #elseBlock><h6 class="title">{{episode.title}}</h6></ng-template>
|
||||||
|
<p class="overview">{{episode.overview}}</p>
|
||||||
|
</ng-template>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<mat-menu #more="matMenu">
|
||||||
|
<ng-template matMenuContent let-episode="episode">
|
||||||
|
<a [href]="'/video/' + episode.slug" download><button mat-menu-item>Download episode</button></a>
|
||||||
|
</ng-template>
|
||||||
|
</mat-menu>
|
||||||
|
|
||||||
|
<button mat-raised-button color="accent" class="scrollBtn leftBtn d-none" #leftBtn (click)="scrollLeft()">
|
||||||
|
<mat-icon>arrow_left</mat-icon>
|
||||||
|
</button>
|
||||||
|
<button mat-raised-button color="accent" class="scrollBtn rightBtn" #rightBtn (click)="scrollRight()">
|
||||||
|
<mat-icon>arrow_right</mat-icon>
|
||||||
|
</button>
|
||||||
|
</div>
|
@ -0,0 +1,210 @@
|
|||||||
|
@import "~bootstrap/scss/functions";
|
||||||
|
@import "~bootstrap/scss/variables";
|
||||||
|
@import "~bootstrap/scss/mixins/breakpoints";
|
||||||
|
|
||||||
|
.root
|
||||||
|
{
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
&:host-context(.hoverEnabled) &:hover
|
||||||
|
{
|
||||||
|
.scrollBtn
|
||||||
|
{
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.episodes
|
||||||
|
{
|
||||||
|
display: flex;
|
||||||
|
padding-left: 15px;
|
||||||
|
padding-right: 15px;
|
||||||
|
overflow-x: auto;
|
||||||
|
min-width: 100%;
|
||||||
|
flex-shrink: 0;
|
||||||
|
flex-direction: row;
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: #999 transparent;
|
||||||
|
|
||||||
|
&::-webkit-scrollbar
|
||||||
|
{
|
||||||
|
height: 4px;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
&::-webkit-scrollbar-thumb
|
||||||
|
{
|
||||||
|
background-color: #999;
|
||||||
|
border-radius: 90px;
|
||||||
|
|
||||||
|
&:host-context(.hoverEnabled) &:hover
|
||||||
|
{
|
||||||
|
background-color: rgb(134, 127, 127);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.episode
|
||||||
|
{
|
||||||
|
visibility: visible;
|
||||||
|
display: inline-block;
|
||||||
|
margin: .25rem .25rem 1.25rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 55%;
|
||||||
|
outline: none;
|
||||||
|
position: relative;
|
||||||
|
cursor: pointer;
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: inherit;
|
||||||
|
|
||||||
|
@include media-breakpoint-up(sm)
|
||||||
|
{
|
||||||
|
width: 40%;
|
||||||
|
}
|
||||||
|
|
||||||
|
@include media-breakpoint-up(md)
|
||||||
|
{
|
||||||
|
width: 33%;
|
||||||
|
}
|
||||||
|
|
||||||
|
@include media-breakpoint-up(lg)
|
||||||
|
{
|
||||||
|
width: 28%;
|
||||||
|
}
|
||||||
|
|
||||||
|
@include media-breakpoint-up(xl)
|
||||||
|
{
|
||||||
|
width: 18%;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
> .moreBtn
|
||||||
|
{
|
||||||
|
position: absolute;
|
||||||
|
top: 2%;
|
||||||
|
right: 2%;
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
outline: none;
|
||||||
|
display: none;
|
||||||
|
z-index: 255
|
||||||
|
}
|
||||||
|
|
||||||
|
> div
|
||||||
|
{
|
||||||
|
.img
|
||||||
|
{
|
||||||
|
width: 100%;
|
||||||
|
height: 0;
|
||||||
|
padding-top: 56.25%;
|
||||||
|
background-color: #333333;
|
||||||
|
background-size: contain;
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
> .playBtn
|
||||||
|
{
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
margin: auto;
|
||||||
|
width: 64px;
|
||||||
|
height: 64px;
|
||||||
|
outline: none;
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.title
|
||||||
|
{
|
||||||
|
padding-top: .2rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 0;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 1;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overview
|
||||||
|
{
|
||||||
|
font-weight: 300;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 4;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle
|
||||||
|
{
|
||||||
|
font-weight: 300;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 1;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&:host-context(.hoverEnabled) &:hover, &:host-context(.hoverEnabled) &:focus
|
||||||
|
{
|
||||||
|
.moreBtn
|
||||||
|
{
|
||||||
|
display: block;
|
||||||
|
-webkit-touch-callout: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
> div
|
||||||
|
{
|
||||||
|
.img
|
||||||
|
{
|
||||||
|
outline: solid var(--accentColor);
|
||||||
|
|
||||||
|
.playBtn
|
||||||
|
{
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.title
|
||||||
|
{
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.playIcon
|
||||||
|
{
|
||||||
|
font-size: 64px;
|
||||||
|
width: 64px;
|
||||||
|
height: 64px;
|
||||||
|
line-height: 64px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scrollBtn
|
||||||
|
{
|
||||||
|
padding: 0;
|
||||||
|
outline: none;
|
||||||
|
min-width: 0;
|
||||||
|
position: absolute;
|
||||||
|
top: 20%;
|
||||||
|
bottom: 60%;
|
||||||
|
display: none;
|
||||||
|
|
||||||
|
&.leftBtn
|
||||||
|
{
|
||||||
|
left: 0;
|
||||||
|
padding-left: 10px;
|
||||||
|
padding-right: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.rightBtn
|
||||||
|
{
|
||||||
|
right: 0;
|
||||||
|
padding-right: 10px;
|
||||||
|
padding-left: 2px;
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,39 @@
|
|||||||
|
import { Component, Input, QueryList, ViewChildren } from "@angular/core";
|
||||||
|
import { MatMenuTrigger } from "@angular/material/menu";
|
||||||
|
import { DomSanitizer, SafeStyle } from "@angular/platform-browser";
|
||||||
|
import { Episode } from "../../models/resources/episode";
|
||||||
|
import { HorizontalScroller } from "../../misc/horizontal-scroller";
|
||||||
|
import { Page } from "../../models/page";
|
||||||
|
import { HttpClient } from "@angular/common/http";
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: "app-episodes-list",
|
||||||
|
templateUrl: "./episodes-list.component.html",
|
||||||
|
styleUrls: ["./episodes-list.component.scss"]
|
||||||
|
})
|
||||||
|
export class EpisodesListComponent extends HorizontalScroller
|
||||||
|
{
|
||||||
|
@Input() displayShowTitle = false;
|
||||||
|
@Input() episodes: Page<Episode>;
|
||||||
|
@ViewChildren(MatMenuTrigger) menus: QueryList<MatMenuTrigger>;
|
||||||
|
openedIndex: number = undefined;
|
||||||
|
|
||||||
|
constructor(private sanitizer: DomSanitizer, public client: HttpClient)
|
||||||
|
{
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
sanitize(url: string): SafeStyle
|
||||||
|
{
|
||||||
|
if (!url)
|
||||||
|
return undefined;
|
||||||
|
return this.sanitizer.bypassSecurityTrustStyle("url(" + url + ")");
|
||||||
|
}
|
||||||
|
|
||||||
|
openMenu(index: number): void
|
||||||
|
{
|
||||||
|
const menu: MatMenuTrigger = this.menus.find((x, i) => i === index);
|
||||||
|
menu.focus();
|
||||||
|
menu.openMenu();
|
||||||
|
}
|
||||||
|
}
|
100
front/src/app/components/items-grid/items-grid.component.html
Normal file
100
front/src/app/components/items-grid/items-grid.component.html
Normal file
@ -0,0 +1,100 @@
|
|||||||
|
<div class="container-fluid justify-content-center" *ngIf="this.sortEnabled">
|
||||||
|
<button mat-icon-button matTooltipPosition="below" matTooltip="Filter" [matMenuTriggerFor]="filterMenu">
|
||||||
|
<mat-icon [matBadge]="getFilterCount().toString()" [matBadgeHidden]="getFilterCount() == 0"
|
||||||
|
matBadgeColor="warn" matBadgeSize="small">
|
||||||
|
filter_list
|
||||||
|
</mat-icon>
|
||||||
|
</button>
|
||||||
|
<button mat-button matTooltipPosition="below" matTooltip="Sort" [matMenuTriggerFor]="sortMenu">
|
||||||
|
<mat-icon>sort</mat-icon> Sort by {{this.sortType}}
|
||||||
|
<i *ngIf="this.sortUp" class="material-icons arrow">arrow_upward</i>
|
||||||
|
<i *ngIf="!this.sortUp" class="material-icons arrow">arrow_downward</i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<mat-menu #filterMenu="matMenu" class="big-panel">
|
||||||
|
<ng-container *ngIf="this.genres.length > 0">
|
||||||
|
<h4><b>Genres</b></h4>
|
||||||
|
<mat-chip-list>
|
||||||
|
<!--suppress AngularInvalidExpressionResultType ('default' color is valid for mat-chip)-->
|
||||||
|
<mat-chip *ngFor="let genre of this.genres"
|
||||||
|
[color]="this.filters.genres.includes(genre) ? 'accent' : 'default'" selected
|
||||||
|
(click)="this.addFilter('genres', genre)">
|
||||||
|
{{genre.name}}
|
||||||
|
</mat-chip>
|
||||||
|
</mat-chip-list>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<br/>
|
||||||
|
|
||||||
|
<ng-container>
|
||||||
|
<mat-form-field class="w-100 px-3" (click)="$event.stopPropagation();">
|
||||||
|
<mat-label>Studio</mat-label>
|
||||||
|
<input type="text" matInput [formControl]="studioForm"
|
||||||
|
[matAutocomplete]="autoStudio"
|
||||||
|
[value]="this.nameGetter(this.filters.studio)">
|
||||||
|
<mat-autocomplete autoActiveFirstOption #autoStudio="matAutocomplete"
|
||||||
|
(optionSelected)="this.addFilter('studio', $event.option.value, false)"
|
||||||
|
[displayWith]="this.nameGetter">
|
||||||
|
<mat-option *ngIf="this.shouldDisplayNoneStudio()" [value]="null">None</mat-option>
|
||||||
|
<mat-option *ngFor="let studio of this.filteredStudios | async" [value]="studio">
|
||||||
|
{{studio.name}}
|
||||||
|
</mat-option>
|
||||||
|
</mat-autocomplete>
|
||||||
|
</mat-form-field>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<ng-container>
|
||||||
|
<mat-form-field class="w-100 px-3" (click)="$event.stopPropagation();">
|
||||||
|
<mat-label>People</mat-label>
|
||||||
|
<mat-chip-list #peopleList>
|
||||||
|
<mat-chip *ngFor="let people of this.filters.people"
|
||||||
|
color="accent" selected
|
||||||
|
removable="true"
|
||||||
|
(removed)="this.addFilter('people', people)"
|
||||||
|
(click)="this.addFilter('people', people)">
|
||||||
|
{{people.name || people.slug}}
|
||||||
|
<mat-icon matChipRemove>cancel</mat-icon>
|
||||||
|
</mat-chip>
|
||||||
|
<input #peopleInput
|
||||||
|
[matAutocomplete]="autoPpl"
|
||||||
|
[matChipInputFor]="peopleList"
|
||||||
|
[formControl]="peopleForm"
|
||||||
|
(matChipInputTokenEnd)="this.addFilter('people', {id: 0, slug: $event.value});
|
||||||
|
$event.input.value = null;"/>
|
||||||
|
</mat-chip-list>
|
||||||
|
<mat-autocomplete #autoPpl="matAutocomplete"
|
||||||
|
(optionSelected)="this.addFilter('people', $event.option.value);
|
||||||
|
peopleInput.value = null;">
|
||||||
|
<mat-option *ngFor="let people of this.filteredPeople | async" [value]="people">
|
||||||
|
{{people.name}}
|
||||||
|
</mat-option>
|
||||||
|
</mat-autocomplete>
|
||||||
|
</mat-form-field>
|
||||||
|
</ng-container>
|
||||||
|
</mat-menu>
|
||||||
|
|
||||||
|
<mat-menu #sortMenu="matMenu">
|
||||||
|
<div *ngFor="let type of this.sortKeys">
|
||||||
|
<button *ngIf="type != this.sortType; else elseBlock;" mat-menu-item (click)="sort(type, true)">
|
||||||
|
Sort by {{type}}
|
||||||
|
</button>
|
||||||
|
<ng-template #elseBlock>
|
||||||
|
<button mat-menu-item (click)="sort(type, !this.sortUp)">
|
||||||
|
Sort by {{type}}
|
||||||
|
<i *ngIf="!this.sortUp" class="material-icons arrow">arrow_upward</i>
|
||||||
|
<i *ngIf="this.sortUp" class="material-icons arrow">arrow_downward</i>
|
||||||
|
</button>
|
||||||
|
</ng-template>
|
||||||
|
</div>
|
||||||
|
</mat-menu>
|
||||||
|
|
||||||
|
<div class="container-fluid justify-content-center"
|
||||||
|
infinite-scroll (scrolled)="this.page?.loadNext(this.client)" infiniteScrollContainer="#main" fromRoot="true">
|
||||||
|
<a class="show" *ngFor="let item of this.page?.items" draggable="false"
|
||||||
|
[href]="getLink(item)" [routerLink]="getLink(item)">
|
||||||
|
<div matRipple [style.background-image]="getPoster(item)"></div>
|
||||||
|
<p class="title">{{item.title ? item.title : item.name}}</p>
|
||||||
|
<p class="date">{{getDate(item)}}</p>
|
||||||
|
</a>
|
||||||
|
</div>
|
127
front/src/app/components/items-grid/items-grid.component.scss
Normal file
127
front/src/app/components/items-grid/items-grid.component.scss
Normal file
@ -0,0 +1,127 @@
|
|||||||
|
@import "~bootstrap/scss/functions";
|
||||||
|
@import "~bootstrap/scss/variables";
|
||||||
|
@import "~bootstrap/scss/mixins/breakpoints";
|
||||||
|
|
||||||
|
button
|
||||||
|
{
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.arrow
|
||||||
|
{
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container-fluid
|
||||||
|
{
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.show
|
||||||
|
{
|
||||||
|
width: 27%;
|
||||||
|
min-width: 100px;
|
||||||
|
max-width: 168px;
|
||||||
|
list-style: none;
|
||||||
|
margin: .5em;
|
||||||
|
text-decoration: none;
|
||||||
|
color: inherit;
|
||||||
|
outline: none;
|
||||||
|
|
||||||
|
@include media-breakpoint-up(sm)
|
||||||
|
{
|
||||||
|
width: 22%;
|
||||||
|
min-width: 120px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@include media-breakpoint-up(md)
|
||||||
|
{
|
||||||
|
width: 18%;
|
||||||
|
margin: 1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
@include media-breakpoint-up(lg)
|
||||||
|
{
|
||||||
|
width: 18%;
|
||||||
|
}
|
||||||
|
|
||||||
|
@include media-breakpoint-up(xl)
|
||||||
|
{
|
||||||
|
width: 15%;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
&:focus, &:hover
|
||||||
|
{
|
||||||
|
> div
|
||||||
|
{
|
||||||
|
outline: solid var(--accentColor);
|
||||||
|
}
|
||||||
|
|
||||||
|
> .title
|
||||||
|
{
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
> div
|
||||||
|
{
|
||||||
|
width: 100%;
|
||||||
|
height: 0;
|
||||||
|
padding-top: 147.0588%;
|
||||||
|
background-size: cover;
|
||||||
|
background-color: #333333;
|
||||||
|
}
|
||||||
|
|
||||||
|
> p
|
||||||
|
{
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 0;
|
||||||
|
opacity: 1;
|
||||||
|
|
||||||
|
&.date
|
||||||
|
{
|
||||||
|
opacity: 0.8;
|
||||||
|
font-size: 0.8em;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&:host-context(.hoverEnabled) &:hover
|
||||||
|
{
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
::ng-deep .big-panel
|
||||||
|
{
|
||||||
|
width: 80vw !important;
|
||||||
|
max-width: none !important;
|
||||||
|
margin-left: -20vw;
|
||||||
|
margin-right: -20vw;
|
||||||
|
overflow-x: hidden;
|
||||||
|
|
||||||
|
@include media-breakpoint-up(sm)
|
||||||
|
{
|
||||||
|
width: 70vw !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
@include media-breakpoint-up(md)
|
||||||
|
{
|
||||||
|
width: 50vw !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
> div
|
||||||
|
{
|
||||||
|
text-align: center;
|
||||||
|
|
||||||
|
> mat-chip-list > div
|
||||||
|
{
|
||||||
|
justify-content: center;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
307
front/src/app/components/items-grid/items-grid.component.ts
Normal file
307
front/src/app/components/items-grid/items-grid.component.ts
Normal file
@ -0,0 +1,307 @@
|
|||||||
|
import { Component, Input, OnInit } from "@angular/core";
|
||||||
|
import { FormControl } from "@angular/forms";
|
||||||
|
import { ActivatedRoute, ActivatedRouteSnapshot, Params, Router } from "@angular/router";
|
||||||
|
import { DomSanitizer, SafeStyle } from "@angular/platform-browser";
|
||||||
|
import { Genre } from "../../models/resources/genre";
|
||||||
|
import { LibraryItem } from "../../models/resources/library-item";
|
||||||
|
import { Page } from "../../models/page";
|
||||||
|
import { HttpClient } from "@angular/common/http";
|
||||||
|
import { People } from "../../models/resources/people";
|
||||||
|
import { IResource } from "../../models/resources/resource";
|
||||||
|
import { Show, ShowRole } from "../../models/resources/show";
|
||||||
|
import { Collection } from "../../models/resources/collection";
|
||||||
|
import { Studio } from "../../models/resources/studio";
|
||||||
|
import { ItemsUtils } from "../../misc/items-utils";
|
||||||
|
import { PeopleService, StudioService } from "../../services/api.service";
|
||||||
|
import { PreLoaderService } from "../../services/pre-loader.service";
|
||||||
|
import { Observable } from "rxjs";
|
||||||
|
import { catchError, filter, map, mergeAll } from "rxjs/operators";
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: "app-items-grid",
|
||||||
|
templateUrl: "./items-grid.component.html",
|
||||||
|
styleUrls: ["./items-grid.component.scss"]
|
||||||
|
})
|
||||||
|
export class ItemsGridComponent implements OnInit
|
||||||
|
{
|
||||||
|
|
||||||
|
constructor(private route: ActivatedRoute,
|
||||||
|
private sanitizer: DomSanitizer,
|
||||||
|
private loader: PreLoaderService,
|
||||||
|
private router: Router,
|
||||||
|
private studioApi: StudioService,
|
||||||
|
private peopleApi: PeopleService,
|
||||||
|
public client: HttpClient)
|
||||||
|
{
|
||||||
|
this.route.data.subscribe((data) =>
|
||||||
|
{
|
||||||
|
this.page = data.items;
|
||||||
|
});
|
||||||
|
this.route.queryParams.subscribe((data) =>
|
||||||
|
{
|
||||||
|
this.updateGenresFilterFromQuery(data);
|
||||||
|
this.updateStudioFilterFromQuery(data);
|
||||||
|
this.updatePeopleFilterFromQuery(data);
|
||||||
|
});
|
||||||
|
this.loader.load<Genre>("/api/genres?limit=0").subscribe(data =>
|
||||||
|
{
|
||||||
|
this.genres = data;
|
||||||
|
this.updateGenresFilterFromQuery(this.route.snapshot.queryParams);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public static readonly showOnlyFilters: string[] = ["genres", "studio", "people"];
|
||||||
|
public static readonly filters: string[] = [].concat(...ItemsGridComponent.showOnlyFilters);
|
||||||
|
@Input() page: Page<LibraryItem | Show | ShowRole | Collection>;
|
||||||
|
@Input() sortEnabled: boolean = true;
|
||||||
|
|
||||||
|
complexFiltersEnabled: boolean;
|
||||||
|
|
||||||
|
sortType: string = "title";
|
||||||
|
sortKeys: string[] = ["title", "start air", "end air"];
|
||||||
|
sortUp: boolean = true;
|
||||||
|
filters: {genres: Genre[], studio: Studio, people: People[]} = {genres: [], studio: null, people: []};
|
||||||
|
|
||||||
|
genres: Genre[] = [];
|
||||||
|
|
||||||
|
studioForm: FormControl = new FormControl();
|
||||||
|
filteredStudios: Observable<Studio[]>;
|
||||||
|
|
||||||
|
peopleForm: FormControl = new FormControl();
|
||||||
|
filteredPeople: Observable<People[]>;
|
||||||
|
|
||||||
|
/*
|
||||||
|
* /browse -> /api/items | /api/shows
|
||||||
|
* /browse/:library -> /api/library/:slug/items | /api/library/:slug/shows
|
||||||
|
* /genre/:slug -> /api/shows
|
||||||
|
* /studio/:slug -> /api/shows
|
||||||
|
*
|
||||||
|
* /collection/:slug -> /api/collection/:slug/shows |> /api/collections/:slug/shows
|
||||||
|
* /people/:slug -> /api/people/:slug/roles |> /api/people/:slug/roles
|
||||||
|
*/
|
||||||
|
|
||||||
|
static routeMapper(route: ActivatedRouteSnapshot, endpoint: string, query: [string, string][]): string
|
||||||
|
{
|
||||||
|
const queryParams: [string, string][] = Object.entries(route.queryParams)
|
||||||
|
.filter(x => ItemsGridComponent.filters.includes(x[0]) || x[0] === "sortBy");
|
||||||
|
if (query)
|
||||||
|
queryParams.push(...query);
|
||||||
|
|
||||||
|
if (queryParams.some(x => ItemsGridComponent.showOnlyFilters.includes(x[0])))
|
||||||
|
endpoint = endpoint.replace(/items?$/, "show");
|
||||||
|
|
||||||
|
const params: string = queryParams.length > 0
|
||||||
|
? "?" + queryParams.map(x => `${x[0]}=${x[1]}`).join("&")
|
||||||
|
: "";
|
||||||
|
return `api/${endpoint}${params}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateGenresFilterFromQuery(query: Params): void
|
||||||
|
{
|
||||||
|
let selectedGenres: string[] = [];
|
||||||
|
if (query.genres?.startsWith("ctn:"))
|
||||||
|
selectedGenres = query.genres.substr(4).split(",");
|
||||||
|
else if (query.genres != null)
|
||||||
|
selectedGenres = query.genres.split(",");
|
||||||
|
if (this.router.url.startsWith("/genre"))
|
||||||
|
selectedGenres.push(this.route.snapshot.params.slug);
|
||||||
|
|
||||||
|
this.filters.genres = this.genres.filter(x => selectedGenres.includes(x.slug));
|
||||||
|
}
|
||||||
|
|
||||||
|
updateStudioFilterFromQuery(query: Params): void
|
||||||
|
{
|
||||||
|
const slug: string = this.router.url.startsWith("/studio") ? this.route.snapshot.params.slug : query.studio;
|
||||||
|
|
||||||
|
if (slug && this.filters.studio?.slug !== slug)
|
||||||
|
{
|
||||||
|
this.filters.studio = {id: 0, slug, name: slug};
|
||||||
|
this.studioApi.get(slug).subscribe(x => this.filters.studio = x);
|
||||||
|
}
|
||||||
|
else if (!slug)
|
||||||
|
this.filters.studio = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
updatePeopleFilterFromQuery(query: Params): void
|
||||||
|
{
|
||||||
|
let slugs: string[] = [];
|
||||||
|
if (query.people != null)
|
||||||
|
{
|
||||||
|
if (query.people.startsWith("ctn:"))
|
||||||
|
slugs = query.people.substr(4).split(",");
|
||||||
|
else
|
||||||
|
slugs = query.people.split(",");
|
||||||
|
}
|
||||||
|
else if (this.route.snapshot.params.slug && this.router.url.startsWith("/people"))
|
||||||
|
slugs = [this.route.snapshot.params.slug];
|
||||||
|
|
||||||
|
this.filters.people = slugs.map(x => ({slug: x, name: x} as People));
|
||||||
|
for (const slug of slugs)
|
||||||
|
{
|
||||||
|
this.peopleApi.get(slug).subscribe(x =>
|
||||||
|
{
|
||||||
|
const i: number = this.filters.people.findIndex(y => y.slug === slug);
|
||||||
|
this.filters.people[i] = x;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnInit(): void
|
||||||
|
{
|
||||||
|
this.filteredStudios = this.studioForm.valueChanges
|
||||||
|
.pipe(
|
||||||
|
filter(x => x),
|
||||||
|
map(x => typeof x === "string" ? x : x.name),
|
||||||
|
map(x => this.studioApi.search(x)),
|
||||||
|
mergeAll(),
|
||||||
|
catchError(x =>
|
||||||
|
{
|
||||||
|
console.log(x);
|
||||||
|
return [];
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
this.filteredPeople = this.peopleForm.valueChanges
|
||||||
|
.pipe(
|
||||||
|
filter(x => x),
|
||||||
|
map(x => typeof x === "string" ? x : x.name),
|
||||||
|
map(x => this.peopleApi.search(x)),
|
||||||
|
mergeAll(),
|
||||||
|
catchError(x =>
|
||||||
|
{
|
||||||
|
console.log(x);
|
||||||
|
return [];
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
shouldDisplayNoneStudio(): boolean
|
||||||
|
{
|
||||||
|
return this.studioForm.value === "" || typeof this.studioForm.value !== "string";
|
||||||
|
}
|
||||||
|
|
||||||
|
getFilterCount(): number
|
||||||
|
{
|
||||||
|
let count: number = this.filters.genres.length + this.filters.people.length;
|
||||||
|
if (this.filters.studio != null)
|
||||||
|
count++;
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
|
addFilter(category: string, resource: IResource, isArray: boolean = true, toggle: boolean = false): void
|
||||||
|
{
|
||||||
|
if (isArray)
|
||||||
|
{
|
||||||
|
if (this.filters[category].includes(resource) || this.filters[category].some(x => x.slug === resource.slug))
|
||||||
|
this.filters[category].splice(this.filters[category].indexOf(resource), 1);
|
||||||
|
else
|
||||||
|
this.filters[category].push(resource);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
if (resource && (this.filters[category] === resource || this.filters[category]?.slug === resource.slug))
|
||||||
|
{
|
||||||
|
if (!toggle)
|
||||||
|
return;
|
||||||
|
this.filters[category] = null;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
this.filters[category] = resource;
|
||||||
|
}
|
||||||
|
|
||||||
|
let param: string = null;
|
||||||
|
if (isArray && this.filters[category].length > 0)
|
||||||
|
param = `${this.filters[category].map(x => x.slug).join(",")}`;
|
||||||
|
else if (!isArray && this.filters[category] != null)
|
||||||
|
param = resource.slug;
|
||||||
|
|
||||||
|
if (/\/browse($|\?)/.test(this.router.url)
|
||||||
|
|| this.router.url.startsWith("/genre")
|
||||||
|
|| this.router.url.startsWith("/studio")
|
||||||
|
|| this.router.url.startsWith("/people"))
|
||||||
|
{
|
||||||
|
if (this.filters.genres.length === 1 && this.getFilterCount() === 1)
|
||||||
|
{
|
||||||
|
this.router.navigate(["genre", this.filters.genres[0].slug], {
|
||||||
|
replaceUrl: true,
|
||||||
|
queryParams: {sortBy: this.route.snapshot.queryParams.sortBy}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
else if (this.filters.studio != null && this.getFilterCount() === 1)
|
||||||
|
{
|
||||||
|
this.router.navigate(["studio", this.filters.studio.slug], {
|
||||||
|
replaceUrl: true,
|
||||||
|
queryParams: {sortBy: this.route.snapshot.queryParams.sortBy}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
else if (this.filters.people.length === 1 && this.getFilterCount() === 1)
|
||||||
|
{
|
||||||
|
this.router.navigate(["people", this.filters.people[0].slug], {
|
||||||
|
replaceUrl: true,
|
||||||
|
queryParams: {sortBy: this.route.snapshot.queryParams.sortBy}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
else if (this.getFilterCount() === 0 || this.router.url !== "/browse")
|
||||||
|
{
|
||||||
|
const params: {[key: string]: string} = {[category]: param};
|
||||||
|
if (this.router.url.startsWith("/studio") && category !== "studio")
|
||||||
|
params.studio = this.route.snapshot.params.slug;
|
||||||
|
if (this.router.url.startsWith("/genre") && category !== "genres")
|
||||||
|
params.genres = `${this.route.snapshot.params.slug}`;
|
||||||
|
if (this.router.url.startsWith("/people") && category !== "people")
|
||||||
|
params.people = `${this.route.snapshot.params.slug}`;
|
||||||
|
|
||||||
|
this.router.navigate(["/browse"], {
|
||||||
|
queryParams: params,
|
||||||
|
replaceUrl: true,
|
||||||
|
queryParamsHandling: "merge"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
this.router.navigate([], {
|
||||||
|
relativeTo: this.route,
|
||||||
|
queryParams: {[category]: param},
|
||||||
|
replaceUrl: true,
|
||||||
|
queryParamsHandling: "merge"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
nameGetter(obj: Studio): string
|
||||||
|
{
|
||||||
|
return obj?.name ?? "None";
|
||||||
|
}
|
||||||
|
|
||||||
|
getPoster(obj: LibraryItem | Show | ShowRole | Collection): SafeStyle
|
||||||
|
{
|
||||||
|
if (!obj.poster)
|
||||||
|
return undefined;
|
||||||
|
return this.sanitizer.bypassSecurityTrustStyle(`url(${obj.poster})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
getDate(item: LibraryItem | Show | ShowRole | Collection): string
|
||||||
|
{
|
||||||
|
return ItemsUtils.getDate(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
getLink(item: LibraryItem | Show | ShowRole | Collection): string
|
||||||
|
{
|
||||||
|
return ItemsUtils.getLink(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
sort(type: string, order: boolean): void
|
||||||
|
{
|
||||||
|
this.sortType = type;
|
||||||
|
this.sortUp = order;
|
||||||
|
|
||||||
|
const param: string = `${this.sortType.replace(/\s/g, "")}:${this.sortUp ? "asc" : "desc"}`;
|
||||||
|
this.router.navigate([], {
|
||||||
|
relativeTo: this.route,
|
||||||
|
queryParams: { sortBy: param },
|
||||||
|
replaceUrl: true,
|
||||||
|
queryParamsHandling: "merge"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,17 @@
|
|||||||
|
<div class="scroll-row mb-5">
|
||||||
|
<div class="container" #scrollView (scroll)="onScroll()"
|
||||||
|
infinite-scroll (scrolled)="this.items?.loadNext(this.client)" [horizontal]="true" [scrollWindow]="false">
|
||||||
|
<a class="item" *ngFor="let item of this.items?.items" draggable="false"
|
||||||
|
[routerLink]="getLink(item)" [href]="getLink(item)" #itemsDom>
|
||||||
|
<div matRipple [style.background-image]="getPoster(item)"> </div>
|
||||||
|
<p class="title">{{item.title ? item.title : item.name}}</p>
|
||||||
|
<p class="date">{{getDate(item)}}</p>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<button mat-raised-button color="accent" class="scrollBtn leftBtn d-none" #leftBtn (click)="scrollLeft()">
|
||||||
|
<mat-icon>arrow_left</mat-icon>
|
||||||
|
</button>
|
||||||
|
<button mat-raised-button color="accent" class="scrollBtn rightBtn" #rightBtn (click)="scrollRight()">
|
||||||
|
<mat-icon>arrow_right</mat-icon>
|
||||||
|
</button>
|
||||||
|
</div>
|
150
front/src/app/components/items-list/items-list.component.scss
Normal file
150
front/src/app/components/items-list/items-list.component.scss
Normal file
@ -0,0 +1,150 @@
|
|||||||
|
@import "~bootstrap/scss/functions";
|
||||||
|
@import "~bootstrap/scss/variables";
|
||||||
|
@import "~bootstrap/scss/mixins/breakpoints";
|
||||||
|
|
||||||
|
.container
|
||||||
|
{
|
||||||
|
display: flex;
|
||||||
|
padding-left: 15px;
|
||||||
|
padding-right: 15px;
|
||||||
|
overflow-x: auto;
|
||||||
|
min-width: 100%;
|
||||||
|
flex-shrink: 0;
|
||||||
|
flex-direction: row;
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: #999 transparent;
|
||||||
|
|
||||||
|
&::-webkit-scrollbar
|
||||||
|
{
|
||||||
|
height: 4px;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
&::-webkit-scrollbar-thumb
|
||||||
|
{
|
||||||
|
background-color: #999;
|
||||||
|
border-radius: 90px;
|
||||||
|
|
||||||
|
&:host-context(.hoverEnabled) &:hover
|
||||||
|
{
|
||||||
|
background-color: rgb(134, 127, 127);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.item
|
||||||
|
{
|
||||||
|
width: 33%;
|
||||||
|
min-width: 120px;
|
||||||
|
max-width: 200px;
|
||||||
|
list-style: none;
|
||||||
|
padding: .5em;
|
||||||
|
text-decoration: none;
|
||||||
|
color: inherit;
|
||||||
|
outline: none;
|
||||||
|
flex-shrink: 0;
|
||||||
|
flex-grow: 0;
|
||||||
|
|
||||||
|
@include media-breakpoint-up(sm)
|
||||||
|
{
|
||||||
|
width: 25%;
|
||||||
|
}
|
||||||
|
|
||||||
|
@include media-breakpoint-up(md)
|
||||||
|
{
|
||||||
|
width: 20%;
|
||||||
|
padding: 1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
@include media-breakpoint-up(lg)
|
||||||
|
{
|
||||||
|
width: 18%;
|
||||||
|
}
|
||||||
|
|
||||||
|
@include media-breakpoint-up(xl)
|
||||||
|
{
|
||||||
|
width: 15%;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
&:focus, &:hover
|
||||||
|
{
|
||||||
|
> div
|
||||||
|
{
|
||||||
|
outline: solid var(--accentColor);
|
||||||
|
}
|
||||||
|
|
||||||
|
> .title
|
||||||
|
{
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
> div
|
||||||
|
{
|
||||||
|
width: 100%;
|
||||||
|
height: 0;
|
||||||
|
padding-top: 147.0588%;
|
||||||
|
background-size: cover;
|
||||||
|
background-color: #333333;
|
||||||
|
}
|
||||||
|
|
||||||
|
> p
|
||||||
|
{
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 0;
|
||||||
|
opacity: 1;
|
||||||
|
|
||||||
|
&.date
|
||||||
|
{
|
||||||
|
opacity: 0.8;
|
||||||
|
font-size: 0.8em;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&:host-context(.hoverEnabled) &:hover
|
||||||
|
{
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.scroll-row
|
||||||
|
{
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
&:host-context(.hoverEnabled) &:hover
|
||||||
|
{
|
||||||
|
.scrollBtn
|
||||||
|
{
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.scrollBtn
|
||||||
|
{
|
||||||
|
padding: 0;
|
||||||
|
outline: none;
|
||||||
|
min-width: 0;
|
||||||
|
position: absolute;
|
||||||
|
top: 30%;
|
||||||
|
bottom: 40%;
|
||||||
|
display: none;
|
||||||
|
|
||||||
|
&.leftBtn
|
||||||
|
{
|
||||||
|
left: 0;
|
||||||
|
padding-left: 10px;
|
||||||
|
padding-right: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.rightBtn
|
||||||
|
{
|
||||||
|
right: 0;
|
||||||
|
padding-right: 10px;
|
||||||
|
padding-left: 2px;
|
||||||
|
}
|
||||||
|
}
|
44
front/src/app/components/items-list/items-list.component.ts
Normal file
44
front/src/app/components/items-list/items-list.component.ts
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
import { Component, Input } from "@angular/core";
|
||||||
|
import { Collection } from "../../models/resources/collection";
|
||||||
|
import { DomSanitizer, SafeUrl } from "@angular/platform-browser";
|
||||||
|
import { HorizontalScroller } from "../../misc/horizontal-scroller";
|
||||||
|
import { Page } from "../../models/page";
|
||||||
|
import { HttpClient } from "@angular/common/http";
|
||||||
|
import { Show, ShowRole } from "../../models/resources/show";
|
||||||
|
import { LibraryItem } from "../../models/resources/library-item";
|
||||||
|
import { ItemsUtils } from "../../misc/items-utils";
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: "app-items-list",
|
||||||
|
templateUrl: "./items-list.component.html",
|
||||||
|
styleUrls: ["./items-list.component.scss"]
|
||||||
|
})
|
||||||
|
export class ItemsListComponent extends HorizontalScroller
|
||||||
|
{
|
||||||
|
@Input() items: Page<Collection | Show | LibraryItem | ShowRole>;
|
||||||
|
@Input() type: string;
|
||||||
|
|
||||||
|
constructor(private sanitizer: DomSanitizer, public client: HttpClient)
|
||||||
|
{
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
getPoster(item: LibraryItem | Show | ShowRole | Collection): SafeUrl
|
||||||
|
{
|
||||||
|
if (!item.poster)
|
||||||
|
return undefined;
|
||||||
|
return this.sanitizer.bypassSecurityTrustStyle(`url(${item.poster})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
getDate(item: LibraryItem | Show | ShowRole | Collection): string
|
||||||
|
{
|
||||||
|
return ItemsUtils.getDate(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
getLink(item: LibraryItem | Show | ShowRole | Collection): string
|
||||||
|
{
|
||||||
|
if (this.type)
|
||||||
|
return `/${this.type}/${item.slug}`;
|
||||||
|
return ItemsUtils.getLink(item);
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,18 @@
|
|||||||
|
<div class="scroll-row mb-5">
|
||||||
|
<div class="people-container" #scrollView
|
||||||
|
(scroll)="onScroll()" infinite-scroll (scrolled)="this.people?.loadNext(this.client)"
|
||||||
|
[horizontal]="true" [scrollWindow]="false">
|
||||||
|
<a class="people" *ngFor="let people of this.people?.items" draggable="false"
|
||||||
|
routerLink="/people/{{people.slug}}" href="/people/{{people.slug}}" #itemsDom>
|
||||||
|
<div matRipple [style.background-image]="getPeopleIcon(people)"> </div>
|
||||||
|
<h6 class="name">{{people.name}}</h6>
|
||||||
|
<p class="role">{{people.role}}</p>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<button mat-raised-button color="accent" class="scrollBtn leftBtn d-none" #leftBtn (click)="scrollLeft()">
|
||||||
|
<mat-icon>arrow_left</mat-icon>
|
||||||
|
</button>
|
||||||
|
<button mat-raised-button color="accent" class="scrollBtn rightBtn" #rightBtn (click)="scrollRight()">
|
||||||
|
<mat-icon>arrow_right</mat-icon>
|
||||||
|
</button>
|
||||||
|
</div>
|
141
front/src/app/components/people-list/people-list.component.scss
Normal file
141
front/src/app/components/people-list/people-list.component.scss
Normal file
@ -0,0 +1,141 @@
|
|||||||
|
@import "~bootstrap/scss/functions";
|
||||||
|
@import "~bootstrap/scss/variables";
|
||||||
|
@import "~bootstrap/scss/mixins/breakpoints";
|
||||||
|
|
||||||
|
.people-container
|
||||||
|
{
|
||||||
|
display: flex;
|
||||||
|
padding-left: 15px;
|
||||||
|
padding-right: 15px;
|
||||||
|
overflow-x: auto;
|
||||||
|
min-width: 100%;
|
||||||
|
flex-shrink: 0;
|
||||||
|
flex-direction: row;
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: #999 transparent;
|
||||||
|
|
||||||
|
&::-webkit-scrollbar
|
||||||
|
{
|
||||||
|
height: 4px;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
&::-webkit-scrollbar-thumb
|
||||||
|
{
|
||||||
|
background-color: #999;
|
||||||
|
border-radius: 90px;
|
||||||
|
|
||||||
|
&:host-context(.hoverEnabled) &:hover
|
||||||
|
{
|
||||||
|
background-color: rgb(134, 127, 127);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.people
|
||||||
|
{
|
||||||
|
visibility: visible;
|
||||||
|
margin: .25rem;
|
||||||
|
text-decoration: none;
|
||||||
|
color: inherit;
|
||||||
|
outline: none;
|
||||||
|
flex-shrink: 0;
|
||||||
|
flex-grow: 0;
|
||||||
|
width: 33%;
|
||||||
|
|
||||||
|
@include media-breakpoint-up(sm)
|
||||||
|
{
|
||||||
|
width: 22%;
|
||||||
|
}
|
||||||
|
|
||||||
|
@include media-breakpoint-up(md)
|
||||||
|
{
|
||||||
|
width: 20%;
|
||||||
|
}
|
||||||
|
|
||||||
|
@include media-breakpoint-up(lg)
|
||||||
|
{
|
||||||
|
width: 15%;
|
||||||
|
}
|
||||||
|
|
||||||
|
@include media-breakpoint-up(xl)
|
||||||
|
{
|
||||||
|
width: 10%;
|
||||||
|
}
|
||||||
|
|
||||||
|
> div
|
||||||
|
{
|
||||||
|
width: 100%;
|
||||||
|
height: 0;
|
||||||
|
padding-top: 147.0588%;
|
||||||
|
background-size: cover;
|
||||||
|
background-color: #333333;
|
||||||
|
}
|
||||||
|
|
||||||
|
> p, h6
|
||||||
|
{
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 0;
|
||||||
|
|
||||||
|
&.role
|
||||||
|
{
|
||||||
|
font-size: 0.8em;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&:host-context(.hoverEnabled) &:hover, &:focus
|
||||||
|
{
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
> div
|
||||||
|
{
|
||||||
|
outline: solid var(--accentColor);
|
||||||
|
}
|
||||||
|
|
||||||
|
.name
|
||||||
|
{
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.scroll-row
|
||||||
|
{
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
&:host-context(.hoverEnabled) &:hover
|
||||||
|
{
|
||||||
|
.scrollBtn
|
||||||
|
{
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.scrollBtn
|
||||||
|
{
|
||||||
|
padding: 0;
|
||||||
|
outline: none;
|
||||||
|
min-width: 0;
|
||||||
|
position: absolute;
|
||||||
|
top: 30%;
|
||||||
|
bottom: 40%;
|
||||||
|
display: none;
|
||||||
|
|
||||||
|
&.leftBtn
|
||||||
|
{
|
||||||
|
left: 0;
|
||||||
|
padding-left: 10px;
|
||||||
|
padding-right: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.rightBtn
|
||||||
|
{
|
||||||
|
right: 0;
|
||||||
|
padding-right: 10px;
|
||||||
|
padding-left: 2px;
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,28 @@
|
|||||||
|
import { Component, Input } from "@angular/core";
|
||||||
|
import { DomSanitizer, SafeStyle } from "@angular/platform-browser";
|
||||||
|
import { People } from "../../models/resources/people";
|
||||||
|
import { HorizontalScroller } from "../../misc/horizontal-scroller";
|
||||||
|
import { Page } from "../../models/page";
|
||||||
|
import { HttpClient } from "@angular/common/http";
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: "app-people-list",
|
||||||
|
templateUrl: "./people-list.component.html",
|
||||||
|
styleUrls: ["./people-list.component.scss"]
|
||||||
|
})
|
||||||
|
export class PeopleListComponent extends HorizontalScroller
|
||||||
|
{
|
||||||
|
@Input() people: Page<People>;
|
||||||
|
|
||||||
|
constructor(private sanitizer: DomSanitizer, public client: HttpClient)
|
||||||
|
{
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
getPeopleIcon(item: People): SafeStyle
|
||||||
|
{
|
||||||
|
if (!item.poster)
|
||||||
|
return undefined;
|
||||||
|
return this.sanitizer.bypassSecurityTrustStyle(`url(${item.poster})`);
|
||||||
|
}
|
||||||
|
}
|
23
front/src/app/components/show-grid/show-grid.component.html
Normal file
23
front/src/app/components/show-grid/show-grid.component.html
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
<div class="container-fluid">
|
||||||
|
<div *ngFor="let show of this.shows?.items" class="show-container">
|
||||||
|
<mat-card class="show">
|
||||||
|
<a draggable="false" class="d-flex" (click)="this.clickCallback.emit(show)"
|
||||||
|
[href]="getLink(show)" [routerLink]="getLink(show)">
|
||||||
|
<div class="thumb">
|
||||||
|
<div [style.background-image]="getThumb(show)"> </div>
|
||||||
|
</div>
|
||||||
|
<div class="data">
|
||||||
|
<p class="title">{{show.title}}</p>
|
||||||
|
<p class="date" *ngIf="show.endYear && show.startYear != show.endYear; else elseBlock">{{show.startYear}} - {{show.endYear}}</p>
|
||||||
|
<ng-template #elseBlock><p class="date">{{show.startYear}}</p></ng-template>
|
||||||
|
<p class="overview">{{show.overview}}</p>
|
||||||
|
<ul>
|
||||||
|
<li class="provider" *ngFor="let id of this.show.externalIDs">
|
||||||
|
<a [href]="id.link"><img [src]="id.provider.logo" [alt]="id.provider.name"/></a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
</mat-card>
|
||||||
|
</div>
|
||||||
|
</div>
|
144
front/src/app/components/show-grid/show-grid.component.scss
Normal file
144
front/src/app/components/show-grid/show-grid.component.scss
Normal file
@ -0,0 +1,144 @@
|
|||||||
|
@import "~bootstrap/scss/functions";
|
||||||
|
@import "~bootstrap/scss/variables";
|
||||||
|
@import "~bootstrap/scss/mixins/breakpoints";
|
||||||
|
|
||||||
|
button
|
||||||
|
{
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.arrow
|
||||||
|
{
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container-fluid
|
||||||
|
{
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.show-container
|
||||||
|
{
|
||||||
|
width: 100%;
|
||||||
|
min-width: 300px;
|
||||||
|
list-style: none;
|
||||||
|
padding: .5em;
|
||||||
|
|
||||||
|
@include media-breakpoint-up(lg)
|
||||||
|
{
|
||||||
|
width: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
@include media-breakpoint-up(xl)
|
||||||
|
{
|
||||||
|
width: 33%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.show
|
||||||
|
{
|
||||||
|
padding: 0;
|
||||||
|
|
||||||
|
> a
|
||||||
|
{
|
||||||
|
text-decoration: none;
|
||||||
|
color: inherit;
|
||||||
|
outline: none;
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
&:focus, &:hover
|
||||||
|
{
|
||||||
|
|
||||||
|
> .data > .title
|
||||||
|
{
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
> .thumb
|
||||||
|
{
|
||||||
|
width: 33%;
|
||||||
|
|
||||||
|
> div
|
||||||
|
{
|
||||||
|
width: 100%;
|
||||||
|
height: 0;
|
||||||
|
padding-top: 147.0588%;
|
||||||
|
background-size: cover;
|
||||||
|
background-color: #333333;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
> .data
|
||||||
|
{
|
||||||
|
width: 67%;
|
||||||
|
padding: .5rem;
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
right: 0;
|
||||||
|
|
||||||
|
> p:not(.overview)
|
||||||
|
{
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 0;
|
||||||
|
opacity: 1;
|
||||||
|
display: inline-block;
|
||||||
|
|
||||||
|
&.date
|
||||||
|
{
|
||||||
|
opacity: 0.8;
|
||||||
|
font-size: 0.8em;
|
||||||
|
padding-left: 1rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
> .overview
|
||||||
|
{
|
||||||
|
overflow-y: auto;
|
||||||
|
height: calc(100% - 4rem);
|
||||||
|
text-align: justify;
|
||||||
|
padding-right: .5rem;
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:host-context(.hoverEnabled) &:hover, &:focus
|
||||||
|
{
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.provider
|
||||||
|
{
|
||||||
|
display: inline-block;
|
||||||
|
width: 2.5rem;
|
||||||
|
height: 2.5rem;
|
||||||
|
margin: .25rem;
|
||||||
|
|
||||||
|
> a
|
||||||
|
{
|
||||||
|
width: 2.5rem;
|
||||||
|
height: 2.5rem;
|
||||||
|
position: relative;
|
||||||
|
display: inline-block;
|
||||||
|
|
||||||
|
> img
|
||||||
|
{
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
right: 0;
|
||||||
|
left: 0;
|
||||||
|
margin: auto;
|
||||||
|
max-width: 2.5rem;
|
||||||
|
max-height: 2.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
32
front/src/app/components/show-grid/show-grid.component.ts
Normal file
32
front/src/app/components/show-grid/show-grid.component.ts
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
import { Component, EventEmitter, Input, Output } from "@angular/core";
|
||||||
|
import { DomSanitizer, SafeStyle } from "@angular/platform-browser";
|
||||||
|
import { Show } from "../../models/resources/show";
|
||||||
|
import { Page } from "../../models/page";
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: "app-shows-grid",
|
||||||
|
templateUrl: "./show-grid.component.html",
|
||||||
|
styleUrls: ["./show-grid.component.scss"]
|
||||||
|
})
|
||||||
|
export class ShowGridComponent
|
||||||
|
{
|
||||||
|
@Input() shows: Page<Show>;
|
||||||
|
@Input() externalShows: boolean = false;
|
||||||
|
@Output() clickCallback: EventEmitter<Show> = new EventEmitter();
|
||||||
|
|
||||||
|
constructor(private sanitizer: DomSanitizer) { }
|
||||||
|
|
||||||
|
getThumb(show: Show): SafeStyle
|
||||||
|
{
|
||||||
|
if (!show.poster)
|
||||||
|
return undefined;
|
||||||
|
return this.sanitizer.bypassSecurityTrustStyle(`url(${show.poster})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
getLink(show: Show): string
|
||||||
|
{
|
||||||
|
if (this.externalShows)
|
||||||
|
return null;
|
||||||
|
return `/show/${show.slug}`;
|
||||||
|
}
|
||||||
|
}
|
34
front/src/app/misc/custom-route-reuse-strategy.ts
Normal file
34
front/src/app/misc/custom-route-reuse-strategy.ts
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
import { ActivatedRouteSnapshot, DetachedRouteHandle, RouteReuseStrategy } from "@angular/router";
|
||||||
|
|
||||||
|
export class CustomRouteReuseStrategy extends RouteReuseStrategy
|
||||||
|
{
|
||||||
|
shouldReuseRoute(future: ActivatedRouteSnapshot, curr: ActivatedRouteSnapshot): boolean
|
||||||
|
{
|
||||||
|
if (curr.routeConfig?.path === "browse"
|
||||||
|
|| curr.routeConfig?.path === "genre/:slug"
|
||||||
|
|| curr.routeConfig?.path === "studio/:slug")
|
||||||
|
{
|
||||||
|
return future.routeConfig.path === "browse"
|
||||||
|
|| future.routeConfig.path === "genre/:slug"
|
||||||
|
|| future.routeConfig.path === "studio/:slug";
|
||||||
|
}
|
||||||
|
return future.routeConfig === curr.routeConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
shouldAttach(): boolean
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
shouldDetach(): boolean
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
store(): void {}
|
||||||
|
|
||||||
|
retrieve(): DetachedRouteHandle | null
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
31
front/src/app/misc/fallback.directive.ts
Normal file
31
front/src/app/misc/fallback.directive.ts
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
import { Directive, ElementRef, HostListener, Input, Pipe, PipeTransform } from "@angular/core";
|
||||||
|
|
||||||
|
/* tslint:disable:directive-selector */
|
||||||
|
@Directive({
|
||||||
|
selector: "img[fallback]"
|
||||||
|
})
|
||||||
|
export class FallbackDirective
|
||||||
|
{
|
||||||
|
@Input() fallback: string;
|
||||||
|
|
||||||
|
constructor(private img: ElementRef) { }
|
||||||
|
|
||||||
|
@HostListener("error")
|
||||||
|
onError(): void
|
||||||
|
{
|
||||||
|
const html: HTMLImageElement = this.img.nativeElement;
|
||||||
|
html.src = this.fallback;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Pipe({
|
||||||
|
name: "fallback",
|
||||||
|
pure: true
|
||||||
|
})
|
||||||
|
export class FallbackPipe implements PipeTransform
|
||||||
|
{
|
||||||
|
transform(value: any, ...args: any[]): any
|
||||||
|
{
|
||||||
|
return value ?? args.find(x => x);
|
||||||
|
}
|
||||||
|
}
|
50
front/src/app/misc/horizontal-scroller.ts
Normal file
50
front/src/app/misc/horizontal-scroller.ts
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
import { Component, ElementRef, ViewChild } from "@angular/core";
|
||||||
|
import { MatButton } from "@angular/material/button";
|
||||||
|
|
||||||
|
// noinspection AngularMissingOrInvalidDeclarationInModule
|
||||||
|
@Component({
|
||||||
|
template: ""
|
||||||
|
})
|
||||||
|
export class HorizontalScroller
|
||||||
|
{
|
||||||
|
@ViewChild("scrollView", { static: true }) private scrollView: ElementRef;
|
||||||
|
@ViewChild("leftBtn", { static: false }) private leftBtn: MatButton;
|
||||||
|
@ViewChild("rightBtn", { static: false }) private rightBtn: MatButton;
|
||||||
|
@ViewChild("itemsDom", { static: false }) private itemsDom: ElementRef;
|
||||||
|
|
||||||
|
scrollLeft(): void
|
||||||
|
{
|
||||||
|
const scroll: number = this.roundScroll(this.scrollView.nativeElement.offsetWidth * 0.80);
|
||||||
|
this.scrollView.nativeElement.scrollBy({ top: 0, left: -scroll, behavior: "smooth" });
|
||||||
|
}
|
||||||
|
|
||||||
|
scrollRight(): void
|
||||||
|
{
|
||||||
|
const scroll: number = this.roundScroll(this.scrollView.nativeElement.offsetWidth * 0.80);
|
||||||
|
this.scrollView.nativeElement.scrollBy({ top: 0, left: scroll, behavior: "smooth" });
|
||||||
|
}
|
||||||
|
|
||||||
|
roundScroll(offset: number): number
|
||||||
|
{
|
||||||
|
const itemSize: number = this.itemsDom.nativeElement.scrollWidth;
|
||||||
|
|
||||||
|
offset = Math.round(offset / itemSize) * itemSize;
|
||||||
|
if (offset === 0)
|
||||||
|
offset = itemSize;
|
||||||
|
return offset;
|
||||||
|
}
|
||||||
|
|
||||||
|
onScroll(): void
|
||||||
|
{
|
||||||
|
const scroll: any = this.scrollView.nativeElement;
|
||||||
|
|
||||||
|
if (scroll.scrollLeft <= 0)
|
||||||
|
this.leftBtn._elementRef.nativeElement.classList.add("d-none");
|
||||||
|
else
|
||||||
|
this.leftBtn._elementRef.nativeElement.classList.remove("d-none");
|
||||||
|
if (scroll.scrollLeft >= scroll.scrollWidth - scroll.clientWidth)
|
||||||
|
this.rightBtn._elementRef.nativeElement.classList.add("d-none");
|
||||||
|
else
|
||||||
|
this.rightBtn._elementRef.nativeElement.classList.remove("d-none");
|
||||||
|
}
|
||||||
|
}
|
33
front/src/app/misc/items-utils.ts
Normal file
33
front/src/app/misc/items-utils.ts
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
import { ItemType, LibraryItem } from "../models/resources/library-item";
|
||||||
|
import { Show, ShowRole } from "../models/resources/show";
|
||||||
|
import { Collection } from "../models/resources/collection";
|
||||||
|
import { People } from "../models/resources/people";
|
||||||
|
|
||||||
|
export class ItemsUtils
|
||||||
|
{
|
||||||
|
static getLink(item: LibraryItem | Show | ShowRole | Collection): string
|
||||||
|
{
|
||||||
|
if ("type" in item && item.type === ItemType.Collection)
|
||||||
|
return "/collection/" + item.slug;
|
||||||
|
else
|
||||||
|
return "/show/" + item.slug;
|
||||||
|
}
|
||||||
|
|
||||||
|
static getDate(item: LibraryItem | Show | ShowRole | Collection | People): string
|
||||||
|
{
|
||||||
|
if ("role" in item && item.role)
|
||||||
|
{
|
||||||
|
if ("type" in item && item.type)
|
||||||
|
return `as ${item.role} (${item.type})`;
|
||||||
|
return `as ${item.role}`;
|
||||||
|
}
|
||||||
|
if ("type" in item && item.type && typeof item.type === "string")
|
||||||
|
return item.type;
|
||||||
|
|
||||||
|
if (!("startAir" in item))
|
||||||
|
return "";
|
||||||
|
if (item.endAir && item.startAir?.getFullYear() !== item.endAir.getFullYear())
|
||||||
|
return `${item.startAir.getFullYear()} - ${item.endAir.getFullYear()}`;
|
||||||
|
return item.startAir?.getFullYear().toString();
|
||||||
|
}
|
||||||
|
}
|
75
front/src/app/misc/long-press.directive.ts
Normal file
75
front/src/app/misc/long-press.directive.ts
Normal file
@ -0,0 +1,75 @@
|
|||||||
|
import { Directive, Output, EventEmitter, HostListener, HostBinding, ElementRef } from "@angular/core";
|
||||||
|
import MouseDownEvent = JQuery.MouseDownEvent;
|
||||||
|
import TouchStartEvent = JQuery.TouchStartEvent;
|
||||||
|
import ContextMenuEvent = JQuery.ContextMenuEvent;
|
||||||
|
import ClickEvent = JQuery.ClickEvent;
|
||||||
|
|
||||||
|
function cancelClick(event: ClickEvent): void
|
||||||
|
{
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
this.removeEventListener("click", cancelClick, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Directive({
|
||||||
|
selector: `[appLongPress]`
|
||||||
|
})
|
||||||
|
export class LongPressDirective
|
||||||
|
{
|
||||||
|
@Output() longPressed = new EventEmitter();
|
||||||
|
private _timer: NodeJS.Timeout = null;
|
||||||
|
|
||||||
|
constructor(private ref: ElementRef) {}
|
||||||
|
|
||||||
|
@HostBinding("style.-webkit-touch-callout")
|
||||||
|
defaultLongTouchEvent: string = "none";
|
||||||
|
|
||||||
|
@HostBinding("class.longpress")
|
||||||
|
get longPress(): boolean
|
||||||
|
{
|
||||||
|
return this._timer !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@HostListener("touchstart", ["$event"])
|
||||||
|
@HostListener("mousedown", ["$event"])
|
||||||
|
start(event: MouseDownEvent | TouchStartEvent): void
|
||||||
|
{
|
||||||
|
const startBox: DOMRect = event.target.getBoundingClientRect();
|
||||||
|
this._timer = setTimeout(() =>
|
||||||
|
{
|
||||||
|
const endBox: DOMRect = event.target.getBoundingClientRect();
|
||||||
|
if (startBox.top !== endBox.top || startBox.left !== endBox.left)
|
||||||
|
return;
|
||||||
|
this.longPressed.emit();
|
||||||
|
this._timer = null;
|
||||||
|
this.ref.nativeElement.addEventListener("click", cancelClick, true);
|
||||||
|
}, 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
@HostListener("touchend", ["$event"])
|
||||||
|
@HostListener("window:mouseup", ["$event"])
|
||||||
|
end(): void
|
||||||
|
{
|
||||||
|
setTimeout(() =>
|
||||||
|
{
|
||||||
|
this.ref.nativeElement.removeEventListener("click", cancelClick, true);
|
||||||
|
}, 50);
|
||||||
|
this.cancel();
|
||||||
|
}
|
||||||
|
|
||||||
|
@HostListener("wheel", ["$event"])
|
||||||
|
@HostListener("scroll", ["$event"])
|
||||||
|
@HostListener("document.scroll", ["$event"])
|
||||||
|
@HostListener("window.scroll", ["$event"])
|
||||||
|
cancel(): void
|
||||||
|
{
|
||||||
|
clearTimeout(this._timer);
|
||||||
|
this._timer = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@HostListener("contextmenu", ["$event"])
|
||||||
|
context(event: ContextMenuEvent): void
|
||||||
|
{
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
|
}
|
26
front/src/app/misc/password-validator.ts
Normal file
26
front/src/app/misc/password-validator.ts
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
import { AbstractControl, NG_VALIDATORS, Validator } from "@angular/forms";
|
||||||
|
import { Directive } from "@angular/core";
|
||||||
|
|
||||||
|
@Directive({
|
||||||
|
selector: "[appPasswordValidator]",
|
||||||
|
providers: [{provide: NG_VALIDATORS, useExisting: PasswordValidator, multi: true}]
|
||||||
|
})
|
||||||
|
export class PasswordValidator implements Validator
|
||||||
|
{
|
||||||
|
validate(control: AbstractControl): {[key: string]: any} | null
|
||||||
|
{
|
||||||
|
if (!control.value)
|
||||||
|
return null;
|
||||||
|
if (!/[a-z]/.test(control.value))
|
||||||
|
return {passwordError: {error: "The password must contains a lowercase letter."}};
|
||||||
|
if (!/[A-Z]/.test(control.value))
|
||||||
|
return {passwordError: {error: "The password must contains an uppercase letter."}};
|
||||||
|
if (!/[0-9]/.test(control.value))
|
||||||
|
return {passwordError: {error: "The password must contains a digit."}};
|
||||||
|
if (!/\W/.test(control.value))
|
||||||
|
return {passwordError: {error: "The password must contains a non-alphanumeric character."}};
|
||||||
|
if (control.value.toString().length < 6)
|
||||||
|
return {passwordError: {error: "Password must be at least 6 character long."}};
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
7
front/src/app/models/account.ts
Normal file
7
front/src/app/models/account.ts
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
export interface Account
|
||||||
|
{
|
||||||
|
username: string;
|
||||||
|
email: string;
|
||||||
|
picture: string;
|
||||||
|
permissions: string[];
|
||||||
|
}
|
8
front/src/app/models/external-id.ts
Normal file
8
front/src/app/models/external-id.ts
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
import { Provider } from "./provider";
|
||||||
|
|
||||||
|
export interface ExternalID
|
||||||
|
{
|
||||||
|
provider: Provider;
|
||||||
|
dataID: string;
|
||||||
|
link: string;
|
||||||
|
}
|
37
front/src/app/models/page.ts
Normal file
37
front/src/app/models/page.ts
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
import { HttpClient } from "@angular/common/http";
|
||||||
|
|
||||||
|
export class Page<T>
|
||||||
|
{
|
||||||
|
this: string;
|
||||||
|
next: string;
|
||||||
|
first: string;
|
||||||
|
count: number;
|
||||||
|
items: T[];
|
||||||
|
private _isLoading: boolean = false;
|
||||||
|
|
||||||
|
constructor(init?: Partial<Page<T>>)
|
||||||
|
{
|
||||||
|
Object.assign(this, init);
|
||||||
|
}
|
||||||
|
|
||||||
|
loadNext(client: HttpClient): void
|
||||||
|
{
|
||||||
|
if (this.next == null || this._isLoading)
|
||||||
|
return;
|
||||||
|
|
||||||
|
this._isLoading = true;
|
||||||
|
client.get<Page<T>>(this.next).subscribe(x =>
|
||||||
|
{
|
||||||
|
this.items.push(...x.items);
|
||||||
|
this.count += x.count;
|
||||||
|
this.next = x.next;
|
||||||
|
this.this = x.this;
|
||||||
|
this._isLoading = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
changeType(type: string): string
|
||||||
|
{
|
||||||
|
return this.first.replace(/\/\w*($|\?)/, `/${type}$1`);
|
||||||
|
}
|
||||||
|
}
|
5
front/src/app/models/provider.ts
Normal file
5
front/src/app/models/provider.ts
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
export interface Provider
|
||||||
|
{
|
||||||
|
name: string;
|
||||||
|
logo: string;
|
||||||
|
}
|
3
front/src/app/models/resources/collection.js
Normal file
3
front/src/app/models/resources/collection.js
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
"use strict";
|
||||||
|
Object.defineProperty(exports, "__esModule", { value: true });
|
||||||
|
//# sourceMappingURL=collection.js.map
|
1
front/src/app/models/resources/collection.js.map
Normal file
1
front/src/app/models/resources/collection.js.map
Normal file
@ -0,0 +1 @@
|
|||||||
|
{"version":3,"file":"collection.js","sourceRoot":"","sources":["collection.ts"],"names":[],"mappings":""}
|
12
front/src/app/models/resources/collection.ts
Normal file
12
front/src/app/models/resources/collection.ts
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
import { Show } from "./show";
|
||||||
|
import { IResource } from "./resource";
|
||||||
|
|
||||||
|
export interface Collection extends IResource
|
||||||
|
{
|
||||||
|
name: string;
|
||||||
|
poster: string;
|
||||||
|
overview: string;
|
||||||
|
startAir: Date;
|
||||||
|
endAir: Date;
|
||||||
|
shows: Show[];
|
||||||
|
}
|
3
front/src/app/models/resources/episode.js
Normal file
3
front/src/app/models/resources/episode.js
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
"use strict";
|
||||||
|
Object.defineProperty(exports, "__esModule", { value: true });
|
||||||
|
//# sourceMappingURL=episode.js.map
|
1
front/src/app/models/resources/episode.js.map
Normal file
1
front/src/app/models/resources/episode.js.map
Normal file
@ -0,0 +1 @@
|
|||||||
|
{"version":3,"file":"episode.js","sourceRoot":"","sources":["episode.ts"],"names":[],"mappings":""}
|
16
front/src/app/models/resources/episode.ts
Normal file
16
front/src/app/models/resources/episode.ts
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
import { ExternalID } from "../external-id";
|
||||||
|
import { IResource } from "./resource";
|
||||||
|
import { Show } from "./show";
|
||||||
|
|
||||||
|
export interface Episode extends IResource
|
||||||
|
{
|
||||||
|
seasonNumber: number;
|
||||||
|
episodeNumber: number;
|
||||||
|
title: string;
|
||||||
|
thumbnail: string;
|
||||||
|
overview: string;
|
||||||
|
releaseDate: string;
|
||||||
|
runtime: number;
|
||||||
|
show: Show;
|
||||||
|
externalIDs: ExternalID[];
|
||||||
|
}
|
3
front/src/app/models/resources/genre.js
Normal file
3
front/src/app/models/resources/genre.js
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
"use strict";
|
||||||
|
Object.defineProperty(exports, "__esModule", { value: true });
|
||||||
|
//# sourceMappingURL=genre.js.map
|
1
front/src/app/models/resources/genre.js.map
Normal file
1
front/src/app/models/resources/genre.js.map
Normal file
@ -0,0 +1 @@
|
|||||||
|
{"version":3,"file":"genre.js","sourceRoot":"","sources":["genre.ts"],"names":[],"mappings":""}
|
6
front/src/app/models/resources/genre.ts
Normal file
6
front/src/app/models/resources/genre.ts
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
import { IResource } from "./resource";
|
||||||
|
|
||||||
|
export interface Genre extends IResource
|
||||||
|
{
|
||||||
|
name: string;
|
||||||
|
}
|
20
front/src/app/models/resources/library-item.ts
Normal file
20
front/src/app/models/resources/library-item.ts
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
import { IResource } from "./resource";
|
||||||
|
|
||||||
|
export enum ItemType
|
||||||
|
{
|
||||||
|
Show,
|
||||||
|
Movie,
|
||||||
|
Collection
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LibraryItem extends IResource
|
||||||
|
{
|
||||||
|
title: string;
|
||||||
|
overview: string;
|
||||||
|
status: string;
|
||||||
|
trailerUrl: string;
|
||||||
|
startAir: Date;
|
||||||
|
endAir: Date;
|
||||||
|
poster: string;
|
||||||
|
type: ItemType;
|
||||||
|
}
|
8
front/src/app/models/resources/library.ts
Normal file
8
front/src/app/models/resources/library.ts
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
import { IResource } from "./resource";
|
||||||
|
|
||||||
|
export interface Library extends IResource
|
||||||
|
{
|
||||||
|
id: number;
|
||||||
|
slug: string;
|
||||||
|
name: string;
|
||||||
|
}
|
3
front/src/app/models/resources/people.js
Normal file
3
front/src/app/models/resources/people.js
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
"use strict";
|
||||||
|
Object.defineProperty(exports, "__esModule", { value: true });
|
||||||
|
//# sourceMappingURL=people.js.map
|
1
front/src/app/models/resources/people.js.map
Normal file
1
front/src/app/models/resources/people.js.map
Normal file
@ -0,0 +1 @@
|
|||||||
|
{"version":3,"file":"people.js","sourceRoot":"","sources":["people.ts"],"names":[],"mappings":""}
|
14
front/src/app/models/resources/people.ts
Normal file
14
front/src/app/models/resources/people.ts
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
import { ExternalID } from "../external-id";
|
||||||
|
import { IResource } from "./resource";
|
||||||
|
import { Show } from "./show";
|
||||||
|
|
||||||
|
export interface People extends IResource
|
||||||
|
{
|
||||||
|
name: string;
|
||||||
|
role: string;
|
||||||
|
type: string;
|
||||||
|
poster: string;
|
||||||
|
|
||||||
|
shows: Show;
|
||||||
|
externalIDs: ExternalID[];
|
||||||
|
}
|
5
front/src/app/models/resources/resource.ts
Normal file
5
front/src/app/models/resources/resource.ts
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
export interface IResource
|
||||||
|
{
|
||||||
|
id: number;
|
||||||
|
slug: string;
|
||||||
|
}
|
3
front/src/app/models/resources/season.js
Normal file
3
front/src/app/models/resources/season.js
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
"use strict";
|
||||||
|
Object.defineProperty(exports, "__esModule", { value: true });
|
||||||
|
//# sourceMappingURL=season.js.map
|
1
front/src/app/models/resources/season.js.map
Normal file
1
front/src/app/models/resources/season.js.map
Normal file
@ -0,0 +1 @@
|
|||||||
|
{"version":3,"file":"season.js","sourceRoot":"","sources":["season.ts"],"names":[],"mappings":""}
|
12
front/src/app/models/resources/season.ts
Normal file
12
front/src/app/models/resources/season.ts
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
import { Episode } from "./episode";
|
||||||
|
import { ExternalID } from "../external-id";
|
||||||
|
import { IResource } from "./resource";
|
||||||
|
|
||||||
|
export interface Season extends IResource
|
||||||
|
{
|
||||||
|
seasonNumber: number;
|
||||||
|
title: string;
|
||||||
|
overview: string;
|
||||||
|
episodes: Episode[];
|
||||||
|
externalIDs: ExternalID[];
|
||||||
|
}
|
3
front/src/app/models/resources/show.js
Normal file
3
front/src/app/models/resources/show.js
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
"use strict";
|
||||||
|
Object.defineProperty(exports, "__esModule", { value: true });
|
||||||
|
//# sourceMappingURL=show.js.map
|
1
front/src/app/models/resources/show.js.map
Normal file
1
front/src/app/models/resources/show.js.map
Normal file
@ -0,0 +1 @@
|
|||||||
|
{"version":3,"file":"show.js","sourceRoot":"","sources":["show.ts"],"names":[],"mappings":""}
|
45
front/src/app/models/resources/show.ts
Normal file
45
front/src/app/models/resources/show.ts
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
import { Season } from "./season";
|
||||||
|
import { Genre } from "./genre";
|
||||||
|
import { People } from "./people";
|
||||||
|
import { Studio } from "./studio";
|
||||||
|
import { ExternalID } from "../external-id";
|
||||||
|
import { IResource } from "./resource";
|
||||||
|
|
||||||
|
export interface Show extends IResource
|
||||||
|
{
|
||||||
|
title: string;
|
||||||
|
aliases: string[];
|
||||||
|
overview: string;
|
||||||
|
genres: Genre[];
|
||||||
|
status: string;
|
||||||
|
studio: Studio;
|
||||||
|
people: People[];
|
||||||
|
seasons: Season[];
|
||||||
|
trailer: string;
|
||||||
|
isMovie: boolean;
|
||||||
|
startAir: Date;
|
||||||
|
endAir: Date;
|
||||||
|
poster: string;
|
||||||
|
logo: string;
|
||||||
|
thumbnail: string;
|
||||||
|
|
||||||
|
externalIDs: ExternalID[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ShowRole extends IResource
|
||||||
|
{
|
||||||
|
role: string;
|
||||||
|
type: string;
|
||||||
|
|
||||||
|
title: string;
|
||||||
|
aliases: string[];
|
||||||
|
overview: string;
|
||||||
|
status: string;
|
||||||
|
trailerUrl: string;
|
||||||
|
isMovie: boolean;
|
||||||
|
startAir: Date;
|
||||||
|
endAir: Date;
|
||||||
|
poster: string;
|
||||||
|
logo: string;
|
||||||
|
backdrop: string;
|
||||||
|
}
|
3
front/src/app/models/resources/studio.js
Normal file
3
front/src/app/models/resources/studio.js
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
"use strict";
|
||||||
|
Object.defineProperty(exports, "__esModule", { value: true });
|
||||||
|
//# sourceMappingURL=studio.js.map
|
1
front/src/app/models/resources/studio.js.map
Normal file
1
front/src/app/models/resources/studio.js.map
Normal file
@ -0,0 +1 @@
|
|||||||
|
{"version":3,"file":"studio.js","sourceRoot":"","sources":["studio.ts"],"names":[],"mappings":""}
|
6
front/src/app/models/resources/studio.ts
Normal file
6
front/src/app/models/resources/studio.ts
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
import { IResource } from "./resource";
|
||||||
|
|
||||||
|
export interface Studio extends IResource
|
||||||
|
{
|
||||||
|
name: string;
|
||||||
|
}
|
3
front/src/app/models/search-result.js
Normal file
3
front/src/app/models/search-result.js
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
"use strict";
|
||||||
|
Object.defineProperty(exports, "__esModule", { value: true });
|
||||||
|
//# sourceMappingURL=search-result.js.map
|
1
front/src/app/models/search-result.js.map
Normal file
1
front/src/app/models/search-result.js.map
Normal file
@ -0,0 +1 @@
|
|||||||
|
{"version":3,"file":"search-result.js","sourceRoot":"","sources":["search-result.ts"],"names":[],"mappings":""}
|
17
front/src/app/models/search-result.ts
Normal file
17
front/src/app/models/search-result.ts
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
import { Show } from "./resources/show";
|
||||||
|
import { Episode } from "./resources/episode";
|
||||||
|
import { People } from "./resources/people";
|
||||||
|
import { Studio } from "./resources/studio";
|
||||||
|
import { Genre } from "./resources/genre";
|
||||||
|
import { Collection } from "./resources/collection";
|
||||||
|
|
||||||
|
export interface SearchResult
|
||||||
|
{
|
||||||
|
query: string;
|
||||||
|
collections: Collection[];
|
||||||
|
shows: Show[];
|
||||||
|
episodes: Episode[];
|
||||||
|
people: People[];
|
||||||
|
genres: Genre[];
|
||||||
|
studios: Studio[];
|
||||||
|
}
|
3
front/src/app/models/watch-item.js
Normal file
3
front/src/app/models/watch-item.js
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
"use strict";
|
||||||
|
Object.defineProperty(exports, "__esModule", { value: true });
|
||||||
|
//# sourceMappingURL=watch-item.js.map
|
1
front/src/app/models/watch-item.js.map
Normal file
1
front/src/app/models/watch-item.js.map
Normal file
@ -0,0 +1 @@
|
|||||||
|
{"version":3,"file":"watch-item.js","sourceRoot":"","sources":["watch-item.ts"],"names":[],"mappings":""}
|
36
front/src/app/models/watch-item.ts
Normal file
36
front/src/app/models/watch-item.ts
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
import { Episode } from "./resources/episode";
|
||||||
|
|
||||||
|
export interface WatchItem
|
||||||
|
{
|
||||||
|
showTitle: string;
|
||||||
|
showSlug: string;
|
||||||
|
seasonNumber: number;
|
||||||
|
episodeNumber: number;
|
||||||
|
title: string;
|
||||||
|
slug: string;
|
||||||
|
duration: number;
|
||||||
|
releaseDate: string;
|
||||||
|
isMovie: boolean;
|
||||||
|
|
||||||
|
poster: string;
|
||||||
|
backdrop: string;
|
||||||
|
|
||||||
|
previousEpisode: Episode;
|
||||||
|
nextEpisode: Episode;
|
||||||
|
|
||||||
|
container: string;
|
||||||
|
video: Track;
|
||||||
|
audios: Track[];
|
||||||
|
subtitles: Track[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Track
|
||||||
|
{
|
||||||
|
displayName: string;
|
||||||
|
title: string;
|
||||||
|
language: string;
|
||||||
|
isDefault: boolean;
|
||||||
|
isForced: boolean;
|
||||||
|
codec: string;
|
||||||
|
slug: string;
|
||||||
|
}
|
13
front/src/app/pages/collection/collection.component.html
Normal file
13
front/src/app/pages/collection/collection.component.html
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
<div class="container-fluid">
|
||||||
|
<div class="row justify-content-center">
|
||||||
|
<div class="col-md-4 col-lg-3 col-xl-2 collection-info">
|
||||||
|
<div [style.background-image]="getThumb()"></div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-8 col-lg-9 col-xl-10">
|
||||||
|
<h3 class="text-center text-md-left p-2 p-md-3">{{collection.name}}</h3>
|
||||||
|
<h5 class="date">{{getDate(collection)}}</h5>
|
||||||
|
<hr />
|
||||||
|
<app-items-grid [page]="shows"></app-items-grid>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
23
front/src/app/pages/collection/collection.component.scss
Normal file
23
front/src/app/pages/collection/collection.component.scss
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
.collection-info
|
||||||
|
{
|
||||||
|
width: 60%;
|
||||||
|
|
||||||
|
> div
|
||||||
|
{
|
||||||
|
width: 100%;
|
||||||
|
height: 0;
|
||||||
|
padding-top: 147.0588%;
|
||||||
|
background-size: cover;
|
||||||
|
background-color: #333333;
|
||||||
|
margin: 10px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
hr
|
||||||
|
{
|
||||||
|
margin: 10px 0 10px 0;
|
||||||
|
border-top: 1px solid rgba(255, 255, 255, .60);
|
||||||
|
border-left: 0;
|
||||||
|
width: inherit;
|
||||||
|
height: 2px;
|
||||||
|
}
|
39
front/src/app/pages/collection/collection.component.ts
Normal file
39
front/src/app/pages/collection/collection.component.ts
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
import { Component } from "@angular/core";
|
||||||
|
import { Collection } from "../../models/resources/collection";
|
||||||
|
import { ActivatedRoute } from "@angular/router";
|
||||||
|
import { DomSanitizer, SafeStyle } from "@angular/platform-browser";
|
||||||
|
import { Show, ShowRole } from "../../models/resources/show";
|
||||||
|
import { Page } from "../../models/page";
|
||||||
|
import { People } from "../../models/resources/people";
|
||||||
|
import { LibraryItem } from "../../models/resources/library-item";
|
||||||
|
import { ItemsUtils } from "../../misc/items-utils";
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: "app-collection",
|
||||||
|
templateUrl: "./collection.component.html",
|
||||||
|
styleUrls: ["./collection.component.scss"]
|
||||||
|
})
|
||||||
|
export class CollectionComponent
|
||||||
|
{
|
||||||
|
collection: Collection | People;
|
||||||
|
shows: Page<Show>;
|
||||||
|
|
||||||
|
constructor(private route: ActivatedRoute, private sanitizer: DomSanitizer)
|
||||||
|
{
|
||||||
|
this.route.data.subscribe((data) =>
|
||||||
|
{
|
||||||
|
this.collection = data.collection;
|
||||||
|
this.shows = data.shows;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
getThumb(): SafeStyle
|
||||||
|
{
|
||||||
|
return this.sanitizer.bypassSecurityTrustStyle(`url(${this.collection.poster})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
getDate(item: LibraryItem | Show | ShowRole | Collection | People): string
|
||||||
|
{
|
||||||
|
return ItemsUtils.getDate(item);
|
||||||
|
}
|
||||||
|
}
|
118
front/src/app/pages/metadata-edit/metadata-edit.component.html
Normal file
118
front/src/app/pages/metadata-edit/metadata-edit.component.html
Normal file
@ -0,0 +1,118 @@
|
|||||||
|
<!--suppress TypeScriptUnresolvedVariable ($event.target.value does exist) -->
|
||||||
|
<h2 mat-dialog-title>Editing metadata of {{this.show.title}}</h2>
|
||||||
|
<div matDialogContent class="pb-4">
|
||||||
|
<mat-accordion>
|
||||||
|
<mat-expansion-panel expanded="true">
|
||||||
|
<mat-expansion-panel-header>
|
||||||
|
<mat-panel-title>Edit metadata</mat-panel-title>
|
||||||
|
<mat-panel-description>Manually edit each property</mat-panel-description>
|
||||||
|
</mat-expansion-panel-header>
|
||||||
|
<form #showForm="ngForm" class="pt-3">
|
||||||
|
<mat-form-field class="w-100">
|
||||||
|
<mat-label>Title</mat-label>
|
||||||
|
<input matInput [(ngModel)]="this.show.title" name="title">
|
||||||
|
</mat-form-field>
|
||||||
|
|
||||||
|
<mat-form-field class="w-100">
|
||||||
|
<mat-label>Aliases</mat-label>
|
||||||
|
<mat-chip-list #aliasList>
|
||||||
|
<mat-chip *ngFor="let alias of this.show.aliases" (removed)="removeAlias(alias)" removable="true">
|
||||||
|
{{alias}}
|
||||||
|
<mat-icon matChipRemove>cancel</mat-icon>
|
||||||
|
</mat-chip>
|
||||||
|
<input placeholder="New alias..." [matChipInputFor]="aliasList" (matChipInputTokenEnd)="addAlias($event)" />
|
||||||
|
</mat-chip-list>
|
||||||
|
</mat-form-field>
|
||||||
|
|
||||||
|
<mat-form-field class="w-100">
|
||||||
|
<mat-label>Overview</mat-label>
|
||||||
|
<textarea matInput [(ngModel)]="this.show.overview" name="overview"></textarea>
|
||||||
|
</mat-form-field>
|
||||||
|
|
||||||
|
<mat-form-field class="w-25 pr-3">
|
||||||
|
<mat-label>Air period</mat-label>
|
||||||
|
<mat-date-range-input [rangePicker]="picker">
|
||||||
|
<input matStartDate placeholder="Start date" name="startAir" [(ngModel)]="this.show.startAir">
|
||||||
|
<input matEndDate placeholder="End date" name="endAir" [(ngModel)]="this.show.endAir">
|
||||||
|
</mat-date-range-input>
|
||||||
|
<mat-datepicker-toggle matSuffix [for]="picker"></mat-datepicker-toggle>
|
||||||
|
<mat-date-range-picker #picker></mat-date-range-picker>
|
||||||
|
</mat-form-field>
|
||||||
|
|
||||||
|
<mat-form-field class="w-50">
|
||||||
|
<mat-label>Status</mat-label>
|
||||||
|
<mat-select>
|
||||||
|
<mat-option value="Finished">Finished</mat-option>
|
||||||
|
<mat-option value="Airing">Airing</mat-option>
|
||||||
|
<mat-option value="Planned">Planned</mat-option>
|
||||||
|
</mat-select>
|
||||||
|
</mat-form-field>
|
||||||
|
|
||||||
|
<mat-form-field class="w-100">
|
||||||
|
<mat-label>Genres</mat-label>
|
||||||
|
<mat-chip-list #genreList>
|
||||||
|
<mat-chip *ngFor="let genre of this.show.genres" (removed)="removeGenre(genre)" removable="true">
|
||||||
|
{{genre.name}}
|
||||||
|
<mat-icon matChipRemove>cancel</mat-icon>
|
||||||
|
</mat-chip>
|
||||||
|
<input #genreInput placeholder="New genre..."
|
||||||
|
[formControl]="genreForm"
|
||||||
|
[matChipInputFor]="genreList"
|
||||||
|
(matChipInputTokenEnd)="addGenre($event); $event.input.value = null;"
|
||||||
|
[matAutocomplete]="genreAuto" />
|
||||||
|
<mat-autocomplete #genreAuto="matAutocomplete"
|
||||||
|
(optionSelected)="autocompleteGenre($event); genreInput.value = null;">
|
||||||
|
<mat-option *ngFor="let genre of this.filteredGenres | async" [value]="genre">
|
||||||
|
{{genre.name}}
|
||||||
|
</mat-option>
|
||||||
|
</mat-autocomplete>
|
||||||
|
</mat-chip-list>
|
||||||
|
</mat-form-field>
|
||||||
|
|
||||||
|
<mat-form-field class="w-100">
|
||||||
|
<mat-label>Trailer</mat-label>
|
||||||
|
<input matInput [(ngModel)]="this.show.trailer" name="trailer">
|
||||||
|
</mat-form-field>
|
||||||
|
|
||||||
|
<mat-form-field class="w-100">
|
||||||
|
<mat-label>Studio</mat-label>
|
||||||
|
<input matInput [value]="this.show.studio?.name"
|
||||||
|
[formControl]="studioForm"
|
||||||
|
(input)="this.show.studio = {id: 0, slug: null, name: $event.target.value};"
|
||||||
|
[matAutocomplete]="studioAuto" name="studio">
|
||||||
|
<mat-autocomplete #studioAuto="matAutocomplete" (optionSelected)="this.show.studio = $event.option.value">
|
||||||
|
<mat-option *ngFor="let studio of this.filteredStudios | async" [value]="studio">
|
||||||
|
{{studio.name}}
|
||||||
|
</mat-option>
|
||||||
|
</mat-autocomplete>
|
||||||
|
</mat-form-field>
|
||||||
|
</form>
|
||||||
|
</mat-expansion-panel>
|
||||||
|
<mat-expansion-panel>
|
||||||
|
<mat-expansion-panel-header>
|
||||||
|
<mat-panel-title>Identify show</mat-panel-title>
|
||||||
|
<mat-panel-description>Search on metadata providers</mat-panel-description>
|
||||||
|
</mat-expansion-panel-header>
|
||||||
|
<mat-form-field class="w-100 mx-2">
|
||||||
|
<mat-label>Search for</mat-label>
|
||||||
|
<input matInput value="{{show.title}}" (input)="this.reIdentify($event.target.value)">
|
||||||
|
</mat-form-field>
|
||||||
|
<mat-form-field *ngFor="let provider of this.providers" class="provider px-1">
|
||||||
|
<mat-label>{{provider.name}} ID</mat-label>
|
||||||
|
<input matInput [value]="this.getMetadataID(provider)?.dataID" (input)="this.setMetadataID(provider, $event.target.value)" >
|
||||||
|
</mat-form-field>
|
||||||
|
<app-shows-grid #identifyGrid [externalShows]="true" (clickCallback)="this.identifyID($event)"></app-shows-grid>
|
||||||
|
</mat-expansion-panel>
|
||||||
|
</mat-accordion>
|
||||||
|
</div>
|
||||||
|
<div mat-dialog-actions align="end">
|
||||||
|
<button mat-button mat-dialog-close>Cancel</button>
|
||||||
|
<button mat-button (click)="apply()">
|
||||||
|
<mat-icon *ngIf="this.metadataChanged"
|
||||||
|
style="color: red;"
|
||||||
|
class="mr-2"
|
||||||
|
matTooltip="You changed an external id, the whole show's metadata will be refreshed. Individual changes made won't last."
|
||||||
|
matTooltipPosition="above">warning</mat-icon>
|
||||||
|
Apply
|
||||||
|
</button>
|
||||||
|
</div>
|
@ -0,0 +1,18 @@
|
|||||||
|
@import "~bootstrap/scss/functions";
|
||||||
|
@import "~bootstrap/scss/variables";
|
||||||
|
@import "~bootstrap/scss/mixins/breakpoints";
|
||||||
|
|
||||||
|
.provider
|
||||||
|
{
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
@include media-breakpoint-up(md)
|
||||||
|
{
|
||||||
|
width: 33%;
|
||||||
|
}
|
||||||
|
|
||||||
|
@include media-breakpoint-up(lg)
|
||||||
|
{
|
||||||
|
width: 25%;
|
||||||
|
}
|
||||||
|
}
|
188
front/src/app/pages/metadata-edit/metadata-edit.component.ts
Normal file
188
front/src/app/pages/metadata-edit/metadata-edit.component.ts
Normal file
@ -0,0 +1,188 @@
|
|||||||
|
import { Component, Inject, OnInit, ViewChild } from "@angular/core";
|
||||||
|
import { FormControl } from "@angular/forms";
|
||||||
|
import { MAT_DIALOG_DATA, MatDialogRef } from "@angular/material/dialog";
|
||||||
|
import { HttpClient } from "@angular/common/http";
|
||||||
|
import { Page } from "../../models/page";
|
||||||
|
import { Show } from "../../models/resources/show";
|
||||||
|
import { Genre } from "../../models/resources/genre";
|
||||||
|
import { MatChipInputEvent } from "@angular/material/chips";
|
||||||
|
import { MatAutocompleteSelectedEvent } from "@angular/material/autocomplete";
|
||||||
|
import { Observable, of } from "rxjs";
|
||||||
|
import { catchError, filter, map, mergeAll, tap } from "rxjs/operators";
|
||||||
|
import { Studio } from "../../models/resources/studio";
|
||||||
|
import { Provider } from "../../models/provider";
|
||||||
|
import { MatSnackBar } from "@angular/material/snack-bar";
|
||||||
|
import { ShowGridComponent } from "../../components/show-grid/show-grid.component";
|
||||||
|
import { GenreService, ShowService, StudioService } from "../../services/api.service";
|
||||||
|
import { ExternalID } from "../../models/external-id";
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: "app-metadata-edit",
|
||||||
|
templateUrl: "./metadata-edit.component.html",
|
||||||
|
styleUrls: ["./metadata-edit.component.scss"]
|
||||||
|
})
|
||||||
|
export class MetadataEditComponent implements OnInit
|
||||||
|
{
|
||||||
|
studioForm: FormControl = new FormControl();
|
||||||
|
filteredStudios: Observable<Studio[]>;
|
||||||
|
|
||||||
|
genreForm: FormControl = new FormControl();
|
||||||
|
filteredGenres: Observable<Genre[]>;
|
||||||
|
|
||||||
|
@ViewChild("identifyGrid") private identifyGrid: ShowGridComponent;
|
||||||
|
private _identifying: Observable<Show[]>;
|
||||||
|
private _identifiedShows: [string, Show[]];
|
||||||
|
public providers: Provider[] = [];
|
||||||
|
|
||||||
|
public metadataChanged: boolean = false;
|
||||||
|
|
||||||
|
constructor(public dialogRef: MatDialogRef<MetadataEditComponent>,
|
||||||
|
@Inject(MAT_DIALOG_DATA) public show: Show,
|
||||||
|
private http: HttpClient,
|
||||||
|
private showsApi: ShowService,
|
||||||
|
private studioApi: StudioService,
|
||||||
|
private genreApi: GenreService,
|
||||||
|
private snackBar: MatSnackBar)
|
||||||
|
{
|
||||||
|
this.http.get<Page<Provider>>("/api/providers").subscribe(result =>
|
||||||
|
{
|
||||||
|
this.providers = result.items;
|
||||||
|
});
|
||||||
|
|
||||||
|
this.reIdentify(this.show.title);
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnInit(): void
|
||||||
|
{
|
||||||
|
this.filteredGenres = this.genreForm.valueChanges
|
||||||
|
.pipe(
|
||||||
|
filter(x => x),
|
||||||
|
map(x => typeof x === "string" ? x : x.name),
|
||||||
|
map(x => this.genreApi.search(x)),
|
||||||
|
mergeAll(),
|
||||||
|
catchError(x =>
|
||||||
|
{
|
||||||
|
console.log(x);
|
||||||
|
return [];
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
this.filteredStudios = this.studioForm.valueChanges
|
||||||
|
.pipe(
|
||||||
|
filter(x => x),
|
||||||
|
map(x => typeof x === "string" ? x : x.name),
|
||||||
|
map(x => this.studioApi.search(x)),
|
||||||
|
mergeAll(),
|
||||||
|
catchError(x =>
|
||||||
|
{
|
||||||
|
console.log(x);
|
||||||
|
return [];
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
apply(): void
|
||||||
|
{
|
||||||
|
if (this.metadataChanged)
|
||||||
|
{
|
||||||
|
this.http.post("/api/show/re-identify/" + this.show.slug, this.show.externalIDs).subscribe(
|
||||||
|
() => {},
|
||||||
|
() =>
|
||||||
|
{
|
||||||
|
this.snackBar.open("An unknown error occurred.", null, {
|
||||||
|
horizontalPosition: "left",
|
||||||
|
panelClass: ["snackError"],
|
||||||
|
duration: 2500
|
||||||
|
});
|
||||||
|
}
|
||||||
|
);
|
||||||
|
this.dialogRef.close(this.show);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
this.showsApi.edit(this.show).subscribe(() =>
|
||||||
|
{
|
||||||
|
this.dialogRef.close(this.show);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
addAlias(event: MatChipInputEvent): void
|
||||||
|
{
|
||||||
|
const input: HTMLInputElement = event.input;
|
||||||
|
const value: string = event.value;
|
||||||
|
|
||||||
|
this.show.aliases.push(value);
|
||||||
|
if (input)
|
||||||
|
input.value = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
removeAlias(alias: string): void
|
||||||
|
{
|
||||||
|
const i: number = this.show.aliases.indexOf(alias);
|
||||||
|
this.show.aliases.splice(i, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
addGenre(event: MatChipInputEvent): void
|
||||||
|
{
|
||||||
|
const input: HTMLInputElement = event.input;
|
||||||
|
const value: string = event.value;
|
||||||
|
const genre: Genre = {id: 0, slug: null, name: value};
|
||||||
|
|
||||||
|
this.show.genres.push(genre);
|
||||||
|
if (input)
|
||||||
|
input.value = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
removeGenre(genre: Genre): void
|
||||||
|
{
|
||||||
|
const i: number = this.show.genres.indexOf(genre);
|
||||||
|
this.show.genres.splice(i, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
autocompleteGenre(event: MatAutocompleteSelectedEvent): void
|
||||||
|
{
|
||||||
|
this.show.genres.push(event.option.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
identityShow(name: string): Observable<Show[]>
|
||||||
|
{
|
||||||
|
if (this._identifiedShows && this._identifiedShows[0] === name)
|
||||||
|
return of(this._identifiedShows[1]);
|
||||||
|
this._identifying = this.http.get<Show[]>("/api/show/identify/" + name + "?isMovie=" + this.show.isMovie).pipe(
|
||||||
|
tap(result => this._identifiedShows = [name, result])
|
||||||
|
);
|
||||||
|
return this._identifying;
|
||||||
|
}
|
||||||
|
|
||||||
|
reIdentify(search: string): void
|
||||||
|
{
|
||||||
|
// TODO implement this
|
||||||
|
// this.identityShow(search).subscribe(x => this.identifyGrid.shows = x);
|
||||||
|
}
|
||||||
|
|
||||||
|
getMetadataID(provider: Provider): ExternalID
|
||||||
|
{
|
||||||
|
return this.show.externalIDs.find(x => x.provider.name === provider.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
setMetadataID(provider: Provider, id: string, link: string = null): void
|
||||||
|
{
|
||||||
|
const i: number = this.show.externalIDs.findIndex(x => x.provider.name === provider.name);
|
||||||
|
|
||||||
|
this.metadataChanged = true;
|
||||||
|
if (i !== -1)
|
||||||
|
{
|
||||||
|
this.show.externalIDs[i].dataID = id;
|
||||||
|
this.show.externalIDs[i].link = link;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
this.show.externalIDs.push({provider, dataID: id, link});
|
||||||
|
}
|
||||||
|
|
||||||
|
identifyID(show: Show): void
|
||||||
|
{
|
||||||
|
for (const id of show.externalIDs)
|
||||||
|
this.setMetadataID(id.provider, id.dataID, id.link);
|
||||||
|
}
|
||||||
|
}
|
9
front/src/app/pages/not-found/not-found.component.html
Normal file
9
front/src/app/pages/not-found/not-found.component.html
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
<br/>
|
||||||
|
<br/>
|
||||||
|
<br/>
|
||||||
|
<br/>
|
||||||
|
<br/>
|
||||||
|
<div class="text-center">
|
||||||
|
<h1>404 Error</h1>
|
||||||
|
<p>The page you requested was not found.</p>
|
||||||
|
</div>
|
11
front/src/app/pages/not-found/not-found.component.ts
Normal file
11
front/src/app/pages/not-found/not-found.component.ts
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
import { Component } from "@angular/core";
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: "app-not-found",
|
||||||
|
templateUrl: "./not-found.component.html",
|
||||||
|
styleUrls: ["./not-found.component.scss"]
|
||||||
|
})
|
||||||
|
export class NotFoundComponent
|
||||||
|
{
|
||||||
|
constructor() { }
|
||||||
|
}
|
155
front/src/app/pages/player/playbackMethodDetector.ts
Normal file
155
front/src/app/pages/player/playbackMethodDetector.ts
Normal file
@ -0,0 +1,155 @@
|
|||||||
|
import { BotInfo, BrowserInfo, detect, NodeInfo, ReactNativeInfo, SearchBotDeviceInfo } from "detect-browser";
|
||||||
|
import { Track, WatchItem } from "../../models/watch-item";
|
||||||
|
|
||||||
|
export enum method
|
||||||
|
{
|
||||||
|
direct = "Direct",
|
||||||
|
transmux = "Transmux",
|
||||||
|
transcode = "Transcode"
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SupportList
|
||||||
|
{
|
||||||
|
container: boolean;
|
||||||
|
videoCodec: boolean;
|
||||||
|
audioCodec: boolean[];
|
||||||
|
|
||||||
|
getPlaybackMethod(): method
|
||||||
|
{
|
||||||
|
if (this.container)
|
||||||
|
{
|
||||||
|
if (this.videoCodec && this.audioCodec)
|
||||||
|
return method.direct;
|
||||||
|
return method.transcode;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.videoCodec && this.audioCodec)
|
||||||
|
return method.transmux;
|
||||||
|
return method.transcode;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getWhatIsSupported(player: HTMLVideoElement, item: WatchItem): SupportList
|
||||||
|
{
|
||||||
|
const supportList: SupportList = new SupportList();
|
||||||
|
const browser: BrowserInfo | SearchBotDeviceInfo | BotInfo | NodeInfo | ReactNativeInfo = detect();
|
||||||
|
|
||||||
|
if (!browser)
|
||||||
|
{
|
||||||
|
supportList.container = false;
|
||||||
|
supportList.videoCodec = false;
|
||||||
|
supportList.audioCodec = item.audios.map(() => false);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
supportList.container = containerIsSupported(player, item.container, browser.name) && item.audios.length <= 1;
|
||||||
|
supportList.videoCodec = videoCodecIsSupported(player, item.video.codec, browser.name);
|
||||||
|
supportList.audioCodec = item.audios.map((x: Track) => audioCodecIsSupported(player, x.codec, browser.name));
|
||||||
|
}
|
||||||
|
return (supportList);
|
||||||
|
}
|
||||||
|
|
||||||
|
function containerIsSupported(player: HTMLVideoElement, container: string, browser: string): boolean
|
||||||
|
{
|
||||||
|
switch (container)
|
||||||
|
{
|
||||||
|
case "asf":
|
||||||
|
return browser === "tizen" || browser === "orsay" || browser === "edge";
|
||||||
|
case "avi":
|
||||||
|
return browser === "tizen" || browser === "orsay" || browser === "edge";
|
||||||
|
case "mpg":
|
||||||
|
case "mpeg":
|
||||||
|
return browser === "tizen" || browser === "orsay" || browser === "edge";
|
||||||
|
case "flv":
|
||||||
|
return browser === "tizen" || browser === "orsay";
|
||||||
|
case "3gp":
|
||||||
|
case "mts":
|
||||||
|
case "trp":
|
||||||
|
case "vob":
|
||||||
|
case "vro":
|
||||||
|
return browser === "tizen" || browser === "orsay";
|
||||||
|
case "mov":
|
||||||
|
return browser === "tizen" || browser === "orsay" || browser === "edge" || browser === "chrome";
|
||||||
|
case "m2ts":
|
||||||
|
return browser === "tizen" || browser === "orsay" || browser === "edge";
|
||||||
|
case "wmv":
|
||||||
|
return browser === "tizen" || browser === "orsay" || browser === "edge";
|
||||||
|
case "ts":
|
||||||
|
return browser === "tizen" || browser === "orsay" || browser === "edge";
|
||||||
|
case "mp4":
|
||||||
|
case "m4v":
|
||||||
|
return true;
|
||||||
|
case "mkv":
|
||||||
|
if (browser === "tizen" || browser === "orsay" || browser === "chrome" || browser === "edge")
|
||||||
|
return true;
|
||||||
|
return !!(player.canPlayType("video/x-matroska") || player.canPlayType("video/mkv"));
|
||||||
|
|
||||||
|
default:
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SHOULD CHECK FOR DEPTH (8bits ok but 10bits unsupported for almost every browsers)
|
||||||
|
function videoCodecIsSupported(player: HTMLVideoElement, codec: string, browser: string): boolean
|
||||||
|
{
|
||||||
|
switch (codec)
|
||||||
|
{
|
||||||
|
case "h264":
|
||||||
|
return !!player.canPlayType("video/mp4; codecs=\"avc1.42E01E, mp4a.40.2\"");
|
||||||
|
case "h265":
|
||||||
|
case "hevc":
|
||||||
|
if (browser === "tizen" || browser === "orsay" || browser === "xboxOne" || browser === "ios")
|
||||||
|
return true;
|
||||||
|
// SHOULD SUPPORT CHROMECAST ULTRA
|
||||||
|
// if (browser.chromecast)
|
||||||
|
// {
|
||||||
|
|
||||||
|
// var isChromecastUltra = userAgent.indexOf('aarch64') !=== -1;
|
||||||
|
// if (isChromecastUltra)
|
||||||
|
// {
|
||||||
|
// return true;
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
return !!player.canPlayType("video/hevc; codecs=\"hevc, aac\"");
|
||||||
|
case "mpeg2video":
|
||||||
|
return browser === "orsay" || browser === "tizen" || browser === "edge";
|
||||||
|
case "vc1":
|
||||||
|
return browser === "orsay" || browser === "tizen" || browser === "edge";
|
||||||
|
case "msmpeg4v2":
|
||||||
|
return browser === "orsay" || browser === "tizen";
|
||||||
|
case "vp8":
|
||||||
|
return !!player.canPlayType("video/webm; codecs=\"vp8");
|
||||||
|
case "vp9":
|
||||||
|
return !!player.canPlayType("video/webm; codecs=\"vp9\"");
|
||||||
|
case "vorbis":
|
||||||
|
return browser === "orsay" || browser === "tizen" || !!player.canPlayType("video/webm; codecs=\"vp8");
|
||||||
|
default:
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SHOULD CHECK FOR NUMBER OF AUDIO CHANNEL (2 ok but 5 not in some browsers)
|
||||||
|
function audioCodecIsSupported(player: HTMLVideoElement, codec: string, browser: string): boolean
|
||||||
|
{
|
||||||
|
switch (codec)
|
||||||
|
{
|
||||||
|
case "mp3":
|
||||||
|
return !!player.canPlayType("video/mp4; codecs=\"avc1.640029, mp4a.69\"") ||
|
||||||
|
!!player.canPlayType("video/mp4; codecs=\"avc1.640029, mp4a.6B\"");
|
||||||
|
case "aac":
|
||||||
|
return !!player.canPlayType("video/mp4; codecs=\"avc1.640029, mp4a.40.2\"");
|
||||||
|
case "mp2":
|
||||||
|
return browser === "orsay" || browser === "tizen" || browser === "edge";
|
||||||
|
case "pcm_s16le":
|
||||||
|
case "pcm_s24le":
|
||||||
|
return browser === "orsay" || browser === "tizen" || browser === "edge";
|
||||||
|
case "aac_latm":
|
||||||
|
return browser === "orsay" || browser === "tizen";
|
||||||
|
case "opus":
|
||||||
|
return !!player.canPlayType("audio/ogg; codecs=\"opus\"");
|
||||||
|
case "flac":
|
||||||
|
return browser === "orsay" || browser === "tizen" || browser === "edge";
|
||||||
|
default:
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
215
front/src/app/pages/player/player.component.html
Normal file
215
front/src/app/pages/player/player.component.html
Normal file
@ -0,0 +1,215 @@
|
|||||||
|
<div id="root"
|
||||||
|
(mouseenter)="!isMobile ? this.showControls = true : null"
|
||||||
|
(mouseleave)="!this.player.paused && !isMobile ? this.showControls = this.isMenuOpen : null"
|
||||||
|
[style.cursor]="this.showControls ? '' : 'none'">
|
||||||
|
<div class="player data-vjs-player">
|
||||||
|
<video id="player" #player
|
||||||
|
[poster]="this.item.backdrop"
|
||||||
|
autoplay muted
|
||||||
|
(click)="this.videoClicked()"
|
||||||
|
(dblclick)="this.fullscreen()"
|
||||||
|
(play)="this.playing = true; this.loading = false"
|
||||||
|
(pause)="this.playing = false"
|
||||||
|
(ended)="this.next()"
|
||||||
|
[volume]="this.volume / 100"
|
||||||
|
[muted]="this.muted"
|
||||||
|
(waiting)="this.loading = true"
|
||||||
|
(canplay)="this.loading = false"
|
||||||
|
(error)="this.playbackError()">
|
||||||
|
</video>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="loadIndicator" [ngClass]="{'d-none': !this.loading}">
|
||||||
|
<div class="spinner-border align-self-center" role="status"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<mat-card class="d-none w-25 m-5 stats" [ngClass]="{'d-block': this.displayStats}">
|
||||||
|
<mat-card-header>
|
||||||
|
<h4>Stats</h4>
|
||||||
|
<div style="flex: 1 1 auto"></div>
|
||||||
|
<button mat-icon-button aria-label="Close" (click)="this.displayStats = false">
|
||||||
|
<mat-icon>close</mat-icon>
|
||||||
|
</button>
|
||||||
|
</mat-card-header>
|
||||||
|
<mat-card-content>
|
||||||
|
Play method: <span>{{this.playMethod}}</span>
|
||||||
|
<br />
|
||||||
|
<br />
|
||||||
|
Video Container:
|
||||||
|
<span>
|
||||||
|
{{this.item.container}}
|
||||||
|
<i class="material-icons">{{this.supportList | supportedButton: "container"}}</i>
|
||||||
|
</span>
|
||||||
|
<br />
|
||||||
|
Video Codec:
|
||||||
|
<span>
|
||||||
|
{{this.item.video.codec}}
|
||||||
|
<i class="material-icons">{{this.supportList | supportedButton: "video"}}</i>
|
||||||
|
</span>
|
||||||
|
<br />
|
||||||
|
Audio Codec:
|
||||||
|
<span>
|
||||||
|
{{
|
||||||
|
this.selectedAudio != -1 && this.selectedAudio < this.item.audios.length
|
||||||
|
? this.item.audios[this.selectedAudio].codec
|
||||||
|
: "none"
|
||||||
|
}}
|
||||||
|
<i class="material-icons">{{this.supportList | supportedButton: "audio":this.selectedAudio}}</i>
|
||||||
|
</span>
|
||||||
|
<br />
|
||||||
|
Subtitle Codec:
|
||||||
|
<span>
|
||||||
|
{{this.selectedSubtitle != -1 ? this.item.subtitles[this.selectedSubtitle].codec : "none"}}
|
||||||
|
<i class="material-icons">{{this.supportList | supportedButton: "subtitle"}}</i>
|
||||||
|
</span>
|
||||||
|
<br />
|
||||||
|
</mat-card-content>
|
||||||
|
</mat-card>
|
||||||
|
|
||||||
|
<div id="hover"
|
||||||
|
(mouseenter)="this.areControlHovered = true"
|
||||||
|
(mouseleave)="this.areControlHovered = false"
|
||||||
|
[ngClass]="{'idle': !this.showControls}">
|
||||||
|
<div class="back">
|
||||||
|
<a mat-icon-button matTooltipPosition="below" matTooltip="Back" (click)="back()">
|
||||||
|
<mat-icon>arrow_back</mat-icon>
|
||||||
|
</a>
|
||||||
|
<h5>{{this.item.showTitle}}</h5>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="controller container-fluid" id="controller">
|
||||||
|
<div class="img d-none d-sm-block">
|
||||||
|
<img [src]="this.item.poster | fallback" alt="poster" />
|
||||||
|
</div>
|
||||||
|
<div class="content">
|
||||||
|
<h3 *ngIf="!this.item.isMovie">S{{this.item.seasonNumber}}:E{{this.item.episodeNumber}} - {{this.item.title}}</h3>
|
||||||
|
<h3 *ngIf="this.item.isMovie">{{this.item.title}}</h3>
|
||||||
|
|
||||||
|
<div id="progress-bar" #progressBar
|
||||||
|
[ngClass]="{'seeking': this.seeking}"
|
||||||
|
(click)="player.currentTime = this.getTimeFromSeekbar($event.pageX);"
|
||||||
|
(mousedown)="this.startSeeking($event)"
|
||||||
|
(touchstart)="this.startSeeking($event)">
|
||||||
|
<div class="seek-bar">
|
||||||
|
<div id="buffered" [style.width]="player.buffered | bufferToWidth: player.duration">
|
||||||
|
</div>
|
||||||
|
<div id="progress" [style.width]="(player.currentTime / player.duration * 100) + '%'"></div>
|
||||||
|
</div>
|
||||||
|
<div id="thumb" [style.transform]="'translateX(' + (player.currentTime / player.duration * 100) + '%)'">
|
||||||
|
<div></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="buttons">
|
||||||
|
<div class="left">
|
||||||
|
<a *ngIf="this.item.previousEpisode" mat-icon-button matTooltipPosition="above" matTooltip="Previous"
|
||||||
|
(click)="previous()">
|
||||||
|
<mat-icon>skip_previous</mat-icon>
|
||||||
|
</a>
|
||||||
|
<button mat-icon-button matTooltipPosition="above" id="play"
|
||||||
|
[matTooltip]="this.playing ? 'Pause' : 'Play'"
|
||||||
|
(click)="togglePlayback()">
|
||||||
|
<mat-icon>{{this.playing ? 'pause' : 'play_arrow'}}</mat-icon>
|
||||||
|
</button>
|
||||||
|
<a mat-icon-button id="nextBtn" *ngIf="this.item.nextEpisode" (click)="next()">
|
||||||
|
<mat-icon>skip_next</mat-icon>
|
||||||
|
|
||||||
|
<div id="next">
|
||||||
|
<div id="main">
|
||||||
|
<img src="{{this.item.nextEpisode.thumbnail}}" alt="next episode thumbnail" />
|
||||||
|
</div>
|
||||||
|
<div id="overview">
|
||||||
|
<h6>S{{this.item.nextEpisode.seasonNumber}}:E{{this.item.nextEpisode.episodeNumber}} - {{this.item.nextEpisode.title}}</h6>
|
||||||
|
<p>{{this.item.nextEpisode.overview}}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
<div id="volume" [ngClass]="{'d-none': this.isMobile}">
|
||||||
|
<button mat-icon-button matTooltipPosition="above" matTooltip="Volume"
|
||||||
|
(click)="this.muted = !this.muted">
|
||||||
|
<mat-icon>{{this.volume | volumeToButton: this.muted}}</mat-icon>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<mat-slider [value]="this.volume" (input)="this.volume = $event.value"></mat-slider>
|
||||||
|
</div>
|
||||||
|
<p>{{player.currentTime | formatTime: player.duration}} / {{player.duration | formatTime}}</p>
|
||||||
|
</div>
|
||||||
|
<div class="right">
|
||||||
|
<button *ngIf="this.item.audios.length > 1" mat-icon-button
|
||||||
|
matTooltipPosition="above" matTooltip="Select audio track">
|
||||||
|
<mat-icon>music_note</mat-icon>
|
||||||
|
</button>
|
||||||
|
<button *ngIf="this.item.subtitles.length > 0" mat-icon-button [matMenuTriggerFor]="subtitles"
|
||||||
|
(menuOpened)="this.isMenuOpen = true"
|
||||||
|
matTooltipPosition="above" matTooltip="Select subtitle track">
|
||||||
|
<mat-icon>closed_caption</mat-icon>
|
||||||
|
</button>
|
||||||
|
<button mat-icon-button matTooltipPosition="above" matTooltip="Cast">
|
||||||
|
<mat-icon>cast</mat-icon>
|
||||||
|
</button>
|
||||||
|
<button mat-icon-button matTooltipPosition="above" matTooltip="Settings"
|
||||||
|
(menuOpened)="this.isMenuOpen = true"
|
||||||
|
[matMenuTriggerFor]="settings">
|
||||||
|
<mat-icon>settings</mat-icon>
|
||||||
|
</button>
|
||||||
|
<button mat-icon-button matTooltipPosition="above"
|
||||||
|
[ngClass]="{'d-none': this.isMobile}"
|
||||||
|
[matTooltip]="this.isFullScreen ? 'Exit fullscreen' : 'Fullscreen'"
|
||||||
|
(click)="fullscreen()">
|
||||||
|
<mat-icon>{{this.isFullScreen ? "fullscreen_exit" : "fullscreen"}}</mat-icon>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<mat-menu #subtitles="matMenu" (closed)="this.isMenuOpen = false">
|
||||||
|
<ng-template matMenuContent>
|
||||||
|
<button [ngClass]="{'selected': this.selectedSubtitle === -1}" mat-menu-item
|
||||||
|
(click)="selectSubtitle(null)">
|
||||||
|
<span>None</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div *ngFor="let subtitle of this.item.subtitles; index as i">
|
||||||
|
<button [ngClass]="{'selected': this.selectedSubtitle === i}"
|
||||||
|
mat-menu-item *ngIf="subtitle.codec === 'ass' || subtitle.codec === 'subrip';
|
||||||
|
else elseBlock"
|
||||||
|
(click)="selectSubtitle(subtitle)">
|
||||||
|
<span>{{subtitle.displayName}}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<ng-template #elseBlock>
|
||||||
|
<button mat-menu-item disabled>
|
||||||
|
<span>{{subtitle.displayName}} ({{subtitle.codec}})</span>
|
||||||
|
</button>
|
||||||
|
</ng-template>
|
||||||
|
</div>
|
||||||
|
</ng-template>
|
||||||
|
</mat-menu>
|
||||||
|
|
||||||
|
<mat-menu #settings="matMenu" (closed)="this.isMenuOpen = false">
|
||||||
|
<ng-template matMenuContent>
|
||||||
|
<button mat-menu-item (click)="this.displayStats = !this.displayStats">
|
||||||
|
<span>Stats</span>
|
||||||
|
</button>
|
||||||
|
<button mat-menu-item [matMenuTriggerFor]="method">
|
||||||
|
<span>Method</span>
|
||||||
|
</button>
|
||||||
|
</ng-template>
|
||||||
|
</mat-menu>
|
||||||
|
|
||||||
|
<mat-menu #method="matMenu">
|
||||||
|
<ng-template matMenuContent>
|
||||||
|
<button mat-menu-item (click)="selectPlayMethod(methodType.direct)">
|
||||||
|
<span>Direct Play</span>
|
||||||
|
</button>
|
||||||
|
<button mat-menu-item (click)="selectPlayMethod(methodType.transmux)">
|
||||||
|
<span>Transmux</span>
|
||||||
|
</button>
|
||||||
|
<button mat-menu-item (click)="selectPlayMethod(methodType.transcode)">
|
||||||
|
<span>Transcode</span>
|
||||||
|
</button>
|
||||||
|
</ng-template>
|
||||||
|
</mat-menu>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
365
front/src/app/pages/player/player.component.scss
Normal file
365
front/src/app/pages/player/player.component.scss
Normal file
@ -0,0 +1,365 @@
|
|||||||
|
@import "vtt-subtitles";
|
||||||
|
|
||||||
|
.player
|
||||||
|
{
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
background: #000;
|
||||||
|
|
||||||
|
> video
|
||||||
|
{
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#hover
|
||||||
|
{
|
||||||
|
transition: opacity .2s linear;
|
||||||
|
opacity: 1;
|
||||||
|
visibility: visible;
|
||||||
|
|
||||||
|
&.idle
|
||||||
|
{
|
||||||
|
transition: opacity .6s linear, visibility 0s .6s;
|
||||||
|
opacity: 0;
|
||||||
|
visibility: hidden;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.back
|
||||||
|
{
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.6);
|
||||||
|
padding: .33%;
|
||||||
|
display: flex;
|
||||||
|
|
||||||
|
> a
|
||||||
|
{
|
||||||
|
outline: none;
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
> h5
|
||||||
|
{
|
||||||
|
margin: 0 0 0 .5rem;
|
||||||
|
align-self: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.controller
|
||||||
|
{
|
||||||
|
position: fixed;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.6);
|
||||||
|
display: flex;
|
||||||
|
padding: 1%;
|
||||||
|
|
||||||
|
.img
|
||||||
|
{
|
||||||
|
width: 15%;
|
||||||
|
position: relative;
|
||||||
|
height: auto;
|
||||||
|
|
||||||
|
> img
|
||||||
|
{
|
||||||
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
bottom: 0;
|
||||||
|
position: absolute;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.content
|
||||||
|
{
|
||||||
|
width: 100%;
|
||||||
|
margin-left: 1rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
.buttons
|
||||||
|
{
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
justify-content: space-between;
|
||||||
|
|
||||||
|
> div
|
||||||
|
{
|
||||||
|
&.left
|
||||||
|
{
|
||||||
|
align-self: start;
|
||||||
|
display: flex;
|
||||||
|
|
||||||
|
> p
|
||||||
|
{
|
||||||
|
margin: 0 0 0 1rem;
|
||||||
|
align-self: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.right
|
||||||
|
{
|
||||||
|
align-self: end;
|
||||||
|
}
|
||||||
|
|
||||||
|
> button
|
||||||
|
{
|
||||||
|
margin-left: .3rem;
|
||||||
|
margin-right: .3rem;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
> a
|
||||||
|
{
|
||||||
|
margin-left: .3rem;
|
||||||
|
margin-right: .3rem;
|
||||||
|
outline: none;
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: inherit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#progress-bar
|
||||||
|
{
|
||||||
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
padding-top: 1rem;
|
||||||
|
padding-bottom: 1rem;
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
.seek-bar
|
||||||
|
{
|
||||||
|
width: 100%;
|
||||||
|
height: 4px;
|
||||||
|
position: relative;
|
||||||
|
background-color: rgba(255, 255, 255, .2);
|
||||||
|
transform: scaleY(.6);
|
||||||
|
|
||||||
|
#progress
|
||||||
|
{
|
||||||
|
width: 0;
|
||||||
|
height: 100%;
|
||||||
|
background-color: var(--accentColor);
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#buffered
|
||||||
|
{
|
||||||
|
width: 0;
|
||||||
|
height: 100%;
|
||||||
|
background-color: rgba(255, 255, 255, .5);
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
bottom: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#thumb
|
||||||
|
{
|
||||||
|
width: 100%;
|
||||||
|
height: 12px;
|
||||||
|
position: absolute;
|
||||||
|
left: -6px;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
margin: auto;
|
||||||
|
opacity: 0;
|
||||||
|
|
||||||
|
> div
|
||||||
|
{
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
border-radius: 6px;
|
||||||
|
background-color: var(--accentColor);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.hoverEnabled &:hover, &.seeking
|
||||||
|
{
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
.seek-bar
|
||||||
|
{
|
||||||
|
transform: scaleY(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#thumb
|
||||||
|
{
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#nextBtn
|
||||||
|
{
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
.hoverEnabled &:hover
|
||||||
|
{
|
||||||
|
#next
|
||||||
|
{
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#next
|
||||||
|
{
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
bottom: 100%;
|
||||||
|
display: none;
|
||||||
|
background-color: #212121;
|
||||||
|
white-space: normal;
|
||||||
|
line-height: normal;
|
||||||
|
cursor: default;
|
||||||
|
height: 150px;
|
||||||
|
|
||||||
|
#main
|
||||||
|
{
|
||||||
|
width: auto;
|
||||||
|
height: 100%;
|
||||||
|
flex-shrink: 0;
|
||||||
|
flex-grow: 0;
|
||||||
|
|
||||||
|
> img
|
||||||
|
{
|
||||||
|
width: auto;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#overview
|
||||||
|
{
|
||||||
|
padding: 1%;
|
||||||
|
width: 50%;
|
||||||
|
min-width: 300px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
> p
|
||||||
|
{
|
||||||
|
text-align: justify;
|
||||||
|
font-weight: 300;
|
||||||
|
overflow: hidden;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#volume
|
||||||
|
{
|
||||||
|
display: flex;
|
||||||
|
|
||||||
|
> button
|
||||||
|
{
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hoverEnabled &:hover, &:focus-within
|
||||||
|
{
|
||||||
|
> mat-slider
|
||||||
|
{
|
||||||
|
width: 100px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
> mat-slider
|
||||||
|
{
|
||||||
|
width: 0;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 0;
|
||||||
|
height: 40px;
|
||||||
|
overflow: hidden;
|
||||||
|
transition: width .2s cubic-bezier(0.4,0, 1, 1);
|
||||||
|
|
||||||
|
> div
|
||||||
|
{
|
||||||
|
top: 19px;
|
||||||
|
left: 10px;
|
||||||
|
right: 10px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.mat-menu-item
|
||||||
|
{
|
||||||
|
outline: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selected
|
||||||
|
{
|
||||||
|
background: #595959 !important;
|
||||||
|
color: var(--accentColor);
|
||||||
|
font-weight: 900;
|
||||||
|
}
|
||||||
|
|
||||||
|
#loadIndicator
|
||||||
|
{
|
||||||
|
position: fixed;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
background: rgba(0, 0, 0, 0.3);
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.volume
|
||||||
|
{
|
||||||
|
min-width: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-panel
|
||||||
|
{
|
||||||
|
min-width: 250px !important;
|
||||||
|
max-width: 300px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats
|
||||||
|
{
|
||||||
|
> mat-card-header
|
||||||
|
{
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
|
||||||
|
> h4
|
||||||
|
{
|
||||||
|
align-self: center;
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
> button
|
||||||
|
{
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
> mat-card-content > span
|
||||||
|
{
|
||||||
|
float: right;
|
||||||
|
> i
|
||||||
|
{
|
||||||
|
vertical-align: middle;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
593
front/src/app/pages/player/player.component.ts
Normal file
593
front/src/app/pages/player/player.component.ts
Normal file
@ -0,0 +1,593 @@
|
|||||||
|
import { Location } from "@angular/common";
|
||||||
|
import {
|
||||||
|
AfterViewInit,
|
||||||
|
Component, ElementRef, HostListener,
|
||||||
|
Injector,
|
||||||
|
OnDestroy,
|
||||||
|
OnInit,
|
||||||
|
Pipe,
|
||||||
|
PipeTransform,
|
||||||
|
ViewChild,
|
||||||
|
ViewEncapsulation
|
||||||
|
} from "@angular/core";
|
||||||
|
import { MatSnackBar } from "@angular/material/snack-bar";
|
||||||
|
import { DomSanitizer, Title } from "@angular/platform-browser";
|
||||||
|
import { ActivatedRoute, Event, NavigationCancel, NavigationEnd, NavigationStart, Router } from "@angular/router";
|
||||||
|
import { OidcSecurityService } from "angular-auth-oidc-client";
|
||||||
|
import Hls from "hls.js";
|
||||||
|
import { ShowService } from "../../services/api.service";
|
||||||
|
import { StartupService } from "../../services/startup.service";
|
||||||
|
import {
|
||||||
|
getWhatIsSupported,
|
||||||
|
method,
|
||||||
|
SupportList
|
||||||
|
} from "./playbackMethodDetector";
|
||||||
|
import { AppComponent } from "../../app.component";
|
||||||
|
import { Track, WatchItem } from "../../models/watch-item";
|
||||||
|
import SubtitlesOctopus from "libass-wasm/dist/js/subtitles-octopus.js";
|
||||||
|
import MouseMoveEvent = JQuery.MouseMoveEvent;
|
||||||
|
import TouchMoveEvent = JQuery.TouchMoveEvent;
|
||||||
|
|
||||||
|
|
||||||
|
@Pipe({
|
||||||
|
name: "formatTime",
|
||||||
|
pure: true
|
||||||
|
})
|
||||||
|
export class FormatTimePipe implements PipeTransform
|
||||||
|
{
|
||||||
|
transform(value: number, hourCheck: number = null): string
|
||||||
|
{
|
||||||
|
if (isNaN(value) || value === null || value === undefined)
|
||||||
|
return `??:??`;
|
||||||
|
hourCheck ??= value;
|
||||||
|
if (hourCheck >= 3600)
|
||||||
|
return new Date(value * 1000).toISOString().substr(11, 8);
|
||||||
|
return new Date(value * 1000).toISOString().substr(14, 5);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Pipe({
|
||||||
|
name: "bufferToWidth",
|
||||||
|
pure: true
|
||||||
|
})
|
||||||
|
export class BufferToWidthPipe implements PipeTransform
|
||||||
|
{
|
||||||
|
transform(buffered: TimeRanges, duration: number): string
|
||||||
|
{
|
||||||
|
if (buffered.length === 0)
|
||||||
|
return "0";
|
||||||
|
return `${buffered.end(buffered.length - 1) / duration * 100}%`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Pipe({
|
||||||
|
name: "volumeToButton",
|
||||||
|
pure: true
|
||||||
|
})
|
||||||
|
export class VolumeToButtonPipe implements PipeTransform
|
||||||
|
{
|
||||||
|
transform(volume: number, muted: boolean): string
|
||||||
|
{
|
||||||
|
if (volume === 0 || muted)
|
||||||
|
return "volume_off";
|
||||||
|
else if (volume < 25)
|
||||||
|
return "volume_mute";
|
||||||
|
else if (volume < 65)
|
||||||
|
return "volume_down";
|
||||||
|
else
|
||||||
|
return "volume_up";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Pipe({
|
||||||
|
name: "supportedButton",
|
||||||
|
pure: true
|
||||||
|
})
|
||||||
|
export class SupportedButtonPipe implements PipeTransform
|
||||||
|
{
|
||||||
|
transform(supports: SupportList, selector: string, audioIndex: number = 0): string
|
||||||
|
{
|
||||||
|
if (!supports)
|
||||||
|
return "help";
|
||||||
|
switch (selector)
|
||||||
|
{
|
||||||
|
case "container":
|
||||||
|
return supports.container ? "check_circle" : "cancel";
|
||||||
|
case "video":
|
||||||
|
return supports.videoCodec ? "check_circle" : "cancel";
|
||||||
|
case "audio":
|
||||||
|
return (audioIndex >= supports.audioCodec.length || supports.audioCodec[audioIndex])
|
||||||
|
? "check_circle"
|
||||||
|
: "cancel";
|
||||||
|
default:
|
||||||
|
return "help";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: "app-player",
|
||||||
|
templateUrl: "./player.component.html",
|
||||||
|
styleUrls: ["./player.component.scss"],
|
||||||
|
encapsulation: ViewEncapsulation.None
|
||||||
|
})
|
||||||
|
export class PlayerComponent implements OnInit, OnDestroy, AfterViewInit
|
||||||
|
{
|
||||||
|
item: WatchItem;
|
||||||
|
selectedAudio: number = 0;
|
||||||
|
selectedSubtitle: number = -1;
|
||||||
|
playMethod: method = method.direct;
|
||||||
|
supportList: SupportList;
|
||||||
|
playing: boolean = true;
|
||||||
|
loading: boolean = false;
|
||||||
|
seeking: boolean = false;
|
||||||
|
muted: boolean = false;
|
||||||
|
|
||||||
|
private _volume: number = 100;
|
||||||
|
get volume(): number { return this._volume; }
|
||||||
|
set volume(value: number) { this._volume = Math.max(0, Math.min(value, 100)); }
|
||||||
|
|
||||||
|
@ViewChild("player") private playerRef: ElementRef;
|
||||||
|
private get player(): HTMLVideoElement { return this.playerRef.nativeElement; }
|
||||||
|
@ViewChild("progressBar") private progressBarRef: ElementRef;
|
||||||
|
private get progressBar(): HTMLElement { return this.progressBarRef.nativeElement; }
|
||||||
|
|
||||||
|
controlHider: NodeJS.Timeout = null;
|
||||||
|
areControlHovered: boolean = false;
|
||||||
|
isMenuOpen: boolean = false;
|
||||||
|
private _showControls: boolean = true;
|
||||||
|
get showControls(): boolean { return this._showControls; }
|
||||||
|
set showControls(value: boolean)
|
||||||
|
{
|
||||||
|
this._showControls = value;
|
||||||
|
if (this.controlHider)
|
||||||
|
clearTimeout(this.controlHider);
|
||||||
|
if (value)
|
||||||
|
{
|
||||||
|
this.controlHider = setTimeout(() =>
|
||||||
|
{
|
||||||
|
this.showControls = this.player.paused || this.areControlHovered || this.isMenuOpen;
|
||||||
|
}, 2500);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
this.controlHider = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
methodType = method;
|
||||||
|
displayStats: boolean = false;
|
||||||
|
|
||||||
|
|
||||||
|
private subtitlesManager: SubtitlesOctopus;
|
||||||
|
private hlsPlayer: Hls = new Hls();
|
||||||
|
private oidcSecurity: OidcSecurityService;
|
||||||
|
constructor(private route: ActivatedRoute,
|
||||||
|
private sanitizer: DomSanitizer,
|
||||||
|
private snackBar: MatSnackBar,
|
||||||
|
private title: Title,
|
||||||
|
private router: Router,
|
||||||
|
private location: Location,
|
||||||
|
private injector: Injector,
|
||||||
|
private shows: ShowService,
|
||||||
|
private startup: StartupService)
|
||||||
|
{ }
|
||||||
|
|
||||||
|
ngOnInit(): void
|
||||||
|
{
|
||||||
|
document.getElementById("nav").classList.add("d-none");
|
||||||
|
if (AppComponent.isMobile)
|
||||||
|
{
|
||||||
|
if (!this.isFullScreen)
|
||||||
|
this.fullscreen();
|
||||||
|
screen.orientation.lock("landscape");
|
||||||
|
$(document).on("fullscreenchange", () =>
|
||||||
|
{
|
||||||
|
if (document.fullscreenElement == null && this.router.url.startsWith("/watch"))
|
||||||
|
this.back();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
this.route.data.subscribe(data =>
|
||||||
|
{
|
||||||
|
this.item = data.item;
|
||||||
|
|
||||||
|
const name: string = this.item.isMovie
|
||||||
|
? this.item.showTitle
|
||||||
|
: `${this.item.showTitle} S${this.item.seasonNumber}:E${this.item.episodeNumber}`;
|
||||||
|
|
||||||
|
if (this.item.isMovie)
|
||||||
|
this.title.setTitle(`${name} - Kyoo`);
|
||||||
|
else
|
||||||
|
this.title.setTitle(`${name} - Kyoo`);
|
||||||
|
|
||||||
|
setTimeout(() =>
|
||||||
|
{
|
||||||
|
this.snackBar.open(`Playing: ${name}`, null, {
|
||||||
|
verticalPosition: "top",
|
||||||
|
horizontalPosition: "right",
|
||||||
|
duration: 2000,
|
||||||
|
panelClass: "info-panel"
|
||||||
|
});
|
||||||
|
}, 750);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.router.events.subscribe((event: Event) =>
|
||||||
|
{
|
||||||
|
switch (true)
|
||||||
|
{
|
||||||
|
case event instanceof NavigationStart:
|
||||||
|
this.loading = true;
|
||||||
|
break;
|
||||||
|
case event instanceof NavigationEnd:
|
||||||
|
case event instanceof NavigationCancel:
|
||||||
|
this.loading = false;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnDestroy(): void
|
||||||
|
{
|
||||||
|
if (this.subtitlesManager)
|
||||||
|
this.subtitlesManager.dispose();
|
||||||
|
if (this.isFullScreen)
|
||||||
|
document.exitFullscreen();
|
||||||
|
|
||||||
|
document.getElementById("nav").classList.remove("d-none");
|
||||||
|
this.title.setTitle("Kyoo");
|
||||||
|
|
||||||
|
$(document).off();
|
||||||
|
}
|
||||||
|
|
||||||
|
ngAfterViewInit(): void
|
||||||
|
{
|
||||||
|
if (this.oidcSecurity === undefined)
|
||||||
|
this.oidcSecurity = this.injector.get(OidcSecurityService);
|
||||||
|
this.hlsPlayer.config.xhrSetup = xhr =>
|
||||||
|
{
|
||||||
|
const token: string = this.oidcSecurity.getAccessToken();
|
||||||
|
if (token)
|
||||||
|
xhr.setRequestHeader("Authorization", "Bearer " + token);
|
||||||
|
};
|
||||||
|
|
||||||
|
this.showControls = true;
|
||||||
|
|
||||||
|
setTimeout(() => this.route.data.subscribe(() =>
|
||||||
|
{
|
||||||
|
// TODO remove the query param for the method (should be a session setting).
|
||||||
|
const queryMethod: string = this.route.snapshot.queryParams.method;
|
||||||
|
this.supportList = getWhatIsSupported(this.player, this.item);
|
||||||
|
this.selectPlayMethod(queryMethod ? method[queryMethod] : this.supportList.getPlaybackMethod());
|
||||||
|
|
||||||
|
// TODO remove this, it should be a user's setting.
|
||||||
|
const subSlug: string = this.route.snapshot.queryParams.sub;
|
||||||
|
if (subSlug != null)
|
||||||
|
{
|
||||||
|
const languageCode: string = subSlug.substring(0, 3);
|
||||||
|
const forced: boolean = subSlug.length > 3 && subSlug.substring(4) === "for";
|
||||||
|
const sub: Track = this.item.subtitles.find(x => x.language === languageCode && x.isForced === forced);
|
||||||
|
this.selectSubtitle(sub, false);
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
get isFullScreen(): boolean
|
||||||
|
{
|
||||||
|
return document.fullscreenElement != null;
|
||||||
|
}
|
||||||
|
|
||||||
|
get isMobile(): boolean
|
||||||
|
{
|
||||||
|
return AppComponent.isMobile;
|
||||||
|
}
|
||||||
|
|
||||||
|
getTimeFromSeekbar(pageX: number): number
|
||||||
|
{
|
||||||
|
const value: number = (pageX - this.progressBar.offsetLeft) / this.progressBar.clientWidth;
|
||||||
|
const percent: number = Math.max(0, Math.min(value, 1));
|
||||||
|
return percent * this.player.duration;
|
||||||
|
}
|
||||||
|
|
||||||
|
startSeeking(event: MouseEvent | TouchEvent): void
|
||||||
|
{
|
||||||
|
event.preventDefault();
|
||||||
|
this.seeking = true;
|
||||||
|
this.player.pause();
|
||||||
|
const pageX: number = "pageX" in event ? event.pageX : event.changedTouches[0].pageX;
|
||||||
|
this.player.currentTime = this.getTimeFromSeekbar(pageX);
|
||||||
|
}
|
||||||
|
|
||||||
|
@HostListener("document:mouseup", ["$event"])
|
||||||
|
@HostListener("document:touchend", ["$event"])
|
||||||
|
endSeeking(event: MouseEvent | TouchEvent): void
|
||||||
|
{
|
||||||
|
if (!this.seeking)
|
||||||
|
return;
|
||||||
|
event.preventDefault();
|
||||||
|
this.seeking = false;
|
||||||
|
const pageX: number = "pageX" in event ? event.pageX : event.changedTouches[0].pageX;
|
||||||
|
this.player.currentTime = this.getTimeFromSeekbar(pageX);
|
||||||
|
this.player.play();
|
||||||
|
}
|
||||||
|
|
||||||
|
@HostListener("document:touchmove", ["$event"])
|
||||||
|
touchSeek(event: TouchMoveEvent): void
|
||||||
|
{
|
||||||
|
if (this.seeking)
|
||||||
|
this.player.currentTime = this.getTimeFromSeekbar(event.changedTouches[0].pageX);
|
||||||
|
}
|
||||||
|
|
||||||
|
@HostListener("document:mousemove", ["$event"])
|
||||||
|
mouseMove(event: MouseMoveEvent): void
|
||||||
|
{
|
||||||
|
if (this.seeking)
|
||||||
|
this.player.currentTime = this.getTimeFromSeekbar(event.pageX);
|
||||||
|
else if (!AppComponent.isMobile)
|
||||||
|
this.showControls = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
playbackError(): void
|
||||||
|
{
|
||||||
|
if (this.playMethod === method.transcode)
|
||||||
|
{
|
||||||
|
this.snackBar.open("This episode can't be played.", null, {
|
||||||
|
horizontalPosition: "left",
|
||||||
|
panelClass: ["snackError"],
|
||||||
|
duration: 10000
|
||||||
|
});
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
if (this.playMethod === method.direct)
|
||||||
|
this.playMethod = method.transmux;
|
||||||
|
else
|
||||||
|
this.playMethod = method.transcode;
|
||||||
|
this.selectPlayMethod(this.playMethod);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
selectPlayMethod(playMethod: method): void
|
||||||
|
{
|
||||||
|
this.playMethod = playMethod;
|
||||||
|
const url: string = [
|
||||||
|
"/video",
|
||||||
|
this.playMethod.toLowerCase(),
|
||||||
|
this.item.slug,
|
||||||
|
this.playMethod !== method.direct ? "master.m3u8" : null
|
||||||
|
].filter(x => x !== null).join("/");
|
||||||
|
if (this.playMethod === method.direct || this.player.canPlayType("application/vnd.apple.mpegurl"))
|
||||||
|
this.player.src = url;
|
||||||
|
else
|
||||||
|
{
|
||||||
|
this.hlsPlayer.loadSource(url);
|
||||||
|
this.hlsPlayer.attachMedia(this.player);
|
||||||
|
this.hlsPlayer.on(Hls.Events.MANIFEST_LOADED, () =>
|
||||||
|
{
|
||||||
|
this.player.play();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
back(): void
|
||||||
|
{
|
||||||
|
if (this.startup.loadedFromWatch)
|
||||||
|
{
|
||||||
|
this.router.navigate(["show", this.startup.show], {replaceUrl: true});
|
||||||
|
this.startup.loadedFromWatch = false;
|
||||||
|
this.startup.show = null;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
this.location.back();
|
||||||
|
}
|
||||||
|
|
||||||
|
next(): void
|
||||||
|
{
|
||||||
|
if (this.item.nextEpisode == null)
|
||||||
|
return;
|
||||||
|
this.router.navigate(["/watch", this.item.nextEpisode.slug], {
|
||||||
|
queryParamsHandling: "merge",
|
||||||
|
replaceUrl: true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
previous(): void
|
||||||
|
{
|
||||||
|
if (this.item.previousEpisode == null)
|
||||||
|
return;
|
||||||
|
this.router.navigate(["/watch", this.item.previousEpisode.slug], {
|
||||||
|
queryParamsHandling: "merge",
|
||||||
|
replaceUrl: true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
videoClicked(): void
|
||||||
|
{
|
||||||
|
if (AppComponent.isMobile)
|
||||||
|
this.showControls = !this.showControls;
|
||||||
|
else
|
||||||
|
{
|
||||||
|
this.showControls = !this.player.paused;
|
||||||
|
this.togglePlayback();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
togglePlayback(): void
|
||||||
|
{
|
||||||
|
if (this.player.paused)
|
||||||
|
this.player.play();
|
||||||
|
else
|
||||||
|
this.player.pause();
|
||||||
|
}
|
||||||
|
|
||||||
|
fullscreen(): void
|
||||||
|
{
|
||||||
|
if (this.isFullScreen)
|
||||||
|
document.exitFullscreen();
|
||||||
|
else
|
||||||
|
document.body.requestFullscreen();
|
||||||
|
}
|
||||||
|
|
||||||
|
async selectSubtitle(subtitle: Track | number, changeUrl: boolean = true): Promise<void>
|
||||||
|
{
|
||||||
|
if (typeof(subtitle) === "number")
|
||||||
|
{
|
||||||
|
this.selectedSubtitle = subtitle;
|
||||||
|
subtitle = this.item.subtitles[subtitle];
|
||||||
|
}
|
||||||
|
else
|
||||||
|
this.selectedSubtitle = this.item.subtitles.indexOf(subtitle);
|
||||||
|
|
||||||
|
if (changeUrl)
|
||||||
|
{
|
||||||
|
let subSlug: string;
|
||||||
|
if (subtitle != null)
|
||||||
|
{
|
||||||
|
subSlug = subtitle.language;
|
||||||
|
if (subtitle.isForced)
|
||||||
|
subSlug += "-for";
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.router.navigate([], {
|
||||||
|
relativeTo: this.route,
|
||||||
|
queryParams: {sub: subSlug},
|
||||||
|
replaceUrl: true,
|
||||||
|
queryParamsHandling: "merge",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
if (subtitle == null)
|
||||||
|
{
|
||||||
|
this.snackBar.open("Subtitle removed.", null, {
|
||||||
|
verticalPosition: "top",
|
||||||
|
horizontalPosition: "right",
|
||||||
|
duration: 750,
|
||||||
|
panelClass: "info-panel"
|
||||||
|
});
|
||||||
|
if (this.subtitlesManager)
|
||||||
|
this.subtitlesManager.freeTrack();
|
||||||
|
this.removeHtmlTrack();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
this.snackBar.open(`${subtitle.displayName} subtitle loaded.`, null, {
|
||||||
|
verticalPosition: "top",
|
||||||
|
horizontalPosition: "right",
|
||||||
|
duration: 750,
|
||||||
|
panelClass: "info-panel"
|
||||||
|
});
|
||||||
|
this.removeHtmlTrack();
|
||||||
|
|
||||||
|
if (subtitle.codec === "ass")
|
||||||
|
{
|
||||||
|
if (!this.subtitlesManager)
|
||||||
|
{
|
||||||
|
const fonts: { [key: string]: string } = await this.shows.getFonts(this.item.showSlug).toPromise();
|
||||||
|
this.subtitlesManager = new SubtitlesOctopus({
|
||||||
|
video: this.player,
|
||||||
|
subUrl: `subtitle/${subtitle.slug}`,
|
||||||
|
fonts: Object.values(fonts),
|
||||||
|
renderMode: "fast"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
else
|
||||||
|
this.subtitlesManager.setTrackByUrl(`subtitle/${subtitle.slug}`);
|
||||||
|
}
|
||||||
|
else if (subtitle.codec === "subrip")
|
||||||
|
{
|
||||||
|
if (this.subtitlesManager)
|
||||||
|
this.subtitlesManager.freeTrack();
|
||||||
|
|
||||||
|
const track: HTMLTrackElement = document.createElement("track");
|
||||||
|
track.kind = "subtitles";
|
||||||
|
track.label = subtitle.displayName;
|
||||||
|
track.srclang = subtitle.language;
|
||||||
|
track.src = `subtitle/${subtitle.slug}.vtt`;
|
||||||
|
track.classList.add("subtitle_container");
|
||||||
|
track.default = true;
|
||||||
|
track.onload = () =>
|
||||||
|
{
|
||||||
|
this.player.textTracks[0].mode = "showing";
|
||||||
|
};
|
||||||
|
this.player.appendChild(track);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
removeHtmlTrack(): void
|
||||||
|
{
|
||||||
|
const elements: HTMLCollectionOf<HTMLTrackElement> = this.player.getElementsByTagName("track");
|
||||||
|
if (elements.length > 0)
|
||||||
|
elements.item(0).remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
@HostListener("document:keyup", ["$event"])
|
||||||
|
keypress(event: KeyboardEvent): void
|
||||||
|
{
|
||||||
|
if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey)
|
||||||
|
return;
|
||||||
|
|
||||||
|
switch (event.key)
|
||||||
|
{
|
||||||
|
case " ":
|
||||||
|
case "k":
|
||||||
|
case "K":
|
||||||
|
this.togglePlayback();
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "ArrowUp":
|
||||||
|
this.volume += 5;
|
||||||
|
this.snackBar.open(`${this.volume}%`, null, {
|
||||||
|
verticalPosition: "top",
|
||||||
|
horizontalPosition: "right",
|
||||||
|
duration: 300,
|
||||||
|
panelClass: "volume"
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case "ArrowDown":
|
||||||
|
this.volume += 5;
|
||||||
|
this.snackBar.open(`${this.volume}%`, null, {
|
||||||
|
verticalPosition: "top",
|
||||||
|
horizontalPosition: "right",
|
||||||
|
duration: 300,
|
||||||
|
panelClass: "volume"
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "v":
|
||||||
|
case "V":
|
||||||
|
this.selectSubtitle((this.selectedSubtitle + 2) % (this.item.subtitles.length + 1) - 1);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "f":
|
||||||
|
case "F":
|
||||||
|
this.fullscreen();
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "m":
|
||||||
|
case "M":
|
||||||
|
this.muted = !this.muted;
|
||||||
|
this.snackBar.open(this.player.muted ? "Sound muted." : "Sound unmuted", null, {
|
||||||
|
verticalPosition: "top",
|
||||||
|
horizontalPosition: "right",
|
||||||
|
duration: 750,
|
||||||
|
panelClass: "info-panel"
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "n":
|
||||||
|
case "N":
|
||||||
|
this.next();
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "p":
|
||||||
|
case "P":
|
||||||
|
this.previous();
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
5
front/src/app/pages/player/vtt-subtitles.scss
Normal file
5
front/src/app/pages/player/vtt-subtitles.scss
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
::cue
|
||||||
|
{
|
||||||
|
background-color: transparent;
|
||||||
|
text-shadow: -1px -1px 0 #000, 1px -1px 0 #000, -1px 1px 0 #000, 1px 1px 0 #000;
|
||||||
|
}
|
16
front/src/app/pages/search/search.component.html
Normal file
16
front/src/app/pages/search/search.component.html
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
<div *ngIf="items.collections.length > 0" class="container-fluid mt-3">
|
||||||
|
<h3>Collections</h3>
|
||||||
|
</div>
|
||||||
|
<app-items-list [items]="AsPage(items.collections)" type="collection"></app-items-list>
|
||||||
|
<div *ngIf="items.shows.length > 0" class="container-fluid mt-3">
|
||||||
|
<h3>Shows</h3>
|
||||||
|
</div>
|
||||||
|
<app-items-list [items]="AsPage(items.shows)"></app-items-list>
|
||||||
|
<div *ngIf="items.episodes.length > 0" class="container-fluid mt-5">
|
||||||
|
<h3>Episodes</h3>
|
||||||
|
</div>
|
||||||
|
<app-episodes-list displayShowTitle="true" [episodes]="AsPage(items.episodes)"></app-episodes-list>
|
||||||
|
<div *ngIf="items.people.length > 0" class="container-fluid mt-5">
|
||||||
|
<h3>People</h3>
|
||||||
|
</div>
|
||||||
|
<app-people-list [people]="AsPage(items.people)"></app-people-list>
|
0
front/src/app/pages/search/search.component.scss
Normal file
0
front/src/app/pages/search/search.component.scss
Normal file
45
front/src/app/pages/search/search.component.ts
Normal file
45
front/src/app/pages/search/search.component.ts
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
import { Component, OnInit, OnDestroy, AfterViewInit } from "@angular/core";
|
||||||
|
import { ActivatedRoute } from "@angular/router";
|
||||||
|
import { SearchResult } from "../../models/search-result";
|
||||||
|
import { Title } from "@angular/platform-browser";
|
||||||
|
import { Page } from "../../models/page";
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: "app-search",
|
||||||
|
templateUrl: "./search.component.html",
|
||||||
|
styleUrls: ["./search.component.scss"]
|
||||||
|
})
|
||||||
|
export class SearchComponent implements OnInit, OnDestroy, AfterViewInit
|
||||||
|
{
|
||||||
|
items: SearchResult;
|
||||||
|
|
||||||
|
constructor(private route: ActivatedRoute, private title: Title) { }
|
||||||
|
|
||||||
|
ngOnInit(): void
|
||||||
|
{
|
||||||
|
this.route.data.subscribe((data) =>
|
||||||
|
{
|
||||||
|
this.items = data.items;
|
||||||
|
this.title.setTitle(this.items.query + " - Kyoo");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
ngAfterViewInit(): void
|
||||||
|
{
|
||||||
|
const searchBar: HTMLInputElement = document.getElementById("search") as HTMLInputElement;
|
||||||
|
searchBar.classList.add("searching");
|
||||||
|
searchBar.value = this.items.query;
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnDestroy(): void
|
||||||
|
{
|
||||||
|
const searchBar: HTMLInputElement = document.getElementById("search") as HTMLInputElement;
|
||||||
|
searchBar.classList.remove("searching");
|
||||||
|
searchBar.value = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
AsPage<T>(collection: T[]): Page<T>
|
||||||
|
{
|
||||||
|
return new Page<T>({this: "", items: collection, next: null, count: collection.length});
|
||||||
|
}
|
||||||
|
}
|
107
front/src/app/pages/show-details/show-details.component.html
Normal file
107
front/src/app/pages/show-details/show-details.component.html
Normal file
@ -0,0 +1,107 @@
|
|||||||
|
<div class="backdrop">
|
||||||
|
<img id="backdrop" [src]="this.show.thumbnail | fallback" alt="backdrop" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="header container pt-sm-5">
|
||||||
|
<div class="row">
|
||||||
|
<div class="poster d-none d-sm-block">
|
||||||
|
<div [style.background-image]="getThumb(this.show)"></div>
|
||||||
|
</div>
|
||||||
|
<div class="main col">
|
||||||
|
<div class="info">
|
||||||
|
<h1 class="title">{{this.show.title}}</h1>
|
||||||
|
<h2 class="date" *ngIf="getDate(show)">{{getDate(show)}}</h2>
|
||||||
|
</div>
|
||||||
|
<div class="buttons">
|
||||||
|
<button mat-mini-fab matTooltipPosition="above" matTooltip="Play" class="mr-3" (click)="playClicked()">
|
||||||
|
<mat-icon>play_arrow</mat-icon>
|
||||||
|
</button>
|
||||||
|
<button *ngIf="this.show.trailer" mat-icon-button matTooltipPosition="above" matTooltip="Trailer" (click)="openTrailer()">
|
||||||
|
<mat-icon>local_movies</mat-icon>
|
||||||
|
</button>
|
||||||
|
<a *ngIf="this.show.isMovie" [href]="'/video/' + this.show.slug" download>
|
||||||
|
<button mat-icon-button matTooltipPosition="above" matTooltip="Download">
|
||||||
|
<mat-icon>cloud_download</mat-icon>
|
||||||
|
</button>
|
||||||
|
</a>
|
||||||
|
<button mat-icon-button matTooltipPosition="above" matTooltip="Watched">
|
||||||
|
<mat-icon>done</mat-icon>
|
||||||
|
</button>
|
||||||
|
<button mat-icon-button matTooltipPosition="above" matTooltip="More" [matMenuTriggerFor]="showMenu">
|
||||||
|
<mat-icon>more_horiz</mat-icon>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-3 secondary d-none d-md-block">
|
||||||
|
<img [src]="this.show.logo" #logo alt="" (error)="logo.style.display = 'none'" />
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<p>Studio: <b><a draggable="false"
|
||||||
|
href="/studio/{{this.show.studio?.slug}}"
|
||||||
|
routerLink="/studio/{{this.show.studio?.slug}}">{{this.show.studio?.name}}</a></b></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<mat-menu #showMenu="matMenu">
|
||||||
|
<button mat-menu-item (click)="editMetadata()">Edit metadata</button>
|
||||||
|
<button mat-menu-item (click)="redownloadImages()">Re-download images</button>
|
||||||
|
<button mat-menu-item (click)="extractSubs()">Re-extract subtitles</button>
|
||||||
|
</mat-menu>
|
||||||
|
|
||||||
|
<div class="row pt-3 d-md-none">
|
||||||
|
<div class="col">
|
||||||
|
<p class="mr-1 d-inline-block">Studio: <b><a routerLink="/studio/{{this.show.studio?.slug}}">{{this.show.studio?.name}}</a></b></p>
|
||||||
|
<div class="d-sm-none">
|
||||||
|
<p>Genres:
|
||||||
|
<span *ngFor="let genre of this.show.genres; let isLast = last">
|
||||||
|
<b><a draggable="false"
|
||||||
|
href="/genre/{{genre.slug}}"
|
||||||
|
routerLink="/genre/{{genre.slug}}">{{genre.name}}</a></b>
|
||||||
|
{{isLast ? "" : ", "}}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row pt-3">
|
||||||
|
<div class="col">
|
||||||
|
<p class="text-justify overview">{{this.show.overview}}</p>
|
||||||
|
<ul>
|
||||||
|
<li class="provider" *ngFor="let id of this.show.externalIDs">
|
||||||
|
<a draggable="false" [href]="id.link"><img [src]="id.provider.logo" [alt]="id.provider.name"/></a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<hr class="d-none d-sm-block">
|
||||||
|
<div class="col-3 d-none d-sm-block">
|
||||||
|
<h3 style="opacity: .8;">Genres</h3>
|
||||||
|
<ul>
|
||||||
|
<li *ngFor="let genre of this.show.genres">
|
||||||
|
<b><a draggable="false" class="genre"
|
||||||
|
href="/genre/{{genre.slug}}"
|
||||||
|
routerLink="/genre/{{genre.slug}}">{{genre.name}}</a></b>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<div *ngIf="!this.show.isMovie">
|
||||||
|
<div class="container-fluid mt-3">
|
||||||
|
<mat-form-field>
|
||||||
|
<mat-label>Season</mat-label>
|
||||||
|
<mat-select [(value)]="season" (selectionChange)="getEpisodes(season)">
|
||||||
|
<mat-option *ngFor="let season of this.seasons" [value]="season.seasonNumber">{{season.title}}</mat-option>
|
||||||
|
</mat-select>
|
||||||
|
</mat-form-field>
|
||||||
|
</div>
|
||||||
|
<app-episodes-list [episodes]="episodes[season]"></app-episodes-list>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="container-fluid mt-5">
|
||||||
|
<h3>Staff</h3>
|
||||||
|
</div>
|
||||||
|
<app-people-list [people]="people"></app-people-list>
|
195
front/src/app/pages/show-details/show-details.component.scss
Normal file
195
front/src/app/pages/show-details/show-details.component.scss
Normal file
@ -0,0 +1,195 @@
|
|||||||
|
@import "~bootstrap/scss/functions";
|
||||||
|
@import "~bootstrap/scss/variables";
|
||||||
|
@import "~bootstrap/scss/mixins/breakpoints";
|
||||||
|
@import "variables";
|
||||||
|
|
||||||
|
a
|
||||||
|
{
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.backdrop
|
||||||
|
{
|
||||||
|
margin-top: -68px;
|
||||||
|
position: relative;
|
||||||
|
z-index: -1;
|
||||||
|
min-height: 20vh;
|
||||||
|
|
||||||
|
@include media-breakpoint-up(md)
|
||||||
|
{
|
||||||
|
min-height: 60vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
> img
|
||||||
|
{
|
||||||
|
width: 100%;
|
||||||
|
max-height: 75vh;
|
||||||
|
object-fit: cover;
|
||||||
|
min-height: 20vh;
|
||||||
|
|
||||||
|
@include media-breakpoint-up(md)
|
||||||
|
{
|
||||||
|
min-height: 60vh;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&:after
|
||||||
|
{
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
background-image: linear-gradient(to bottom, rgba(0, 0, 0, 0) 50%, rgba(0, 0, 0, 0.6) 100%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.header
|
||||||
|
{
|
||||||
|
@include media-breakpoint-up(sm)
|
||||||
|
{
|
||||||
|
margin-top: -12rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@include media-breakpoint-up(md)
|
||||||
|
{
|
||||||
|
margin-top: -13rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@include media-breakpoint-up(lg)
|
||||||
|
{
|
||||||
|
margin-top: -19rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@include media-breakpoint-up(xl)
|
||||||
|
{
|
||||||
|
margin-top: -23rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.poster
|
||||||
|
{
|
||||||
|
width: 33%;
|
||||||
|
|
||||||
|
@include media-breakpoint-up(md)
|
||||||
|
{
|
||||||
|
width: 25%;
|
||||||
|
}
|
||||||
|
|
||||||
|
> div
|
||||||
|
{
|
||||||
|
width: 100%;
|
||||||
|
height: 0;
|
||||||
|
padding-top: 147.0588%;
|
||||||
|
background-size: cover;
|
||||||
|
background-color: #333333;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.main
|
||||||
|
{
|
||||||
|
align-self: center;
|
||||||
|
padding-left: 2.5em;
|
||||||
|
|
||||||
|
.info
|
||||||
|
{
|
||||||
|
margin-top: -3.25rem;
|
||||||
|
|
||||||
|
@include media-breakpoint-up(sm)
|
||||||
|
{
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title
|
||||||
|
{
|
||||||
|
font-weight: 900 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.date
|
||||||
|
{
|
||||||
|
font-weight: 300 !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.buttons
|
||||||
|
{
|
||||||
|
> button
|
||||||
|
{
|
||||||
|
outline: none;
|
||||||
|
margin: .3em;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.secondary
|
||||||
|
{
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
> img
|
||||||
|
{
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
> div
|
||||||
|
{
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
> div > p
|
||||||
|
{
|
||||||
|
opacity: .87;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.overview
|
||||||
|
{
|
||||||
|
opacity: .87;
|
||||||
|
|
||||||
|
@include media-breakpoint-up(sm)
|
||||||
|
{
|
||||||
|
padding-top: 2.25rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
hr
|
||||||
|
{
|
||||||
|
margin: 0 10px 0 10px;
|
||||||
|
border-right: 1px solid rgba(255, 255, 255, .60);
|
||||||
|
border-top: 0;
|
||||||
|
height: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.genre
|
||||||
|
{
|
||||||
|
opacity: .8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.provider
|
||||||
|
{
|
||||||
|
display: inline-block;
|
||||||
|
width: 3rem;
|
||||||
|
height: 3rem;
|
||||||
|
margin: .5rem;
|
||||||
|
|
||||||
|
> a
|
||||||
|
{
|
||||||
|
width: 3rem;
|
||||||
|
height: 3rem;
|
||||||
|
position: relative;
|
||||||
|
display: inline-block;
|
||||||
|
|
||||||
|
> img
|
||||||
|
{
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
right: 0;
|
||||||
|
left: 0;
|
||||||
|
margin: auto;
|
||||||
|
max-width: 3rem;
|
||||||
|
max-height: 3rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
184
front/src/app/pages/show-details/show-details.component.ts
Normal file
184
front/src/app/pages/show-details/show-details.component.ts
Normal file
@ -0,0 +1,184 @@
|
|||||||
|
import { AfterViewInit, Component, OnDestroy } from "@angular/core";
|
||||||
|
import { MatSnackBar } from "@angular/material/snack-bar";
|
||||||
|
import { DomSanitizer, SafeStyle, Title } from "@angular/platform-browser";
|
||||||
|
import { ActivatedRoute, Router } from "@angular/router";
|
||||||
|
import { Episode } from "../../models/resources/episode";
|
||||||
|
import { Show, ShowRole } from "../../models/resources/show";
|
||||||
|
import { MatDialog } from "@angular/material/dialog";
|
||||||
|
import { TrailerDialogComponent } from "../trailer-dialog/trailer-dialog.component";
|
||||||
|
import { MetadataEditComponent } from "../metadata-edit/metadata-edit.component";
|
||||||
|
import { Season } from "../../models/resources/season";
|
||||||
|
import { EpisodeService, PeopleService, SeasonService } from "../../services/api.service";
|
||||||
|
import { Page } from "../../models/page";
|
||||||
|
import { People } from "../../models/resources/people";
|
||||||
|
import { HttpClient } from "@angular/common/http";
|
||||||
|
import { LibraryItem } from "../../models/resources/library-item";
|
||||||
|
import { Collection } from "../../models/resources/collection";
|
||||||
|
import { ItemsUtils } from "../../misc/items-utils";
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: "app-show-details",
|
||||||
|
templateUrl: "./show-details.component.html",
|
||||||
|
styleUrls: ["./show-details.component.scss"]
|
||||||
|
})
|
||||||
|
export class ShowDetailsComponent implements AfterViewInit, OnDestroy
|
||||||
|
{
|
||||||
|
show: Show;
|
||||||
|
seasons: Season[];
|
||||||
|
season = 1;
|
||||||
|
episodes: Page<Episode>[] = [];
|
||||||
|
people: Page<People>;
|
||||||
|
|
||||||
|
private scrollZone: HTMLElement;
|
||||||
|
private toolbar: HTMLElement;
|
||||||
|
private backdrop: HTMLElement;
|
||||||
|
|
||||||
|
constructor(private route: ActivatedRoute,
|
||||||
|
private snackBar: MatSnackBar,
|
||||||
|
private sanitizer: DomSanitizer,
|
||||||
|
private title: Title,
|
||||||
|
private router: Router,
|
||||||
|
private dialog: MatDialog,
|
||||||
|
private http: HttpClient,
|
||||||
|
private seasonService: SeasonService,
|
||||||
|
private episodeService: EpisodeService,
|
||||||
|
private peopleService: PeopleService)
|
||||||
|
{
|
||||||
|
this.route.queryParams.subscribe(params =>
|
||||||
|
{
|
||||||
|
this.season = params.season ?? 1;
|
||||||
|
});
|
||||||
|
|
||||||
|
this.route.data.subscribe(data =>
|
||||||
|
{
|
||||||
|
this.show = data.show;
|
||||||
|
this.title.setTitle(this.show.title + " - Kyoo");
|
||||||
|
|
||||||
|
this.peopleService.getFromShow(this.show.slug).subscribe(x => this.people = x);
|
||||||
|
|
||||||
|
if (this.show.isMovie)
|
||||||
|
return;
|
||||||
|
|
||||||
|
this.seasons = this.show.seasons;
|
||||||
|
if (!this.seasons.find(y => y.seasonNumber === this.season))
|
||||||
|
{
|
||||||
|
this.season = 1;
|
||||||
|
this.getEpisodes(1);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
this.getEpisodes(this.season);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
ngAfterViewInit(): void
|
||||||
|
{
|
||||||
|
this.scrollZone = document.getElementById("main");
|
||||||
|
this.toolbar = document.getElementById("toolbar");
|
||||||
|
this.backdrop = document.getElementById("backdrop");
|
||||||
|
this.toolbar.setAttribute("style", `background-color: rgba(0, 0, 0, 0) !important`);
|
||||||
|
this.scrollZone.style.marginTop = "0";
|
||||||
|
this.scrollZone.style.maxHeight = "100vh";
|
||||||
|
this.scrollZone.addEventListener("scroll", () => this.scroll());
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnDestroy(): void
|
||||||
|
{
|
||||||
|
this.title.setTitle("Kyoo");
|
||||||
|
this.toolbar.setAttribute("style", `background-color: #000000 !important`);
|
||||||
|
this.scrollZone.style.marginTop = null;
|
||||||
|
this.scrollZone.style.maxHeight = null;
|
||||||
|
this.scrollZone.removeEventListener("scroll", () => this.scroll());
|
||||||
|
}
|
||||||
|
|
||||||
|
scroll(): void
|
||||||
|
{
|
||||||
|
const opacity: number = 2 * this.scrollZone.scrollTop / this.backdrop.clientHeight;
|
||||||
|
this.toolbar.setAttribute("style", `background-color: rgba(0, 0, 0, ${opacity}) !important`);
|
||||||
|
}
|
||||||
|
|
||||||
|
getThumb(item: Show): SafeStyle
|
||||||
|
{
|
||||||
|
return this.sanitizer.bypassSecurityTrustStyle(`url(${item.poster})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
playClicked(): void
|
||||||
|
{
|
||||||
|
if (this.show.isMovie) {
|
||||||
|
this.router.navigate(["/watch/" + this.show.slug]);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
this.router.navigate(["/watch/" + this.show.slug + "-s1e1"]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getEpisodes(season: number): void
|
||||||
|
{
|
||||||
|
if (season < 0 || this.episodes[season])
|
||||||
|
return;
|
||||||
|
|
||||||
|
this.episodeService.getFromSeasonNumber(this.show.slug, this.season).subscribe(x =>
|
||||||
|
{
|
||||||
|
this.episodes[season] = x;
|
||||||
|
});
|
||||||
|
this.router.navigate([], {
|
||||||
|
relativeTo: this.route,
|
||||||
|
queryParams: {season},
|
||||||
|
replaceUrl: true,
|
||||||
|
queryParamsHandling: "merge",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
openTrailer(): void
|
||||||
|
{
|
||||||
|
this.dialog.open(TrailerDialogComponent, {
|
||||||
|
width: "80%",
|
||||||
|
height: "45vw",
|
||||||
|
data: this.show.trailer,
|
||||||
|
panelClass: "panel"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
editMetadata(): void
|
||||||
|
{
|
||||||
|
this.dialog.open(MetadataEditComponent, {width: "80%", data: this.show})
|
||||||
|
.afterClosed().subscribe((result: Show) =>
|
||||||
|
{
|
||||||
|
if (result) {
|
||||||
|
this.show = result;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
redownloadImages(): void
|
||||||
|
{
|
||||||
|
this.http.put(`api/task/extract/show/${this.show.slug}/thumbnails`, undefined)
|
||||||
|
.subscribe(() => { }, error =>
|
||||||
|
{
|
||||||
|
console.log(error.status + " - " + error.message);
|
||||||
|
this.snackBar.open("An unknown error occurred while re-downloading images.", null, {
|
||||||
|
horizontalPosition: "left",
|
||||||
|
panelClass: ["snackError"],
|
||||||
|
duration: 2500
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
extractSubs(): void
|
||||||
|
{
|
||||||
|
this.http.put(`api/task/extract/show/${this.show.slug}/subs`, undefined)
|
||||||
|
.subscribe(() => { }, error =>
|
||||||
|
{
|
||||||
|
console.log(error.status + " - " + error.message);
|
||||||
|
this.snackBar.open("An unknown error occurred while re-downloading images.", null, {
|
||||||
|
horizontalPosition: "left",
|
||||||
|
panelClass: ["snackError"],
|
||||||
|
duration: 2500
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
getDate(item: LibraryItem | Show | ShowRole | Collection): string
|
||||||
|
{
|
||||||
|
return ItemsUtils.getDate(item);
|
||||||
|
}
|
||||||
|
}
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user