WebApp: Merging the webapp repository with the main repository

This commit is contained in:
Zoe Roux 2021-11-19 17:46:47 +01:00
commit e15c1e9eca
No known key found for this signature in database
GPG Key ID: 8BB9CF5EF72AE933
176 changed files with 15849 additions and 0 deletions

14
front/.editorconfig Normal file
View 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
View 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
View File

@ -0,0 +1,2 @@
# Kyoo.WebApp
The Angular web app for Kyoo.

113
front/angular.json Normal file
View 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
View 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"
}
}

View 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 { }

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

View 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);
}
}
}

View 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
View 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 { }

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

View 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%;
}
}

View 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);
}
}

View 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 { }

View 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));
}
}

View 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();
});
}
}

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

View 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 {}

View 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);
});
}
}

View 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;
}
}

View 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);
}
}

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

View 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;
}
}

View File

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

View File

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

View File

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

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

View 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;
}
}
}

View 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"
});
}
}

View File

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

View 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;
}
}

View 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);
}
}

View File

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

View 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;
}
}

View File

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

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

View 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;
}
}
}

View 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}`;
}
}

View 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;
}
}

View 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);
}
}

View 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");
}
}

View 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();
}
}

View 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();
}
}

View 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;
}
}

View File

@ -0,0 +1,7 @@
export interface Account
{
username: string;
email: string;
picture: string;
permissions: string[];
}

View File

@ -0,0 +1,8 @@
import { Provider } from "./provider";
export interface ExternalID
{
provider: Provider;
dataID: string;
link: string;
}

View 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`);
}
}

View File

@ -0,0 +1,5 @@
export interface Provider
{
name: string;
logo: string;
}

View File

@ -0,0 +1,3 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
//# sourceMappingURL=collection.js.map

View File

@ -0,0 +1 @@
{"version":3,"file":"collection.js","sourceRoot":"","sources":["collection.ts"],"names":[],"mappings":""}

View 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[];
}

View File

@ -0,0 +1,3 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
//# sourceMappingURL=episode.js.map

View File

@ -0,0 +1 @@
{"version":3,"file":"episode.js","sourceRoot":"","sources":["episode.ts"],"names":[],"mappings":""}

View 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[];
}

View File

@ -0,0 +1,3 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
//# sourceMappingURL=genre.js.map

View File

@ -0,0 +1 @@
{"version":3,"file":"genre.js","sourceRoot":"","sources":["genre.ts"],"names":[],"mappings":""}

View File

@ -0,0 +1,6 @@
import { IResource } from "./resource";
export interface Genre extends IResource
{
name: string;
}

View 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;
}

View File

@ -0,0 +1,8 @@
import { IResource } from "./resource";
export interface Library extends IResource
{
id: number;
slug: string;
name: string;
}

View File

@ -0,0 +1,3 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
//# sourceMappingURL=people.js.map

View File

@ -0,0 +1 @@
{"version":3,"file":"people.js","sourceRoot":"","sources":["people.ts"],"names":[],"mappings":""}

View 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[];
}

View File

@ -0,0 +1,5 @@
export interface IResource
{
id: number;
slug: string;
}

View File

@ -0,0 +1,3 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
//# sourceMappingURL=season.js.map

View File

@ -0,0 +1 @@
{"version":3,"file":"season.js","sourceRoot":"","sources":["season.ts"],"names":[],"mappings":""}

View 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[];
}

View File

@ -0,0 +1,3 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
//# sourceMappingURL=show.js.map

View File

@ -0,0 +1 @@
{"version":3,"file":"show.js","sourceRoot":"","sources":["show.ts"],"names":[],"mappings":""}

View 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;
}

View File

@ -0,0 +1,3 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
//# sourceMappingURL=studio.js.map

View File

@ -0,0 +1 @@
{"version":3,"file":"studio.js","sourceRoot":"","sources":["studio.ts"],"names":[],"mappings":""}

View File

@ -0,0 +1,6 @@
import { IResource } from "./resource";
export interface Studio extends IResource
{
name: string;
}

View File

@ -0,0 +1,3 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
//# sourceMappingURL=search-result.js.map

View File

@ -0,0 +1 @@
{"version":3,"file":"search-result.js","sourceRoot":"","sources":["search-result.ts"],"names":[],"mappings":""}

View 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[];
}

View File

@ -0,0 +1,3 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
//# sourceMappingURL=watch-item.js.map

View File

@ -0,0 +1 @@
{"version":3,"file":"watch-item.js","sourceRoot":"","sources":["watch-item.ts"],"names":[],"mappings":""}

View 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;
}

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

View 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;
}

View 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);
}
}

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

View File

@ -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%;
}
}

View 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);
}
}

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

View 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() { }
}

View 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;
}
}

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

View 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;
}
}
}

View 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;
}
}
}

View 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;
}

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

View 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});
}
}

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

View 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;
}
}
}

View 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