mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-16 22:26:16 -08:00
refactor: update nx monorepo structure
This commit is contained in:
1 parent
aa739f43e0
commit
7ac2fd66b8
175 files changed
+3118
-1076
No files matched your search
@@ -0,0 +1,10 @@
|
||||
export * from './lib/art-player/art-player.component';
|
||||
export * from './lib/audio-player/audio-player.component';
|
||||
export * from './lib/external-player-info-dialog/external-player-info-dialog.component';
|
||||
export * from './lib/html-video-player/html-video-player.component';
|
||||
export * from './lib/portal-inline-player/portal-inline-player.component';
|
||||
export * from './lib/vjs-player/vjs-player.component';
|
||||
export * from './lib/video-player/sidebar/sidebar.component';
|
||||
export * from './lib/video-player/toolbar/toolbar.component';
|
||||
export * from './lib/vod-details/vod-details.component';
|
||||
export * from './lib/web-player-view/web-player-view.component';
|
||||
@@ -0,0 +1,235 @@
|
||||
import {
|
||||
Component,
|
||||
ElementRef,
|
||||
EventEmitter,
|
||||
inject,
|
||||
Input,
|
||||
OnChanges,
|
||||
OnDestroy,
|
||||
OnInit,
|
||||
Output,
|
||||
SimpleChanges,
|
||||
} from '@angular/core';
|
||||
import Artplayer from 'artplayer';
|
||||
import Hls from 'hls.js';
|
||||
import { getExtensionFromUrl } from 'm3u-utils';
|
||||
import mpegts from 'mpegts.js';
|
||||
import { Channel } from 'shared-interfaces';
|
||||
|
||||
type AudioTrackSelector = {
|
||||
html: string | HTMLElement;
|
||||
default?: boolean;
|
||||
};
|
||||
|
||||
Artplayer.AUTO_PLAYBACK_TIMEOUT = 10000;
|
||||
|
||||
@Component({
|
||||
selector: 'app-art-player',
|
||||
imports: [],
|
||||
template: `<div #artplayer class="artplayer-container"></div>`,
|
||||
styles: [
|
||||
`
|
||||
:host {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
.artplayer-container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
`,
|
||||
],
|
||||
})
|
||||
export class ArtPlayerComponent implements OnInit, OnDestroy, OnChanges {
|
||||
@Input() channel!: Channel;
|
||||
@Input() volume = 1;
|
||||
@Input() showCaptions = false;
|
||||
@Input() startTime = 0;
|
||||
@Output() timeUpdate = new EventEmitter<{
|
||||
currentTime: number;
|
||||
duration: number;
|
||||
}>();
|
||||
|
||||
private player!: Artplayer;
|
||||
private hls: Hls | null = null;
|
||||
private mpegtsPlayer: mpegts.Player | null = null;
|
||||
|
||||
private readonly elementRef = inject(ElementRef);
|
||||
|
||||
ngOnInit(): void {
|
||||
this.initPlayer();
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.destroyPlayer();
|
||||
}
|
||||
|
||||
ngOnChanges(changes: SimpleChanges): void {
|
||||
if (changes['channel'] && !changes['channel'].firstChange) {
|
||||
this.destroyPlayer();
|
||||
this.initPlayer();
|
||||
}
|
||||
}
|
||||
|
||||
private destroyPlayer(): void {
|
||||
if (this.mpegtsPlayer) {
|
||||
this.mpegtsPlayer.pause();
|
||||
this.mpegtsPlayer.unload();
|
||||
this.mpegtsPlayer.detachMediaElement();
|
||||
this.mpegtsPlayer.destroy();
|
||||
this.mpegtsPlayer = null;
|
||||
}
|
||||
if (this.hls) {
|
||||
this.hls.destroy();
|
||||
this.hls = null;
|
||||
}
|
||||
if (this.player) {
|
||||
this.player.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
private initPlayer(): void {
|
||||
const el = this.elementRef.nativeElement.querySelector(
|
||||
'.artplayer-container'
|
||||
);
|
||||
const extension = getExtensionFromUrl(this.channel?.url ?? '');
|
||||
const isLive = extension === 'm3u8' || extension === 'ts';
|
||||
|
||||
this.player = new Artplayer({
|
||||
container: el,
|
||||
url: this.channel.url + (this.channel.epgParams || ''),
|
||||
volume: this.volume,
|
||||
isLive: isLive,
|
||||
autoplay: true,
|
||||
type: this.getVideoType(this.channel.url),
|
||||
pip: true,
|
||||
autoPlayback: true,
|
||||
autoSize: true,
|
||||
autoMini: true,
|
||||
screenshot: true,
|
||||
setting: true,
|
||||
playbackRate: true,
|
||||
aspectRatio: true,
|
||||
fullscreen: true,
|
||||
fullscreenWeb: true,
|
||||
playsInline: true,
|
||||
airplay: true,
|
||||
backdrop: true,
|
||||
mutex: true,
|
||||
theme: '#ff0000',
|
||||
customType: {
|
||||
m3u8: (video: HTMLVideoElement, url: string) => {
|
||||
if (Hls.isSupported()) {
|
||||
if (this.hls) {
|
||||
this.hls.destroy();
|
||||
}
|
||||
this.hls = new Hls();
|
||||
this.hls.loadSource(url);
|
||||
this.hls.attachMedia(video);
|
||||
this.setupHlsAudioTracks();
|
||||
} else if (
|
||||
video.canPlayType('application/vnd.apple.mpegurl')
|
||||
) {
|
||||
video.src = url;
|
||||
}
|
||||
},
|
||||
ts: (video: HTMLVideoElement, url: string) => {
|
||||
if (mpegts.isSupported()) {
|
||||
if (this.mpegtsPlayer) {
|
||||
this.mpegtsPlayer.destroy();
|
||||
}
|
||||
this.mpegtsPlayer = mpegts.createPlayer({
|
||||
type: 'mpegts',
|
||||
isLive: true,
|
||||
url: url,
|
||||
});
|
||||
this.mpegtsPlayer.attachMediaElement(video);
|
||||
this.mpegtsPlayer.load();
|
||||
this.mpegtsPlayer.play();
|
||||
}
|
||||
},
|
||||
mkv: function (video: HTMLVideoElement, url: string) {
|
||||
video.src = url;
|
||||
video.onerror = () => {
|
||||
console.error('Error loading MKV file:', video.error);
|
||||
video.src = url;
|
||||
};
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (this.startTime > 0) {
|
||||
this.player.on('ready', () => {
|
||||
this.player.seek = this.startTime;
|
||||
});
|
||||
}
|
||||
|
||||
this.player.on('video:timeupdate', () => {
|
||||
this.timeUpdate.emit({
|
||||
currentTime: this.player.currentTime,
|
||||
duration: this.player.duration,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Listens for HLS.js audio tracks and adds a settings menu entry
|
||||
* to ArtPlayer for switching between available audio tracks.
|
||||
*/
|
||||
private setupHlsAudioTracks(): void {
|
||||
if (!this.hls) return;
|
||||
|
||||
const hls = this.hls;
|
||||
|
||||
hls.on(Hls.Events.AUDIO_TRACKS_UPDATED, () => {
|
||||
const tracks = hls.audioTracks;
|
||||
if (!tracks || tracks.length <= 1) return;
|
||||
|
||||
const audioTrackSetting = {
|
||||
html: 'Audio',
|
||||
icon: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="22" height="22" fill="white">
|
||||
<path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02zM14 3.23v2.06c2.89.86 5 3.54 5 6.71s-2.11 5.85-5 6.71v2.06c4.01-.91 7-4.49 7-8.77s-2.99-7.86-7-8.77z"/>
|
||||
</svg>`,
|
||||
width: 220,
|
||||
tooltip: tracks[hls.audioTrack]?.name || '',
|
||||
selector: tracks.map((track, index) => ({
|
||||
html: track.name || track.lang || `Track ${index + 1}`,
|
||||
default: index === hls.audioTrack,
|
||||
})),
|
||||
onSelect: function (this: Artplayer, item: AudioTrackSelector) {
|
||||
const selectedLabel =
|
||||
typeof item.html === 'string'
|
||||
? item.html
|
||||
: item.html.textContent ?? '';
|
||||
const selectedIndex = tracks.findIndex(
|
||||
(t, i) =>
|
||||
(t.name || t.lang || `Track ${i + 1}`) ===
|
||||
selectedLabel
|
||||
);
|
||||
if (selectedIndex >= 0) {
|
||||
hls.audioTrack = selectedIndex;
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
this.player.setting.add(audioTrackSetting);
|
||||
});
|
||||
}
|
||||
|
||||
private getVideoType(url: string): string {
|
||||
const extension = getExtensionFromUrl(url);
|
||||
switch (extension) {
|
||||
case 'mkv':
|
||||
return 'video/matroska';
|
||||
case 'm3u8':
|
||||
return 'm3u8';
|
||||
case 'mp4':
|
||||
return 'mp4';
|
||||
case 'ts':
|
||||
return 'ts';
|
||||
default:
|
||||
return 'auto';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
:host {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin: 20px 0;
|
||||
}
|
||||
|
||||
#audio-player {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.radio-logo {
|
||||
border-radius: 14px;
|
||||
height: 300px;
|
||||
width: 300px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
img {
|
||||
max-width: 300px;
|
||||
max-height: 300px;
|
||||
}
|
||||
}
|
||||
|
||||
.volume-panel {
|
||||
display: flex;
|
||||
align-self: center;
|
||||
align-items: center;
|
||||
|
||||
input {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.controls {
|
||||
height: 100px;
|
||||
align-items: center;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.playing {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 0.3rem;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-end;
|
||||
padding: 0.5rem;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.playing-bar {
|
||||
display: inline-block;
|
||||
background: white;
|
||||
width: 30%;
|
||||
height: 100%;
|
||||
animation: up-and-down 1.3s ease infinite alternate;
|
||||
}
|
||||
|
||||
.playing-bar-stopped {
|
||||
display: inline-block;
|
||||
background: white;
|
||||
width: 30%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.playing-bar1 {
|
||||
height: 60%;
|
||||
}
|
||||
|
||||
.playing-bar2 {
|
||||
height: 30%;
|
||||
animation-delay: -2.2s;
|
||||
}
|
||||
|
||||
.playing-bar3 {
|
||||
height: 75%;
|
||||
animation-delay: -3.7s;
|
||||
}
|
||||
|
||||
@keyframes up-and-down {
|
||||
10% {
|
||||
height: 30%;
|
||||
}
|
||||
|
||||
30% {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
60% {
|
||||
height: 50%;
|
||||
}
|
||||
|
||||
80% {
|
||||
height: 75%;
|
||||
}
|
||||
|
||||
100% {
|
||||
height: 60%;
|
||||
}
|
||||
}
|
||||
|
||||
.icon-button-large {
|
||||
transform: scale(2);
|
||||
margin: 0 20px;
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 599px) {
|
||||
.radio-logo {
|
||||
height: 200px;
|
||||
width: 200px;
|
||||
|
||||
img {
|
||||
max-width: 200px;
|
||||
max-height: 200px;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
import {
|
||||
Component,
|
||||
ElementRef,
|
||||
inject,
|
||||
Input,
|
||||
OnChanges,
|
||||
SimpleChanges,
|
||||
ViewChild,
|
||||
} from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatSliderModule } from '@angular/material/slider';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { ChannelActions } from 'm3u-state';
|
||||
|
||||
@Component({
|
||||
selector: 'app-audio-player',
|
||||
template: `
|
||||
<div id="audio-player">
|
||||
<div class="radio-logo">
|
||||
<img [src]="icon" alt="radio icon" />
|
||||
</div>
|
||||
<audio preload="metadata" autoplay #audio>
|
||||
<source [src]="url" />
|
||||
</audio>
|
||||
<div class="controls">
|
||||
<button mat-icon-button (click)="switchChannel('previous')">
|
||||
<mat-icon>skip_previous</mat-icon>
|
||||
</button>
|
||||
|
||||
@if (playState === 'paused') {
|
||||
<button
|
||||
class="icon-button-large"
|
||||
mat-icon-button
|
||||
(click)="play()"
|
||||
>
|
||||
<mat-icon>play_arrow</mat-icon>
|
||||
</button>
|
||||
}
|
||||
@if (playState === 'play') {
|
||||
<button
|
||||
class="icon-button-large"
|
||||
mat-icon-button
|
||||
(click)="stop()"
|
||||
>
|
||||
<mat-icon>pause</mat-icon>
|
||||
</button>
|
||||
}
|
||||
<button mat-icon-button (click)="switchChannel('next')">
|
||||
<mat-icon>skip_next</mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
<div class="volume-panel">
|
||||
@if (playState === 'play') {
|
||||
<div class="playing">
|
||||
<span class="playing-bar playing-bar1"></span>
|
||||
<span class="playing-bar playing-bar2"></span>
|
||||
<span class="playing-bar playing-bar3"></span>
|
||||
</div>
|
||||
}
|
||||
@if (playState === 'paused') {
|
||||
<div class="playing">
|
||||
<span class="playing-bar-stopped playing-bar1"></span>
|
||||
<span class="playing-bar-stopped playing-bar2"></span>
|
||||
<span class="playing-bar-stopped playing-bar3"></span>
|
||||
</div>
|
||||
}
|
||||
<mat-slider min="0" max="1" step="0.1" color="accent">
|
||||
<input matSliderThumb [(ngModel)]="audio.volume" />
|
||||
</mat-slider>
|
||||
<button mat-icon-button (click)="mute()">
|
||||
@if (audio.volume > 0 && !audio.muted) {
|
||||
<mat-icon>volume_up</mat-icon>
|
||||
}
|
||||
@if (audio.volume === 0 || audio.muted) {
|
||||
<mat-icon>volume_off</mat-icon>
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
styleUrls: ['./audio-player.component.scss'],
|
||||
imports: [MatSliderModule, MatIconModule, MatButtonModule, FormsModule],
|
||||
})
|
||||
export class AudioPlayerComponent implements OnChanges {
|
||||
@Input() icon!: string;
|
||||
@Input({ required: true }) url!: string;
|
||||
|
||||
playState: 'play' | 'paused' = 'paused';
|
||||
|
||||
fallbackVolume!: number;
|
||||
|
||||
@ViewChild('audio', { static: true }) audio!: ElementRef<HTMLAudioElement>;
|
||||
|
||||
private store = inject(Store);
|
||||
|
||||
ngOnChanges(changes: SimpleChanges): void {
|
||||
this.audio.nativeElement.src = changes['url'].currentValue;
|
||||
this.audio.nativeElement.load();
|
||||
this.play();
|
||||
}
|
||||
|
||||
play() {
|
||||
const playPromise = this.audio.nativeElement.play();
|
||||
if (playPromise !== undefined) {
|
||||
playPromise.catch((error) => {
|
||||
console.log(error);
|
||||
});
|
||||
}
|
||||
this.playState = 'play';
|
||||
}
|
||||
|
||||
stop() {
|
||||
this.audio.nativeElement.pause();
|
||||
this.playState = 'paused';
|
||||
}
|
||||
|
||||
mute() {
|
||||
this.audio.nativeElement.muted = !this.audio.nativeElement.muted;
|
||||
if (this.audio.nativeElement.muted) {
|
||||
this.fallbackVolume = this.audio.nativeElement.volume;
|
||||
this.audio.nativeElement.volume = 0;
|
||||
} else this.audio.nativeElement.volume = this.fallbackVolume;
|
||||
}
|
||||
|
||||
switchChannel(direction: 'next' | 'previous') {
|
||||
this.store.dispatch(ChannelActions.setAdjacentChannelAsActive({ direction }));
|
||||
}
|
||||
}
|
||||
+77
@@ -0,0 +1,77 @@
|
||||
import { Component } from '@angular/core';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatCheckboxModule } from '@angular/material/checkbox';
|
||||
import { MatDialogModule } from '@angular/material/dialog';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { RouterLink } from '@angular/router';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
|
||||
@Component({
|
||||
template: `
|
||||
<h2 mat-dialog-title>{{ 'INFORMATION' | translate }}</h2>
|
||||
<mat-dialog-content class="mat-typography">
|
||||
<div class="centered">
|
||||
<mat-icon class="icon">live_tv</mat-icon>
|
||||
<div>
|
||||
The video is playing in external player window.<br />
|
||||
Please make sure that mpv/vlc player is correctly installed
|
||||
on your system.<br />
|
||||
See
|
||||
<a
|
||||
[routerLink]
|
||||
style="cursor: pointer"
|
||||
(click)="
|
||||
openUrl(
|
||||
'https://github.com/4gray/iptvnator/wiki/What-is-mpv-video-player-and-how-to-install-it-on-different-operating-systems%3F'
|
||||
)
|
||||
"
|
||||
>installation instructions</a
|
||||
>
|
||||
for more details.
|
||||
</div>
|
||||
</div>
|
||||
</mat-dialog-content>
|
||||
<mat-dialog-actions style="justify-content: space-between;">
|
||||
<div>
|
||||
<mat-checkbox (change)="setVisibility($event.checked)"
|
||||
>Don't show anymore</mat-checkbox
|
||||
>
|
||||
</div>
|
||||
<button mat-button mat-dialog-close cdkFocusInitial color="accent">
|
||||
{{ 'CLOSE' | translate }}
|
||||
</button>
|
||||
</mat-dialog-actions>
|
||||
`,
|
||||
styles: [
|
||||
`
|
||||
.centered {
|
||||
text-align: center;
|
||||
line-height: 24px;
|
||||
margin: 20px;
|
||||
|
||||
.icon {
|
||||
font-size: 64px;
|
||||
height: 64px;
|
||||
width: 64px;
|
||||
}
|
||||
}
|
||||
`,
|
||||
],
|
||||
imports: [
|
||||
MatButtonModule,
|
||||
MatCheckboxModule,
|
||||
MatDialogModule,
|
||||
MatIconModule,
|
||||
RouterLink,
|
||||
TranslateModule,
|
||||
],
|
||||
})
|
||||
export class ExternalPlayerInfoDialogComponent {
|
||||
openUrl(url: string) {
|
||||
window.open(url, '_blank');
|
||||
}
|
||||
|
||||
setVisibility(value: boolean) {
|
||||
localStorage.setItem('hideExternalInfoDialog', value.toString());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
<video #videoPlayer id="video-player" autoplay="true" controls="true"></video>
|
||||
@@ -0,0 +1,12 @@
|
||||
:host {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#video-player {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { SimpleChange } from '@angular/core';
|
||||
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import { DataService } from 'services';
|
||||
import { HtmlVideoPlayerComponent } from './html-video-player.component';
|
||||
|
||||
describe('HtmlVideoPlayerComponent', () => {
|
||||
let component: HtmlVideoPlayerComponent;
|
||||
let fixture: ComponentFixture<HtmlVideoPlayerComponent>;
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
let dataService: DataService;
|
||||
|
||||
const TEST_CHANNEL = {
|
||||
id: '1234',
|
||||
url: 'http://test.ts',
|
||||
name: 'Test channel',
|
||||
group: {
|
||||
title: 'News group',
|
||||
},
|
||||
http: {
|
||||
'user-agent': 'localhost',
|
||||
},
|
||||
};
|
||||
|
||||
beforeEach(waitForAsync(() => {
|
||||
const dataServiceMock = {
|
||||
sendIpcEvent: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
imports: [HtmlVideoPlayerComponent, TranslateModule.forRoot()],
|
||||
providers: [{ provide: DataService, useValue: dataServiceMock }],
|
||||
}).compileComponents();
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
fixture = TestBed.createComponent(HtmlVideoPlayerComponent);
|
||||
component = fixture.componentInstance;
|
||||
dataService = TestBed.inject(DataService);
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should call play channel function after input changes', () => {
|
||||
jest.spyOn(component, 'playChannel');
|
||||
jest.spyOn(global.console, 'error').mockImplementation(() => {
|
||||
/* empty */
|
||||
});
|
||||
component.ngOnChanges({
|
||||
channel: new SimpleChange(null, TEST_CHANNEL, true),
|
||||
});
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.playChannel).toHaveBeenCalledWith(TEST_CHANNEL);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,218 @@
|
||||
import {
|
||||
Component,
|
||||
ElementRef,
|
||||
EventEmitter,
|
||||
inject,
|
||||
Input,
|
||||
OnChanges,
|
||||
OnDestroy,
|
||||
OnInit,
|
||||
Output,
|
||||
SimpleChanges,
|
||||
ViewChild,
|
||||
} from '@angular/core';
|
||||
import Hls from 'hls.js';
|
||||
import mpegts from 'mpegts.js';
|
||||
import { getExtensionFromUrl } from 'm3u-utils';
|
||||
import { DataService } from 'services';
|
||||
import { Channel } from 'shared-interfaces';
|
||||
|
||||
/**
|
||||
* This component contains the implementation of HTML5 based video player
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-html-video-player',
|
||||
templateUrl: './html-video-player.component.html',
|
||||
styleUrls: ['./html-video-player.component.scss'],
|
||||
standalone: true,
|
||||
})
|
||||
export class HtmlVideoPlayerComponent implements OnInit, OnChanges, OnDestroy {
|
||||
/** Channel to play */
|
||||
@Input() channel!: Channel;
|
||||
@Input() volume = 1;
|
||||
@Input() startTime = 0;
|
||||
@Output() timeUpdate = new EventEmitter<{
|
||||
currentTime: number;
|
||||
duration: number;
|
||||
}>();
|
||||
|
||||
private readonly dataService = inject(DataService);
|
||||
|
||||
/** Video player DOM element */
|
||||
@ViewChild('videoPlayer', { static: true })
|
||||
videoPlayer!: ElementRef<HTMLVideoElement>;
|
||||
|
||||
/** HLS object */
|
||||
hls!: Hls;
|
||||
/** mpegts.js player for raw MPEG-TS streams */
|
||||
private mpegtsPlayer: mpegts.Player | null = null;
|
||||
|
||||
/** Captions/subtitles indicator */
|
||||
@Input() showCaptions!: boolean;
|
||||
|
||||
ngOnInit() {
|
||||
this.videoPlayer.nativeElement.addEventListener('volumechange', () => {
|
||||
this.onVolumeChange();
|
||||
});
|
||||
|
||||
this.videoPlayer.nativeElement.addEventListener('loadedmetadata', () => {
|
||||
if (this.startTime > 0) {
|
||||
this.videoPlayer.nativeElement.currentTime = this.startTime;
|
||||
}
|
||||
});
|
||||
|
||||
this.videoPlayer.nativeElement.addEventListener('timeupdate', () => {
|
||||
this.timeUpdate.emit({
|
||||
currentTime: this.videoPlayer.nativeElement.currentTime,
|
||||
duration: this.videoPlayer.nativeElement.duration,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Listen for component input changes
|
||||
* @param changes component changes
|
||||
*/
|
||||
ngOnChanges(changes: SimpleChanges): void {
|
||||
if (changes['channel'] && changes['channel'].currentValue) {
|
||||
this.playChannel(changes['channel'].currentValue);
|
||||
}
|
||||
if (changes['volume']?.currentValue !== undefined) {
|
||||
console.log(
|
||||
'Setting HTML5 player volume to:',
|
||||
changes['volume'].currentValue
|
||||
);
|
||||
this.videoPlayer.nativeElement.volume =
|
||||
changes['volume'].currentValue;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts to play the given channel
|
||||
* @param channel given channel object
|
||||
*/
|
||||
playChannel(channel: Channel): void {
|
||||
if (this.mpegtsPlayer) {
|
||||
this.mpegtsPlayer.pause();
|
||||
this.mpegtsPlayer.unload();
|
||||
this.mpegtsPlayer.detachMediaElement();
|
||||
this.mpegtsPlayer.destroy();
|
||||
this.mpegtsPlayer = null;
|
||||
}
|
||||
if (this.hls) this.hls.destroy();
|
||||
if (channel.url) {
|
||||
const url = channel.url + (channel.epgParams ?? '');
|
||||
const extension = getExtensionFromUrl(channel.url);
|
||||
|
||||
// Set user agent if specified on channel
|
||||
if (channel.http?.['user-agent']) {
|
||||
window.electron?.setUserAgent(
|
||||
channel.http['user-agent'],
|
||||
channel.http.referrer
|
||||
);
|
||||
}
|
||||
|
||||
if (extension === 'ts' && mpegts.isSupported()) {
|
||||
console.log('Using mpegts.js for TS stream:', channel.name, url);
|
||||
this.mpegtsPlayer = mpegts.createPlayer({
|
||||
type: 'mpegts',
|
||||
isLive: true,
|
||||
url: url,
|
||||
});
|
||||
this.mpegtsPlayer.attachMediaElement(
|
||||
this.videoPlayer.nativeElement
|
||||
);
|
||||
this.mpegtsPlayer.load();
|
||||
this.handlePlayOperation();
|
||||
} else if (
|
||||
extension !== 'mp4' &&
|
||||
extension !== 'mpv' &&
|
||||
Hls &&
|
||||
Hls.isSupported()
|
||||
) {
|
||||
console.log('... switching channel to ', channel.name, url);
|
||||
this.hls = new Hls();
|
||||
this.hls.attachMedia(this.videoPlayer.nativeElement);
|
||||
this.hls.loadSource(url);
|
||||
this.handlePlayOperation();
|
||||
} else {
|
||||
console.log('Using native video player...');
|
||||
this.addSourceToVideo(
|
||||
this.videoPlayer.nativeElement,
|
||||
url,
|
||||
'video/mp4'
|
||||
);
|
||||
this.videoPlayer.nativeElement.play();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
addSourceToVideo(element: HTMLVideoElement, url: string, type: string) {
|
||||
const source = document.createElement('source');
|
||||
source.src = url;
|
||||
source.type = type;
|
||||
element.appendChild(source);
|
||||
}
|
||||
|
||||
/**
|
||||
* Disables text based captions based on the global settings
|
||||
*/
|
||||
disableCaptions(): void {
|
||||
for (
|
||||
let i = 0;
|
||||
i < this.videoPlayer.nativeElement.textTracks.length;
|
||||
i++
|
||||
) {
|
||||
this.videoPlayer.nativeElement.textTracks[i].mode = 'hidden';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles promise based play operation
|
||||
*/
|
||||
handlePlayOperation(): void {
|
||||
const playPromise = this.videoPlayer.nativeElement.play();
|
||||
|
||||
if (playPromise !== undefined) {
|
||||
playPromise
|
||||
.then(() => {
|
||||
// Automatic playback started!
|
||||
if (!this.showCaptions) {
|
||||
this.disableCaptions();
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
// Do nothing
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save volume when user changes it
|
||||
*/
|
||||
onVolumeChange(): void {
|
||||
const currentVolume = this.videoPlayer.nativeElement.volume;
|
||||
console.log('Volume changed to:', currentVolume);
|
||||
localStorage.setItem('volume', currentVolume.toString());
|
||||
}
|
||||
|
||||
/**
|
||||
* Destroy hls instance on component destroy and clean up event listener
|
||||
*/
|
||||
ngOnDestroy(): void {
|
||||
this.videoPlayer.nativeElement.removeEventListener(
|
||||
'volumechange',
|
||||
this.onVolumeChange
|
||||
);
|
||||
if (this.mpegtsPlayer) {
|
||||
this.mpegtsPlayer.pause();
|
||||
this.mpegtsPlayer.unload();
|
||||
this.mpegtsPlayer.detachMediaElement();
|
||||
this.mpegtsPlayer.destroy();
|
||||
this.mpegtsPlayer = null;
|
||||
}
|
||||
if (this.hls) {
|
||||
this.hls.destroy();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
@if (hasPlayback()) {
|
||||
<section class="player-shell" aria-label="Inline player">
|
||||
<header class="player-shell__header">
|
||||
<div class="player-shell__eyebrow">Now Playing</div>
|
||||
<div class="player-shell__title-row">
|
||||
<h3 class="player-shell__title" [matTooltip]="title()">
|
||||
{{ title() }}
|
||||
</h3>
|
||||
<div class="player-shell__actions">
|
||||
<button
|
||||
mat-button
|
||||
type="button"
|
||||
[cdkCopyToClipboard]="streamUrl()"
|
||||
[matTooltip]="streamUrl()"
|
||||
(click)="onCopied()"
|
||||
>
|
||||
<mat-icon>content_copy</mat-icon>
|
||||
Copy Stream URL
|
||||
</button>
|
||||
<button
|
||||
mat-stroked-button
|
||||
type="button"
|
||||
(click)="onClose()"
|
||||
>
|
||||
<mat-icon>close</mat-icon>
|
||||
Close Player
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="player-shell__viewport">
|
||||
<app-web-player-view
|
||||
[streamUrl]="streamUrl()"
|
||||
[startTime]="startTime()"
|
||||
(timeUpdate)="onTimeUpdate($event)"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
:host {
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.player-shell {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
padding: 18px;
|
||||
margin-top: 8px;
|
||||
border-radius: 18px;
|
||||
border: 1px solid rgba(20, 20, 20, 0.08);
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.78), rgba(255, 250, 245, 0.92)),
|
||||
rgba(255, 250, 245, 0.9);
|
||||
box-shadow:
|
||||
0 20px 56px rgba(25, 20, 20, 0.12),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.8);
|
||||
backdrop-filter: blur(18px);
|
||||
}
|
||||
|
||||
.player-shell__header {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.player-shell__eyebrow {
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.18em;
|
||||
text-transform: uppercase;
|
||||
color: rgba(26, 26, 26, 0.58);
|
||||
}
|
||||
|
||||
.player-shell__title-row {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.player-shell__title {
|
||||
margin: 0;
|
||||
font-size: clamp(1.05rem, 1vw + 0.9rem, 1.4rem);
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
color: #161616;
|
||||
}
|
||||
|
||||
.player-shell__actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.player-shell__viewport {
|
||||
overflow: hidden;
|
||||
min-height: 320px;
|
||||
max-height: min(70vh, 720px);
|
||||
aspect-ratio: 16 / 9;
|
||||
border-radius: 14px;
|
||||
background: #000;
|
||||
}
|
||||
|
||||
.player-shell__viewport > app-web-player-view {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
:host-context(.dark-theme) .player-shell {
|
||||
border-color: rgba(255, 255, 255, 0.12);
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.08), transparent),
|
||||
rgba(12, 12, 12, 0.72);
|
||||
box-shadow:
|
||||
0 24px 80px rgba(0, 0, 0, 0.28),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
:host-context(.dark-theme) .player-shell__eyebrow {
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
}
|
||||
|
||||
:host-context(.dark-theme) .player-shell__title {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.player-shell {
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.player-shell__title-row {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.player-shell__actions {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.player-shell__viewport {
|
||||
min-height: 220px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { ClipboardModule } from '@angular/cdk/clipboard';
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
input,
|
||||
output,
|
||||
} from '@angular/core';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatTooltipModule } from '@angular/material/tooltip';
|
||||
import {
|
||||
PlayerContentInfo,
|
||||
ResolvedPortalPlayback,
|
||||
} from 'shared-interfaces';
|
||||
import { WebPlayerViewComponent } from '../web-player-view/web-player-view.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-portal-inline-player',
|
||||
templateUrl: './portal-inline-player.component.html',
|
||||
styleUrl: './portal-inline-player.component.scss',
|
||||
imports: [
|
||||
ClipboardModule,
|
||||
MatButtonModule,
|
||||
MatIconModule,
|
||||
MatTooltipModule,
|
||||
WebPlayerViewComponent,
|
||||
],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
host: {
|
||||
class: 'portal-inline-player',
|
||||
'[attr.data-has-player]': 'hasPlayback()',
|
||||
},
|
||||
})
|
||||
export class PortalInlinePlayerComponent {
|
||||
readonly playback = input<ResolvedPortalPlayback | null>(null);
|
||||
readonly title = computed(() => this.playback()?.title ?? '');
|
||||
readonly streamUrl = computed(() => this.playback()?.streamUrl ?? '');
|
||||
readonly startTime = computed(() => this.playback()?.startTime ?? 0);
|
||||
readonly contentInfo = computed<PlayerContentInfo | undefined>(
|
||||
() => this.playback()?.contentInfo
|
||||
);
|
||||
readonly hasPlayback = computed(() => !!this.playback()?.streamUrl);
|
||||
|
||||
readonly closed = output<void>();
|
||||
readonly timeUpdate = output<{
|
||||
currentTime: number;
|
||||
duration: number;
|
||||
}>();
|
||||
readonly streamUrlCopied = output<void>();
|
||||
|
||||
onClose(): void {
|
||||
this.closed.emit();
|
||||
}
|
||||
|
||||
onTimeUpdate(event: { currentTime: number; duration: number }): void {
|
||||
this.timeUpdate.emit(event);
|
||||
}
|
||||
|
||||
onCopied(): void {
|
||||
this.streamUrlCopied.emit();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,588 @@
|
||||
// ============================================================================
|
||||
// Netflix-style Detail View - Cinematic Streaming Design
|
||||
// ============================================================================
|
||||
|
||||
:host {
|
||||
--poster-width: 220px;
|
||||
--poster-shadow: 0 8px 40px rgba(0, 0, 0, 0.15);
|
||||
--content-max-width: 1400px;
|
||||
|
||||
// Light theme colors (default - no .dark-theme class on body)
|
||||
--hero-gradient-start: rgba(255, 250, 245, 0);
|
||||
--hero-gradient-mid: rgba(255, 250, 245, 0.75);
|
||||
--hero-gradient-end: rgba(255, 250, 245, 1);
|
||||
--surface-bg: #fffaf5;
|
||||
--text-primary: #1a1a1a;
|
||||
--text-secondary: rgba(26, 26, 26, 0.75);
|
||||
--text-muted: rgba(26, 26, 26, 0.5);
|
||||
--accent-color: #b81d24;
|
||||
--tag-bg: rgba(0, 0, 0, 0.06);
|
||||
--tag-border: rgba(0, 0, 0, 0.12);
|
||||
--play-idle-bg: linear-gradient(135deg, #fffaf2 0%, #ffffff 52%, #f7ecdf 100%);
|
||||
--play-idle-fg: #161310;
|
||||
--play-idle-border: rgba(88, 48, 18, 0.12);
|
||||
--play-idle-shadow: 0 18px 38px rgba(91, 56, 24, 0.14);
|
||||
--play-launching-bg: linear-gradient(135deg, #ffd36f 0%, #ffb14c 46%, #f57d35 100%);
|
||||
--play-launching-fg: #241103;
|
||||
--play-launching-border: rgba(142, 66, 0, 0.2);
|
||||
--play-launching-shadow: 0 22px 44px rgba(244, 136, 55, 0.32);
|
||||
--play-stop-bg: linear-gradient(135deg, #ffd1c8 0%, #ff9f8d 42%, #ef5e64 100%);
|
||||
--play-stop-fg: #321012;
|
||||
--play-stop-border: rgba(162, 38, 45, 0.18);
|
||||
--play-stop-shadow: 0 22px 46px rgba(214, 74, 86, 0.26);
|
||||
--play-icon-shell-bg: rgba(255, 255, 255, 0.32);
|
||||
--play-icon-shell-border: rgba(255, 255, 255, 0.3);
|
||||
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
background: var(--surface-bg);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
// Dark theme overrides - cinematic dark mode
|
||||
:host-context(.dark-theme) {
|
||||
--hero-gradient-start: rgba(20, 20, 20, 0);
|
||||
--hero-gradient-mid: rgba(20, 20, 20, 0.7);
|
||||
--hero-gradient-end: rgba(20, 20, 20, 1);
|
||||
--surface-bg: #141414;
|
||||
--text-primary: #fff;
|
||||
--text-secondary: rgba(255, 255, 255, 0.7);
|
||||
--text-muted: rgba(255, 255, 255, 0.5);
|
||||
--accent-color: #e50914;
|
||||
--tag-bg: rgba(255, 255, 255, 0.1);
|
||||
--tag-border: rgba(255, 255, 255, 0.2);
|
||||
--poster-shadow: 0 8px 40px rgba(0, 0, 0, 0.5);
|
||||
--play-idle-bg: linear-gradient(135deg, #fff5e6 0%, #f6efe5 58%, #e7dac9 100%);
|
||||
--play-idle-fg: #15110d;
|
||||
--play-idle-border: rgba(255, 255, 255, 0.18);
|
||||
--play-idle-shadow: 0 20px 42px rgba(0, 0, 0, 0.36);
|
||||
--play-launching-bg: linear-gradient(135deg, #ffdd79 0%, #ffb347 42%, #ef6a3a 100%);
|
||||
--play-launching-fg: #1f1105;
|
||||
--play-launching-border: rgba(255, 223, 136, 0.26);
|
||||
--play-launching-shadow: 0 24px 50px rgba(255, 153, 71, 0.3);
|
||||
--play-stop-bg: linear-gradient(135deg, #ffd7cf 0%, #ff9b8b 40%, #eb5961 100%);
|
||||
--play-stop-fg: #2d1012;
|
||||
--play-stop-border: rgba(255, 205, 205, 0.24);
|
||||
--play-stop-shadow: 0 24px 50px rgba(235, 89, 97, 0.24);
|
||||
--play-icon-shell-bg: rgba(255, 255, 255, 0.28);
|
||||
--play-icon-shell-border: rgba(255, 255, 255, 0.24);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Details Section - Content Elements
|
||||
// ============================================================================
|
||||
|
||||
.details {
|
||||
// Styles for projected content elements
|
||||
|
||||
&__tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 4px 12px;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
background: var(--tag-bg);
|
||||
border: 1px solid var(--tag-border);
|
||||
border-radius: 4px;
|
||||
backdrop-filter: blur(8px);
|
||||
|
||||
&--rating {
|
||||
color: #ffd700;
|
||||
border-color: rgba(255, 215, 0, 0.3);
|
||||
}
|
||||
}
|
||||
|
||||
&__meta-item {
|
||||
font-size: 0.875rem;
|
||||
|
||||
.label {
|
||||
color: var(--text-muted);
|
||||
font-weight: 400;
|
||||
margin-bottom: 2px;
|
||||
text-decoration: none;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.value {
|
||||
color: var(--text-primary);
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Action Buttons - Play, Favorite
|
||||
// ============================================================================
|
||||
|
||||
.play-btn {
|
||||
--play-btn-bg: var(--play-idle-bg);
|
||||
--play-btn-fg: var(--play-idle-fg);
|
||||
--play-btn-border: var(--play-idle-border);
|
||||
--play-btn-shadow: var(--play-idle-shadow);
|
||||
|
||||
position: relative;
|
||||
isolation: isolate;
|
||||
overflow: hidden;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 26px;
|
||||
min-height: 54px;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.01em;
|
||||
background: var(--play-btn-bg);
|
||||
background-size: 140% 140%;
|
||||
background-position: 0% 50%;
|
||||
color: var(--play-btn-fg);
|
||||
border: 1px solid var(--play-btn-border);
|
||||
border-radius: 16px;
|
||||
box-shadow: var(--play-btn-shadow);
|
||||
cursor: pointer;
|
||||
transition:
|
||||
transform 220ms cubic-bezier(0.22, 1, 0.36, 1),
|
||||
background-position 280ms ease,
|
||||
box-shadow 260ms ease,
|
||||
border-color 220ms ease,
|
||||
color 220ms ease,
|
||||
filter 220ms ease;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 1px;
|
||||
border-radius: inherit;
|
||||
background:
|
||||
linear-gradient(115deg, rgba(255, 255, 255, 0.34), transparent 36%),
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.18), transparent 62%);
|
||||
opacity: 0.7;
|
||||
transition: opacity 220ms ease;
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
transform: translateY(-2px);
|
||||
background-position: 100% 50%;
|
||||
box-shadow:
|
||||
var(--play-btn-shadow),
|
||||
0 10px 20px rgba(255, 255, 255, 0.16) inset;
|
||||
}
|
||||
|
||||
&:hover:not(:disabled)::before {
|
||||
opacity: 0.95;
|
||||
}
|
||||
|
||||
&:active:not(:disabled) {
|
||||
transform: translateY(0);
|
||||
box-shadow: var(--play-btn-shadow);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
cursor: progress;
|
||||
opacity: 1;
|
||||
filter: saturate(1.05);
|
||||
}
|
||||
|
||||
mat-icon {
|
||||
font-size: 24px;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
transition:
|
||||
transform 220ms cubic-bezier(0.22, 1, 0.36, 1),
|
||||
color 220ms ease,
|
||||
filter 220ms ease;
|
||||
}
|
||||
|
||||
&:hover:not(:disabled) .play-btn__icon {
|
||||
transform: scale(1.08);
|
||||
}
|
||||
|
||||
&__icon-shell {
|
||||
flex: 0 0 auto;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: 999px;
|
||||
background: var(--play-icon-shell-bg);
|
||||
border: 1px solid var(--play-icon-shell-border);
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.38),
|
||||
0 6px 14px rgba(0, 0, 0, 0.08);
|
||||
backdrop-filter: blur(10px);
|
||||
transition:
|
||||
transform 220ms ease,
|
||||
background-color 220ms ease,
|
||||
border-color 220ms ease,
|
||||
box-shadow 220ms ease;
|
||||
}
|
||||
|
||||
&__label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
&--state-launching,
|
||||
&--state-stop {
|
||||
color: var(--play-btn-fg);
|
||||
}
|
||||
|
||||
&--state-launching {
|
||||
--play-btn-bg: var(--play-launching-bg);
|
||||
--play-btn-fg: var(--play-launching-fg);
|
||||
--play-btn-border: var(--play-launching-border);
|
||||
--play-btn-shadow: var(--play-launching-shadow);
|
||||
animation: playButtonPulse 1.8s ease-in-out infinite;
|
||||
|
||||
.play-btn__icon-shell {
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.42),
|
||||
0 10px 22px rgba(184, 89, 26, 0.2);
|
||||
}
|
||||
|
||||
.play-btn__icon {
|
||||
animation: playIconLaunch 1.15s cubic-bezier(0.55, 0, 0.45, 1) infinite;
|
||||
}
|
||||
}
|
||||
|
||||
&--state-stop {
|
||||
--play-btn-bg: var(--play-stop-bg);
|
||||
--play-btn-fg: var(--play-stop-fg);
|
||||
--play-btn-border: var(--play-stop-border);
|
||||
--play-btn-shadow: var(--play-stop-shadow);
|
||||
|
||||
.play-btn__icon-shell {
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.4),
|
||||
0 12px 24px rgba(145, 38, 53, 0.18);
|
||||
}
|
||||
|
||||
.play-btn__icon {
|
||||
animation: playIconStop 1.2s ease-in-out infinite;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes playButtonPulse {
|
||||
0%,
|
||||
100% {
|
||||
box-shadow: var(--play-btn-shadow);
|
||||
}
|
||||
|
||||
50% {
|
||||
box-shadow:
|
||||
var(--play-btn-shadow),
|
||||
0 0 0 6px rgba(255, 190, 92, 0.14);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes playIconLaunch {
|
||||
0%,
|
||||
100% {
|
||||
transform: rotate(0deg) scale(1);
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: rotate(18deg) scale(1.1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes playIconStop {
|
||||
0%,
|
||||
100% {
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: scale(1.08);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.play-btn,
|
||||
.play-btn::before,
|
||||
.play-btn mat-icon,
|
||||
.play-btn__icon-shell {
|
||||
animation: none !important;
|
||||
transition-duration: 1ms !important;
|
||||
}
|
||||
}
|
||||
|
||||
.favorite-btn,
|
||||
.download-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 12px 20px;
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 500;
|
||||
background: rgba(109, 109, 110, 0.7);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
background: rgba(109, 109, 110, 0.5);
|
||||
}
|
||||
|
||||
mat-icon {
|
||||
font-size: 20px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.favorite-btn {
|
||||
&--active {
|
||||
background: rgba(229, 9, 20, 0.8);
|
||||
|
||||
&:hover {
|
||||
background: rgba(229, 9, 20, 0.6);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.download-btn {
|
||||
background: rgba(33, 150, 243, 0.8);
|
||||
|
||||
&:hover {
|
||||
background: rgba(33, 150, 243, 0.6);
|
||||
}
|
||||
|
||||
&--completed {
|
||||
background: rgba(76, 175, 80, 0.8);
|
||||
|
||||
&:hover {
|
||||
background: rgba(76, 175, 80, 0.6);
|
||||
}
|
||||
}
|
||||
|
||||
&--downloading {
|
||||
background: rgba(109, 109, 110, 0.5);
|
||||
cursor: not-allowed;
|
||||
opacity: 0.7;
|
||||
|
||||
&:hover {
|
||||
background: rgba(109, 109, 110, 0.5);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Content Sections - Below the hero fold
|
||||
// ============================================================================
|
||||
|
||||
.content-section {
|
||||
padding: 20px 0;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
|
||||
&__title {
|
||||
font-size: 1.375rem;
|
||||
font-weight: 600;
|
||||
margin: 0 0 20px 0;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Seasons & Episodes - Inside details section
|
||||
// ============================================================================
|
||||
|
||||
.seasons {
|
||||
margin-top: 24px;
|
||||
width: 100%;
|
||||
|
||||
.season-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 0;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
h4 {
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.expand-icon {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
}
|
||||
|
||||
.episodes {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
padding: 8px 0 16px;
|
||||
|
||||
button {
|
||||
margin: 0;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
.episodes-loading,
|
||||
.seasons-loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 20px 0;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.no-episodes {
|
||||
color: var(--text-muted);
|
||||
font-style: italic;
|
||||
padding: 8px 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Seasons loading when inside details
|
||||
.details .seasons-loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 20px 0;
|
||||
color: var(--text-secondary);
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
// Season container when inside details section
|
||||
.details app-season-container {
|
||||
display: block;
|
||||
width: 100%;
|
||||
margin-top: 24px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// YouTube Trailer Section - Inside details
|
||||
// ============================================================================
|
||||
|
||||
.youtube-trailer {
|
||||
margin-top: 24px;
|
||||
width: 100%;
|
||||
|
||||
h3 {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
margin: 0 0 12px 0;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.trailer-wrapper {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
max-width: 560px;
|
||||
aspect-ratio: 16 / 9;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
|
||||
|
||||
iframe {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Legacy container support (for gradual migration)
|
||||
// ============================================================================
|
||||
|
||||
.container {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
.image {
|
||||
display: none; // Hidden - now using .poster in hero
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Mobile Responsive - Option A: Keep poster grid layout
|
||||
// ============================================================================
|
||||
|
||||
@media (max-width: 800px) {
|
||||
:host {
|
||||
--poster-width: 120px;
|
||||
}
|
||||
|
||||
.action-buttons {
|
||||
width: 100%;
|
||||
// This class is in ContentHeroComponent now, but these rules apply to children?
|
||||
// No, .play-btn is a child of the PROJECTED content.
|
||||
// So we should target the buttons directly for mobile
|
||||
}
|
||||
|
||||
.play-btn,
|
||||
.favorite-btn,
|
||||
.download-btn {
|
||||
flex: 1;
|
||||
justify-content: center;
|
||||
padding: 10px 16px;
|
||||
}
|
||||
|
||||
.youtube-trailer {
|
||||
margin-top: 16px;
|
||||
|
||||
.trailer-wrapper {
|
||||
max-width: 100%;
|
||||
border-radius: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.seasons {
|
||||
padding: 16px 0 0;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Animations
|
||||
// ============================================================================
|
||||
|
||||
.youtube-trailer {
|
||||
animation: fadeInUp 0.5s ease-out 0.4s both;
|
||||
}
|
||||
|
||||
@keyframes fadeInUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Utility - keep .label for backwards compatibility
|
||||
// ============================================================================
|
||||
|
||||
.label {
|
||||
font-weight: 400;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--text-muted);
|
||||
text-decoration: none;
|
||||
padding: 0;
|
||||
opacity: 1;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
@if (showPlaylistHeader()) {
|
||||
<div class="current-playlist">
|
||||
<button
|
||||
mat-icon-button
|
||||
routerLink="/"
|
||||
[matTooltip]="'SETTINGS.BACK_TO_HOME' | translate"
|
||||
class="home-btn"
|
||||
>
|
||||
<mat-icon>home</mat-icon>
|
||||
</button>
|
||||
|
||||
<app-playlist-switcher
|
||||
class="switcher"
|
||||
[currentTitle]="playlistTitle()"
|
||||
[subtitle]="subtitle()"
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
|
||||
<app-channel-list-container
|
||||
[channelList]="channels()"
|
||||
[activeView]="activeView()"
|
||||
/>
|
||||
@@ -0,0 +1,22 @@
|
||||
.current-playlist {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 8px;
|
||||
gap: 4px;
|
||||
app-region: drag;
|
||||
|
||||
button {
|
||||
app-region: no-drag;
|
||||
}
|
||||
|
||||
.switcher {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 480px) {
|
||||
.current-playlist {
|
||||
padding: 6px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { Component, computed, inject, input } from '@angular/core';
|
||||
import { MatIconButton } from '@angular/material/button';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
import { MatTooltip } from '@angular/material/tooltip';
|
||||
import { RouterLink } from '@angular/router';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
|
||||
import { PlaylistSwitcherComponent } from '@iptvnator/playlist/shared/ui';
|
||||
import { selectPlaylistTitle } from 'm3u-state';
|
||||
import { Channel } from 'shared-interfaces';
|
||||
import { ChannelListContainerComponent } from 'components';
|
||||
|
||||
@Component({
|
||||
selector: 'app-sidebar',
|
||||
templateUrl: './sidebar.component.html',
|
||||
styleUrls: ['./sidebar.component.scss'],
|
||||
imports: [
|
||||
ChannelListContainerComponent,
|
||||
MatIcon,
|
||||
MatIconButton,
|
||||
MatTooltip,
|
||||
PlaylistSwitcherComponent,
|
||||
RouterLink,
|
||||
TranslatePipe,
|
||||
],
|
||||
})
|
||||
export class SidebarComponent {
|
||||
readonly channels = input<Channel[]>([]);
|
||||
readonly showPlaylistHeader = input(true);
|
||||
readonly activeView = input<string>('all');
|
||||
|
||||
private readonly store = inject(Store);
|
||||
private readonly translate = inject(TranslateService);
|
||||
|
||||
readonly playlistTitle = this.store.selectSignal(selectPlaylistTitle);
|
||||
|
||||
readonly subtitle = computed(() => {
|
||||
const count = this.channels()?.length ?? 0;
|
||||
return `${count} ${this.translate.instant('HOME.PLAYLISTS.CHANNELS')}`;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
<mat-toolbar color="primary">
|
||||
<button
|
||||
mat-icon-button
|
||||
(click)="toggleLeftDrawerClicked.emit()"
|
||||
[matTooltip]="'TOP_MENU.OPEN_CHANNELS_LIST' | translate"
|
||||
class="sidenav-toggle-button"
|
||||
>
|
||||
<mat-icon>{{ isLeftDrawerOpened ? 'menu' : 'menu_open' }}</mat-icon>
|
||||
</button>
|
||||
<!-- @if (playlistId$ | async) {
|
||||
<button
|
||||
mat-icon-button
|
||||
(click)="updateFavoriteStatus(activeChannel)"
|
||||
[matTooltip]="'TOP_MENU.TOGGLE_FAVORITE_FLAG' | translate"
|
||||
>
|
||||
<mat-icon>
|
||||
{{
|
||||
(favorites$ | async).includes(activeChannel?.url)
|
||||
? 'star'
|
||||
: 'star_outline'
|
||||
}}</mat-icon
|
||||
>
|
||||
</button>
|
||||
} -->
|
||||
<button
|
||||
mat-icon-button
|
||||
(click)="infoOverlayClicked.emit()"
|
||||
[matTooltip]="'TOP_MENU.SHOW_INFO' | translate"
|
||||
>
|
||||
<mat-icon>info</mat-icon>
|
||||
</button>
|
||||
{{ activeChannel?.name }}
|
||||
<div class="spacer"></div>
|
||||
<!-- <button
|
||||
mat-icon-button
|
||||
(click)="settingsClicked.emit()"
|
||||
[matTooltip]="'MENU.SETTINGS' | translate"
|
||||
>
|
||||
<mat-icon>settings</mat-icon>
|
||||
</button> -->
|
||||
<button
|
||||
mat-icon-button
|
||||
(click)="multiEpgClicked.emit()"
|
||||
[matTooltip]="'TOP_MENU.OPEN_MULTI_EPG' | translate"
|
||||
>
|
||||
<mat-icon>view_list</mat-icon>
|
||||
</button>
|
||||
@let isEpgAvailable = isEpgAvailable$ | async;
|
||||
@if (isEpgAvailable) {
|
||||
<button
|
||||
mat-button
|
||||
(click)="toggleRightDrawerClicked.emit()"
|
||||
[matTooltip]="'TOP_MENU.OPEN_EPG_LIST' | translate"
|
||||
>
|
||||
EPG
|
||||
</button>
|
||||
}
|
||||
</mat-toolbar>
|
||||
@@ -0,0 +1,26 @@
|
||||
mat-toolbar {
|
||||
app-region: drag;
|
||||
|
||||
button {
|
||||
app-region: no-drag;
|
||||
}
|
||||
}
|
||||
|
||||
.spacer {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.mat-toolbar.mat-primary {
|
||||
background: var(--mdc-primary-100);
|
||||
color: #ccc;
|
||||
|
||||
button {
|
||||
color: #ccc;
|
||||
}
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 599px) {
|
||||
.sidenav-toggle-button {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { AsyncPipe } from '@angular/common';
|
||||
import { Component, inject, Input, output } from '@angular/core';
|
||||
import { MatButton, MatIconButton } from '@angular/material/button';
|
||||
import { MatDialogModule } from '@angular/material/dialog';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
import { MatToolbarModule } from '@angular/material/toolbar';
|
||||
import { MatTooltip } from '@angular/material/tooltip';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
import {
|
||||
FavoritesActions,
|
||||
selectActivePlaylistId,
|
||||
selectFavorites,
|
||||
selectIsEpgAvailable,
|
||||
} from 'm3u-state';
|
||||
import { Channel } from 'shared-interfaces';
|
||||
|
||||
@Component({
|
||||
imports: [
|
||||
AsyncPipe,
|
||||
MatButton,
|
||||
MatDialogModule,
|
||||
MatIcon,
|
||||
MatIconButton,
|
||||
MatToolbarModule,
|
||||
MatTooltip,
|
||||
TranslatePipe,
|
||||
],
|
||||
selector: 'app-toolbar',
|
||||
templateUrl: './toolbar.component.html',
|
||||
styleUrls: ['./toolbar.component.scss'],
|
||||
})
|
||||
export class ToolbarComponent {
|
||||
@Input() activeChannel!: Channel;
|
||||
@Input() isLeftDrawerOpened = true;
|
||||
readonly infoOverlayClicked = output<void>();
|
||||
readonly multiEpgClicked = output<void>();
|
||||
readonly settingsClicked = output<void>();
|
||||
readonly toggleLeftDrawerClicked = output<void>();
|
||||
readonly toggleRightDrawerClicked = output<void>();
|
||||
|
||||
private readonly store = inject(Store);
|
||||
|
||||
readonly favorites$ = this.store.select(selectFavorites);
|
||||
readonly isEpgAvailable$ = this.store.select(selectIsEpgAvailable);
|
||||
readonly playlistId$ = this.store.select(selectActivePlaylistId);
|
||||
|
||||
updateFavoriteStatus(channel: Channel) {
|
||||
this.store.dispatch(FavoritesActions.updateFavorites({ channel }));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
<video
|
||||
#target
|
||||
class="video-js"
|
||||
controls
|
||||
muted
|
||||
playsinline
|
||||
preload="none"
|
||||
data-setup='{"liveui": true, "muted": false}'
|
||||
></video>
|
||||
@@ -0,0 +1,26 @@
|
||||
:host {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.video-js {
|
||||
position: relative !important;
|
||||
width: 100% !important;
|
||||
height: 100% !important;
|
||||
}
|
||||
|
||||
/* Hide the captions settings item from the captions menu. */
|
||||
.hide-captions {
|
||||
.vjs-texttrack-settings,
|
||||
.vjs-text-track-display,
|
||||
.vjs-subs-caps-button {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.video-js .vjs-menu-button-popup .vjs-menu {
|
||||
z-index: 10;
|
||||
}
|
||||
@@ -0,0 +1,333 @@
|
||||
import {
|
||||
Component,
|
||||
ElementRef,
|
||||
EventEmitter,
|
||||
Input,
|
||||
OnChanges,
|
||||
OnDestroy,
|
||||
OnInit,
|
||||
Output,
|
||||
SimpleChanges,
|
||||
ViewChild,
|
||||
ViewEncapsulation,
|
||||
} from '@angular/core';
|
||||
import '@yangkghjh/videojs-aspect-ratio-panel';
|
||||
import { getExtensionFromUrl } from 'm3u-utils';
|
||||
import mpegts from 'mpegts.js';
|
||||
import videoJs from 'video.js';
|
||||
import 'videojs-contrib-quality-levels';
|
||||
import 'videojs-quality-selector-hls';
|
||||
|
||||
/**
|
||||
* This component contains the implementation of video player that is based on video.js library
|
||||
*/
|
||||
|
||||
type VideoPlayerSource = {
|
||||
src: string;
|
||||
type?: string;
|
||||
};
|
||||
|
||||
type VideoPlayerOptions = Record<string, unknown> & {
|
||||
autoplay?: boolean;
|
||||
sources?: VideoPlayerSource[];
|
||||
};
|
||||
|
||||
type VideoJsAudioTrack = {
|
||||
label?: string;
|
||||
language?: string;
|
||||
enabled?: boolean;
|
||||
kind?: string;
|
||||
};
|
||||
|
||||
type VideoJsAudioTrackList = {
|
||||
length: number;
|
||||
[index: number]: VideoJsAudioTrack;
|
||||
addEventListener: (
|
||||
type: string,
|
||||
listener: EventListenerOrEventListenerObject
|
||||
) => void;
|
||||
};
|
||||
|
||||
type VideoJsTech = {
|
||||
el?: () => Element | null;
|
||||
vhs?: {
|
||||
playlists?: {
|
||||
main?: { mediaGroups?: { AUDIO?: Record<string, unknown> } };
|
||||
master?: { mediaGroups?: { AUDIO?: Record<string, unknown> } };
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
type VideoJsControlChild = {
|
||||
getChild?: (name: string) => VideoJsControlChild | null;
|
||||
addChild?: (
|
||||
name: string,
|
||||
options?: Record<string, unknown>
|
||||
) => VideoJsControlChild | null;
|
||||
show?: () => void;
|
||||
update?: () => void;
|
||||
};
|
||||
|
||||
type VideoJsPlayer = Omit<
|
||||
ReturnType<typeof videoJs>,
|
||||
'audioTracks' | 'tech' | 'getChild'
|
||||
> & {
|
||||
qualitySelectorHls?: (options?: { displayCurrentQuality?: boolean }) => void;
|
||||
aspectRatioPanel?: () => void;
|
||||
audioTracks: () => VideoJsAudioTrackList | null;
|
||||
tech: (options?: unknown) => VideoJsTech | null;
|
||||
getChild: (name: string) => VideoJsControlChild | null;
|
||||
};
|
||||
|
||||
@Component({
|
||||
selector: 'app-vjs-player',
|
||||
templateUrl: './vjs-player.component.html',
|
||||
styleUrls: ['./vjs-player.component.scss'],
|
||||
encapsulation: ViewEncapsulation.None,
|
||||
standalone: true,
|
||||
})
|
||||
export class VjsPlayerComponent implements OnInit, OnChanges, OnDestroy {
|
||||
/** DOM-element reference */
|
||||
@ViewChild('target', { static: true }) target!: ElementRef<Element>;
|
||||
/** Options of VideoJs player */
|
||||
@Input() options!: VideoPlayerOptions;
|
||||
/** VideoJs object */
|
||||
player!: VideoJsPlayer;
|
||||
/** mpegts.js player for raw MPEG-TS streams */
|
||||
private mpegtsPlayer: mpegts.Player | null = null;
|
||||
@Input() volume = 1;
|
||||
@Input() startTime = 0;
|
||||
@Output() timeUpdate = new EventEmitter<{
|
||||
currentTime: number;
|
||||
duration: number;
|
||||
}>();
|
||||
|
||||
/**
|
||||
* Instantiate Video.js on component init
|
||||
*/
|
||||
ngOnInit(): void {
|
||||
const source = this.options?.sources?.[0];
|
||||
const isMpegTs = this.isMpegTsSource(source?.src);
|
||||
|
||||
// For raw MPEG-TS streams, init Video.js without a source (UI/controls only)
|
||||
const vjsOptions = isMpegTs
|
||||
? { ...this.options, sources: [], autoplay: false }
|
||||
: { ...this.options, autoplay: true };
|
||||
|
||||
this.player = videoJs(
|
||||
this.target.nativeElement,
|
||||
vjsOptions,
|
||||
() => {
|
||||
console.log(
|
||||
'Setting VideoJS player initial volume to:',
|
||||
this.volume
|
||||
);
|
||||
this.player.volume(this.volume);
|
||||
|
||||
this.player.on('loadedmetadata', () => {
|
||||
if (this.startTime > 0) {
|
||||
this.player.currentTime(this.startTime);
|
||||
}
|
||||
this.logAudioTracks();
|
||||
this.setupAudioTrackMenu();
|
||||
});
|
||||
|
||||
// Audio tracks may be added after loadedmetadata (e.g. HLS alternate audio)
|
||||
const audioTracks = this.player.audioTracks();
|
||||
if (audioTracks) {
|
||||
audioTracks.addEventListener('addtrack', () => {
|
||||
console.log(
|
||||
'[AudioTrack] addtrack event fired, total tracks:',
|
||||
this.player.audioTracks().length
|
||||
);
|
||||
this.logAudioTracks();
|
||||
this.setupAudioTrackMenu();
|
||||
});
|
||||
audioTracks.addEventListener('removetrack', () => {
|
||||
console.log(
|
||||
'[AudioTrack] removetrack event fired, total tracks:',
|
||||
this.player.audioTracks().length
|
||||
);
|
||||
this.logAudioTracks();
|
||||
this.setupAudioTrackMenu();
|
||||
});
|
||||
audioTracks.addEventListener('change', () => {
|
||||
this.logAudioTracks();
|
||||
});
|
||||
}
|
||||
|
||||
this.player.on('volumechange', () => {
|
||||
const currentVolume = this.player.volume();
|
||||
localStorage.setItem('volume', currentVolume.toString());
|
||||
});
|
||||
|
||||
this.player.on('timeupdate', () => {
|
||||
this.timeUpdate.emit({
|
||||
currentTime: this.player.currentTime(),
|
||||
duration: this.player.duration(),
|
||||
});
|
||||
});
|
||||
|
||||
// Attach mpegts.js after Video.js is ready
|
||||
if (isMpegTs) {
|
||||
this.initMpegTs(source.src);
|
||||
}
|
||||
}
|
||||
) as unknown as VideoJsPlayer;
|
||||
try {
|
||||
if (typeof this.player.qualitySelectorHls === 'function') {
|
||||
this.player.qualitySelectorHls({
|
||||
displayCurrentQuality: true,
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('qualitySelectorHls plugin failed to initialize:', e);
|
||||
}
|
||||
try {
|
||||
if (typeof this.player.aspectRatioPanel === 'function') {
|
||||
this.player.aspectRatioPanel();
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('aspectRatioPanel plugin failed to initialize:', e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces the url source of the player with the changed source url
|
||||
* @param changes contains changed channel object
|
||||
*/
|
||||
ngOnChanges(changes: SimpleChanges): void {
|
||||
if (changes['options']?.previousValue) {
|
||||
const newSource = changes['options'].currentValue.sources[0];
|
||||
if (this.isMpegTsSource(newSource?.src)) {
|
||||
this.destroyMpegTs();
|
||||
this.player.reset();
|
||||
this.initMpegTs(newSource.src);
|
||||
} else {
|
||||
this.destroyMpegTs();
|
||||
this.player.src(newSource);
|
||||
}
|
||||
}
|
||||
if (changes['volume']?.currentValue !== undefined && this.player) {
|
||||
console.log(
|
||||
'Setting VideoJS player volume to:',
|
||||
changes['volume'].currentValue
|
||||
);
|
||||
this.player.volume(changes['volume'].currentValue);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes the players HTML reference on destroy
|
||||
*/
|
||||
ngOnDestroy(): void {
|
||||
this.destroyMpegTs();
|
||||
if (this.player) {
|
||||
this.player.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
private isMpegTsSource(url?: string): boolean {
|
||||
if (!url) return false;
|
||||
return getExtensionFromUrl(url) === 'ts' && mpegts.isSupported();
|
||||
}
|
||||
|
||||
private initMpegTs(url: string): void {
|
||||
const videoEl = this.player.tech({ IWillNotUseThisInPlugins: true })?.el();
|
||||
if (!videoEl) return;
|
||||
|
||||
console.log('Using mpegts.js for TS stream:', url);
|
||||
this.mpegtsPlayer = mpegts.createPlayer({
|
||||
type: 'mpegts',
|
||||
isLive: true,
|
||||
url: url,
|
||||
});
|
||||
this.mpegtsPlayer.attachMediaElement(videoEl as HTMLVideoElement);
|
||||
this.mpegtsPlayer.load();
|
||||
this.mpegtsPlayer.play();
|
||||
}
|
||||
|
||||
private destroyMpegTs(): void {
|
||||
if (this.mpegtsPlayer) {
|
||||
this.mpegtsPlayer.pause();
|
||||
this.mpegtsPlayer.unload();
|
||||
this.mpegtsPlayer.detachMediaElement();
|
||||
this.mpegtsPlayer.destroy();
|
||||
this.mpegtsPlayer = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Logs all available audio tracks for debugging.
|
||||
*/
|
||||
private logAudioTracks(): void {
|
||||
const audioTracks = this.player.audioTracks();
|
||||
console.log('[AudioTrack] Audio tracks count:', audioTracks?.length ?? 0);
|
||||
for (let i = 0; i < (audioTracks?.length ?? 0); i++) {
|
||||
const t = audioTracks[i];
|
||||
console.log(
|
||||
`[AudioTrack] Track ${i}: label="${t.label}", language="${t.language}", enabled=${t.enabled}, kind="${t.kind}"`
|
||||
);
|
||||
}
|
||||
|
||||
// Also check the underlying tech for HLS audio tracks
|
||||
const tech =
|
||||
typeof this.player?.tech === 'function'
|
||||
? this.player.tech({ IWillNotUseThisInPlugins: true })
|
||||
: null;
|
||||
const audioMediaGroups =
|
||||
tech?.vhs?.playlists?.main?.mediaGroups?.AUDIO ??
|
||||
tech?.vhs?.playlists?.master?.mediaGroups?.AUDIO;
|
||||
|
||||
if (audioMediaGroups) {
|
||||
console.log(
|
||||
'[AudioTrack] HLS AUDIO media groups:',
|
||||
JSON.stringify(Object.keys(audioMediaGroups))
|
||||
);
|
||||
} else {
|
||||
console.log(
|
||||
'[AudioTrack] HLS AUDIO media groups: none found in playlist metadata'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets up the audio track selection menu in the control bar.
|
||||
* Uses the Video.js audioTracks() API which works with both
|
||||
* native multi-audio streams and HLS.js alternate audio tracks.
|
||||
*/
|
||||
private setupAudioTrackMenu(): void {
|
||||
const audioTracks = this.player.audioTracks();
|
||||
console.log(
|
||||
'[AudioTrack] setupAudioTrackMenu called, tracks:',
|
||||
audioTracks?.length ?? 0
|
||||
);
|
||||
if (!audioTracks || audioTracks.length <= 1) {
|
||||
console.log(
|
||||
'[AudioTrack] Skipping menu: need >1 tracks, have',
|
||||
audioTracks?.length ?? 0
|
||||
);
|
||||
console.log(
|
||||
'[AudioTrack] If VLC/MPV show more tracks, the HLS manifest likely does not expose alternate audio via EXT-X-MEDIA'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const controlBar = this.player.getChild('controlBar');
|
||||
if (!controlBar) {
|
||||
return;
|
||||
}
|
||||
|
||||
let audioButton =
|
||||
controlBar.getChild('audioTrackButton') ??
|
||||
controlBar.getChild('AudioTrackButton');
|
||||
if (!audioButton) {
|
||||
audioButton = controlBar.addChild('audioTrackButton', {});
|
||||
}
|
||||
|
||||
if (audioButton) {
|
||||
audioButton.show?.();
|
||||
audioButton.update?.();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
@let meta = normalizedMeta();
|
||||
|
||||
<!-- Hero Section with Backdrop -->
|
||||
<app-content-hero
|
||||
[title]="meta.title"
|
||||
[description]="meta.description"
|
||||
[posterUrl]="meta.posterUrl"
|
||||
[backdropUrl]="meta.backdropUrl"
|
||||
(backClicked)="goBack()"
|
||||
>
|
||||
<!-- Tags: Year, Genre, Duration, Rating -->
|
||||
<ng-container hero-tags>
|
||||
@if (meta.year) {
|
||||
<span class="details__tag">{{ meta.year }}</span>
|
||||
}
|
||||
@if (meta.genre) {
|
||||
<span class="details__tag">{{ meta.genre }}</span>
|
||||
}
|
||||
@if (meta.duration) {
|
||||
<span class="details__tag">{{ meta.duration }}</span>
|
||||
}
|
||||
@if (meta.ratingImdb) {
|
||||
<span class="details__tag details__tag--rating">
|
||||
⭐ {{ meta.ratingImdb }}
|
||||
</span>
|
||||
}
|
||||
@if (meta.country) {
|
||||
<span class="details__tag">{{ meta.country }}</span>
|
||||
}
|
||||
</ng-container>
|
||||
|
||||
<!-- Metadata Grid -->
|
||||
<ng-container hero-meta>
|
||||
@if (meta.actors) {
|
||||
<div class="details__meta-item">
|
||||
<span class="label">{{ 'XTREAM.ACTORS' | translate }}</span>
|
||||
<span class="value">{{ meta.actors }}</span>
|
||||
</div>
|
||||
}
|
||||
@if (meta.director) {
|
||||
<div class="details__meta-item">
|
||||
<span class="label">{{ 'XTREAM.DIRECTOR' | translate }}</span>
|
||||
<span class="value">{{ meta.director }}</span>
|
||||
</div>
|
||||
}
|
||||
@if (meta.ratingKinopoisk) {
|
||||
<div class="details__meta-item">
|
||||
<span class="label">{{ 'XTREAM.KINOPOISK_RATING' | translate }}</span>
|
||||
<span class="value">{{ meta.ratingKinopoisk }}</span>
|
||||
</div>
|
||||
}
|
||||
</ng-container>
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<ng-container hero-actions>
|
||||
<!-- Resume/Play buttons -->
|
||||
@if (hasPlaybackPosition()) {
|
||||
<button
|
||||
class="play-btn play-btn--resume"
|
||||
(click)="onPrimaryAction()"
|
||||
[disabled]="isExternalLaunchPending()"
|
||||
[class.play-btn--state-launching]="externalPrimaryButtonState() === 'launching'"
|
||||
[class.play-btn--state-stop]="externalPrimaryButtonState() === 'stop'"
|
||||
>
|
||||
<span class="play-btn__icon-shell" aria-hidden="true">
|
||||
<mat-icon class="play-btn__icon">{{ externalPrimaryIcon() }}</mat-icon>
|
||||
</span>
|
||||
<span class="play-btn__label">
|
||||
@if (externalPrimaryLabel(); as label) {
|
||||
{{ label }}
|
||||
} @else {
|
||||
{{ 'XTREAM.RESUME' | translate }} {{ formattedPosition() }}
|
||||
}
|
||||
</span>
|
||||
</button>
|
||||
@if (!isExternalStopAction()) {
|
||||
<button class="play-btn play-btn--restart" (click)="onPlay()">
|
||||
<mat-icon>replay</mat-icon>
|
||||
{{ 'XTREAM.RESTART' | translate }}
|
||||
</button>
|
||||
}
|
||||
} @else {
|
||||
<button
|
||||
class="play-btn"
|
||||
(click)="onPrimaryAction()"
|
||||
[disabled]="isExternalLaunchPending()"
|
||||
[class.play-btn--state-launching]="externalPrimaryButtonState() === 'launching'"
|
||||
[class.play-btn--state-stop]="externalPrimaryButtonState() === 'stop'"
|
||||
>
|
||||
<span class="play-btn__icon-shell" aria-hidden="true">
|
||||
<mat-icon class="play-btn__icon">{{ externalPrimaryIcon() }}</mat-icon>
|
||||
</span>
|
||||
<span class="play-btn__label">
|
||||
@if (externalPrimaryLabel(); as label) {
|
||||
{{ label }}
|
||||
} @else {
|
||||
{{ 'XTREAM.PLAY' | translate }}
|
||||
}
|
||||
</span>
|
||||
</button>
|
||||
}
|
||||
|
||||
<!-- Favorites button -->
|
||||
@if (!isFavorite()) {
|
||||
<button class="favorite-btn" (click)="toggleFavorite()">
|
||||
<mat-icon>add</mat-icon>
|
||||
{{ 'PORTALS.ADD_TO_FAVORITES' | translate }}
|
||||
</button>
|
||||
} @else {
|
||||
<button
|
||||
class="favorite-btn favorite-btn--active"
|
||||
(click)="toggleFavorite()"
|
||||
>
|
||||
<mat-icon>check</mat-icon>
|
||||
{{ 'PORTALS.REMOVE_FROM_FAVORITES' | translate }}
|
||||
</button>
|
||||
}
|
||||
|
||||
<!-- Download buttons (Electron only) -->
|
||||
@if (isElectron()) {
|
||||
@if (isDownloaded()) {
|
||||
<button class="download-btn download-btn--completed" (click)="playFromLocal()">
|
||||
<mat-icon>play_circle</mat-icon>
|
||||
{{ 'DOWNLOADS.PLAY_LOCAL' | translate }}
|
||||
</button>
|
||||
} @else if (isDownloading()) {
|
||||
<button class="download-btn download-btn--downloading" disabled>
|
||||
<mat-icon>downloading</mat-icon>
|
||||
{{ 'DOWNLOADS.STATUS.DOWNLOADING' | translate }}
|
||||
</button>
|
||||
} @else {
|
||||
<button class="download-btn" (click)="onDownload()">
|
||||
<mat-icon>download</mat-icon>
|
||||
{{ 'DOWNLOADS.DOWNLOAD' | translate }}
|
||||
</button>
|
||||
}
|
||||
}
|
||||
</ng-container>
|
||||
|
||||
@if (inlinePlayback(); as playback) {
|
||||
<app-portal-inline-player
|
||||
[playback]="playback"
|
||||
(timeUpdate)="onInlineTimeUpdate($event)"
|
||||
(closed)="closeInlinePlayback()"
|
||||
(streamUrlCopied)="onStreamUrlCopied()"
|
||||
/>
|
||||
}
|
||||
|
||||
<!-- YouTube Trailer (Xtream only) -->
|
||||
@if (meta.youtubeTrailer) {
|
||||
<div class="youtube-trailer">
|
||||
<h3>{{ 'XTREAM.YOUTUBE_TRAILER' | translate }}</h3>
|
||||
<div class="trailer-wrapper">
|
||||
<iframe
|
||||
[src]="'https://www.youtube.com/embed/' + meta.youtubeTrailer | safe"
|
||||
title="YouTube video player"
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
|
||||
referrerpolicy="strict-origin-when-cross-origin"
|
||||
allowfullscreen
|
||||
></iframe>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</app-content-hero>
|
||||
@@ -0,0 +1,312 @@
|
||||
import {
|
||||
Component,
|
||||
computed,
|
||||
inject,
|
||||
input,
|
||||
output,
|
||||
} from '@angular/core';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
import { SafePipe } from '@iptvnator/pipes';
|
||||
import { PORTAL_EXTERNAL_PLAYBACK } from '@iptvnator/portal/shared/util';
|
||||
import { ContentHeroComponent } from 'components';
|
||||
import {
|
||||
ExternalPlayerSession,
|
||||
ResolvedPortalPlayback,
|
||||
VodDetailsItem,
|
||||
getVodNumericId,
|
||||
normalizeVodDetails,
|
||||
} from 'shared-interfaces';
|
||||
import { DownloadsService } from 'services';
|
||||
import { PortalInlinePlayerComponent } from '../portal-inline-player/portal-inline-player.component';
|
||||
|
||||
/**
|
||||
* Unified VOD details component for both Xtream and Stalker portals.
|
||||
*
|
||||
* Uses discriminated union (VodDetailsItem) for type-safe handling.
|
||||
* All actions are emitted as outputs - parent components handle
|
||||
* store-specific operations (play, favorites, downloads).
|
||||
*
|
||||
* @example
|
||||
* ```html
|
||||
* <app-vod-details
|
||||
* [item]="vodItem"
|
||||
* [isFavorite]="isFavorite()"
|
||||
* [playbackPosition]="position()"
|
||||
* (playClicked)="onPlay($event)"
|
||||
* (resumeClicked)="onResume($event)"
|
||||
* (favoriteToggled)="onToggleFavorite($event)"
|
||||
* />
|
||||
* ```
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-vod-details',
|
||||
templateUrl: './vod-details.component.html',
|
||||
styleUrls: ['../styles/detail-view.scss'],
|
||||
imports: [
|
||||
ContentHeroComponent,
|
||||
MatIcon,
|
||||
PortalInlinePlayerComponent,
|
||||
SafePipe,
|
||||
TranslatePipe,
|
||||
],
|
||||
})
|
||||
export class VodDetailsComponent {
|
||||
// ============ Inputs ============
|
||||
|
||||
/** VOD item with discriminated union type */
|
||||
readonly item = input.required<VodDetailsItem>();
|
||||
|
||||
/** Whether this item is in favorites (managed by parent) */
|
||||
readonly isFavorite = input<boolean>(false);
|
||||
|
||||
/** Playback position in seconds for resume feature (managed by parent) */
|
||||
readonly playbackPosition = input<number | null>(null);
|
||||
|
||||
/** Inline playback payload for embedded players (managed by parent) */
|
||||
readonly inlinePlayback = input<ResolvedPortalPlayback | null>(null);
|
||||
|
||||
/** Active external playback session for launch state */
|
||||
readonly externalPlayback = input<ExternalPlayerSession | null>(null);
|
||||
|
||||
// ============ Outputs ============
|
||||
|
||||
/** Emitted when play button is clicked */
|
||||
readonly playClicked = output<VodDetailsItem>();
|
||||
|
||||
/** Emitted when resume button is clicked (includes position) */
|
||||
readonly resumeClicked = output<{ item: VodDetailsItem; positionSeconds: number }>();
|
||||
|
||||
/** Emitted when favorite toggle is clicked */
|
||||
readonly favoriteToggled = output<{ item: VodDetailsItem; isFavorite: boolean }>();
|
||||
|
||||
/** Emitted when back button is clicked */
|
||||
readonly backClicked = output<void>();
|
||||
|
||||
/** Emitted when download is requested (parent handles URL construction) */
|
||||
readonly downloadRequested = output<VodDetailsItem>();
|
||||
|
||||
/** Emitted when inline playback position changes */
|
||||
readonly inlineTimeUpdated = output<{
|
||||
currentTime: number;
|
||||
duration: number;
|
||||
}>();
|
||||
|
||||
/** Emitted when the inline player should be closed */
|
||||
readonly inlinePlaybackClosed = output<void>();
|
||||
|
||||
/** Emitted when the stream url is copied */
|
||||
readonly streamUrlCopied = output<void>();
|
||||
|
||||
// ============ Services ============
|
||||
|
||||
private readonly downloadsService = inject(DownloadsService);
|
||||
private readonly externalPlaybackActions = inject(PORTAL_EXTERNAL_PLAYBACK);
|
||||
|
||||
// ============ Computed State ============
|
||||
|
||||
/** Whether running in Electron (downloads available) */
|
||||
readonly isElectron = computed(() => this.downloadsService.isAvailable());
|
||||
|
||||
/** Normalized metadata for display */
|
||||
readonly normalizedMeta = computed(() => {
|
||||
return normalizeVodDetails(this.item());
|
||||
});
|
||||
|
||||
/** Whether there's a playback position to resume from */
|
||||
readonly hasPlaybackPosition = computed(() => {
|
||||
const pos = this.playbackPosition();
|
||||
return pos !== null && pos > 0;
|
||||
});
|
||||
|
||||
/** Formatted playback position (e.g., "12:34" or "1:23:45") */
|
||||
readonly formattedPosition = computed(() => {
|
||||
const pos = this.playbackPosition();
|
||||
if (!pos || pos <= 0) return '';
|
||||
|
||||
const hours = Math.floor(pos / 3600);
|
||||
const minutes = Math.floor((pos % 3600) / 60);
|
||||
const seconds = Math.floor(pos % 60);
|
||||
|
||||
if (hours > 0) {
|
||||
return `${hours}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
|
||||
}
|
||||
return `${minutes}:${seconds.toString().padStart(2, '0')}`;
|
||||
});
|
||||
|
||||
/** Whether VOD is already downloaded */
|
||||
readonly isDownloaded = computed(() => {
|
||||
const item = this.item();
|
||||
// Access signal to create reactive dependency
|
||||
this.downloadsService.downloads();
|
||||
const vodId = getVodNumericId(item);
|
||||
return this.downloadsService.isDownloaded(vodId, item.playlistId, 'vod');
|
||||
});
|
||||
|
||||
/** Whether VOD is currently downloading */
|
||||
readonly isDownloading = computed(() => {
|
||||
const item = this.item();
|
||||
// Access signal to create reactive dependency
|
||||
this.downloadsService.downloads();
|
||||
const vodId = getVodNumericId(item);
|
||||
return this.downloadsService.isDownloading(vodId, item.playlistId, 'vod');
|
||||
});
|
||||
|
||||
readonly matchedExternalPlayback = computed(() => {
|
||||
const session = this.externalPlayback();
|
||||
const item = this.item();
|
||||
if (
|
||||
!session?.contentInfo ||
|
||||
session.status === 'closed' ||
|
||||
session.status === 'error'
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const contentInfo = session.contentInfo;
|
||||
if (
|
||||
contentInfo.playlistId !== item.playlistId ||
|
||||
contentInfo.contentType !== 'vod' ||
|
||||
contentInfo.contentXtreamId !== getVodNumericId(item)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return session;
|
||||
});
|
||||
|
||||
readonly externalPrimaryLabel = computed(() => {
|
||||
const session = this.matchedExternalPlayback();
|
||||
if (!session) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const player = session.player.toUpperCase();
|
||||
switch (session.status) {
|
||||
case 'launching':
|
||||
return `Opening in ${player}...`;
|
||||
case 'opened':
|
||||
case 'playing':
|
||||
return `Stop ${player}`;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
readonly externalPrimaryIcon = computed(() => {
|
||||
const session = this.matchedExternalPlayback();
|
||||
switch (session?.status) {
|
||||
case 'launching':
|
||||
return 'hourglass_top';
|
||||
case 'opened':
|
||||
case 'playing':
|
||||
return 'stop_circle';
|
||||
default:
|
||||
return 'play_arrow';
|
||||
}
|
||||
});
|
||||
|
||||
readonly isExternalLaunchPending = computed(
|
||||
() => this.matchedExternalPlayback()?.status === 'launching'
|
||||
);
|
||||
|
||||
readonly isExternalStopAction = computed(() => {
|
||||
const status = this.matchedExternalPlayback()?.status;
|
||||
return status === 'opened' || status === 'playing';
|
||||
});
|
||||
|
||||
readonly externalPrimaryButtonState = computed(() => {
|
||||
if (this.isExternalLaunchPending()) {
|
||||
return 'launching';
|
||||
}
|
||||
|
||||
return this.isExternalStopAction() ? 'stop' : 'idle';
|
||||
});
|
||||
|
||||
// ============ Actions ============
|
||||
|
||||
/** Handle play button click */
|
||||
onPlay(): void {
|
||||
this.playClicked.emit(this.item());
|
||||
}
|
||||
|
||||
onPrimaryAction(): void {
|
||||
if (this.isExternalStopAction()) {
|
||||
void this.stopExternalPlayback();
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.hasPlaybackPosition()) {
|
||||
this.onResume();
|
||||
return;
|
||||
}
|
||||
|
||||
this.onPlay();
|
||||
}
|
||||
|
||||
/** Handle resume button click */
|
||||
onResume(): void {
|
||||
const pos = this.playbackPosition();
|
||||
if (pos && pos > 0) {
|
||||
this.resumeClicked.emit({
|
||||
item: this.item(),
|
||||
positionSeconds: pos,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** Handle favorite toggle - emits the desired new state */
|
||||
toggleFavorite(): void {
|
||||
this.favoriteToggled.emit({
|
||||
item: this.item(),
|
||||
isFavorite: !this.isFavorite(),
|
||||
});
|
||||
}
|
||||
|
||||
/** Handle back navigation - emit event for parent to handle */
|
||||
goBack(): void {
|
||||
this.backClicked.emit();
|
||||
}
|
||||
|
||||
/** Handle download request */
|
||||
onDownload(): void {
|
||||
this.downloadRequested.emit(this.item());
|
||||
}
|
||||
|
||||
onInlineTimeUpdate(event: {
|
||||
currentTime: number;
|
||||
duration: number;
|
||||
}): void {
|
||||
this.inlineTimeUpdated.emit(event);
|
||||
}
|
||||
|
||||
closeInlinePlayback(): void {
|
||||
this.inlinePlaybackClosed.emit();
|
||||
}
|
||||
|
||||
onStreamUrlCopied(): void {
|
||||
this.streamUrlCopied.emit();
|
||||
}
|
||||
|
||||
async stopExternalPlayback(): Promise<void> {
|
||||
await this.externalPlaybackActions.closeSession(
|
||||
this.matchedExternalPlayback()
|
||||
);
|
||||
}
|
||||
|
||||
/** Play from local downloaded file */
|
||||
async playFromLocal(): Promise<void> {
|
||||
const item = this.item();
|
||||
const vodId = getVodNumericId(item);
|
||||
|
||||
const filePath = this.downloadsService.getDownloadedFilePath(
|
||||
vodId,
|
||||
item.playlistId,
|
||||
'vod'
|
||||
);
|
||||
|
||||
if (filePath) {
|
||||
await this.downloadsService.playDownload(filePath);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
@if (player === 'videojs') {
|
||||
<app-vjs-player
|
||||
[options]="vjsOptions"
|
||||
[startTime]="startTime()"
|
||||
(timeUpdate)="timeUpdate.emit($event)"
|
||||
/>
|
||||
} @else if (player === 'html5') {
|
||||
<app-html-video-player
|
||||
[channel]="$any(channel)"
|
||||
[startTime]="startTime()"
|
||||
(timeUpdate)="timeUpdate.emit($event)"
|
||||
/>
|
||||
} @else if (player === 'artplayer') {
|
||||
<app-art-player
|
||||
[channel]="$any(channel)"
|
||||
[volume]="1"
|
||||
[showCaptions]="false"
|
||||
[startTime]="startTime()"
|
||||
(timeUpdate)="timeUpdate.emit($event)"
|
||||
/>
|
||||
} @else {
|
||||
<app-vjs-player
|
||||
[options]="vjsOptions"
|
||||
[startTime]="startTime()"
|
||||
(timeUpdate)="timeUpdate.emit($event)"
|
||||
/>
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
:host {
|
||||
display: block;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
app-art-player,
|
||||
app-vjs-player,
|
||||
app-html-video-player {
|
||||
display: block;
|
||||
height: 100%;
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import {
|
||||
Component,
|
||||
EventEmitter,
|
||||
Output,
|
||||
Signal,
|
||||
ViewEncapsulation,
|
||||
effect,
|
||||
inject,
|
||||
input,
|
||||
} from '@angular/core';
|
||||
import { toSignal } from '@angular/core/rxjs-interop';
|
||||
import { StorageMap } from '@ngx-pwa/local-storage';
|
||||
import { getExtensionFromUrl } from 'm3u-utils';
|
||||
import { Settings, STORE_KEY, VideoPlayer } from 'shared-interfaces';
|
||||
import { ArtPlayerComponent } from '../art-player/art-player.component';
|
||||
import { HtmlVideoPlayerComponent } from '../html-video-player/html-video-player.component';
|
||||
import { VjsPlayerComponent } from '../vjs-player/vjs-player.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-web-player-view',
|
||||
templateUrl: './web-player-view.component.html',
|
||||
styleUrls: ['./web-player-view.component.scss'],
|
||||
imports: [ArtPlayerComponent, HtmlVideoPlayerComponent, VjsPlayerComponent],
|
||||
encapsulation: ViewEncapsulation.None,
|
||||
})
|
||||
export class WebPlayerViewComponent {
|
||||
storage = inject(StorageMap);
|
||||
|
||||
streamUrl = input.required<string>();
|
||||
startTime = input<number>(0);
|
||||
@Output() timeUpdate = new EventEmitter<{
|
||||
currentTime: number;
|
||||
duration: number;
|
||||
}>();
|
||||
|
||||
settings = toSignal(
|
||||
this.storage.get(STORE_KEY.Settings)
|
||||
) as Signal<Settings>;
|
||||
|
||||
channel!: { url: string };
|
||||
player!: VideoPlayer;
|
||||
vjsOptions!: { sources: { src: string; type: string }[] };
|
||||
|
||||
constructor() {
|
||||
effect(() => {
|
||||
this.player = this.settings()?.player ?? VideoPlayer.VideoJs;
|
||||
|
||||
this.setChannel(this.streamUrl());
|
||||
this.setVjsOptions(this.streamUrl());
|
||||
});
|
||||
}
|
||||
|
||||
setVjsOptions(streamUrl: string) {
|
||||
const extension = getExtensionFromUrl(streamUrl);
|
||||
const mimeType =
|
||||
extension === 'm3u' || extension === 'm3u8'
|
||||
? 'application/x-mpegURL'
|
||||
: extension === 'ts'
|
||||
? 'video/mp2t'
|
||||
: 'video/mp4';
|
||||
|
||||
this.vjsOptions = {
|
||||
sources: [{ src: streamUrl, type: mimeType }],
|
||||
};
|
||||
}
|
||||
|
||||
setChannel(streamUrl: string) {
|
||||
this.channel = {
|
||||
url: streamUrl,
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user