Proyecto App Linux

This commit is contained in:
2026-05-03 23:43:09 +02:00
commit 7482733843
1248 changed files with 189750 additions and 0 deletions
+13
View File
@@ -0,0 +1,13 @@
{
"name": "ui-playback",
"$schema": "../../../node_modules/nx/schemas/project-schema.json",
"sourceRoot": "libs/ui/playback/src",
"prefix": "lib",
"projectType": "library",
"tags": ["scope:shared", "domain:playback", "type:ui"],
"targets": {
"lint": {
"executor": "@nx/eslint:lint"
}
}
}
+10
View File
@@ -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: '#6366f1',
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,299 @@
// ─── Radio Player — cinematic hero layout ────────────────────────────────────
// Mirrors the content-hero/vod-details pattern: blurred backdrop, vignette,
// floating artwork + controls centred in the available space.
:host {
display: block;
width: 100%;
height: 100%;
overflow: hidden;
}
// ─── Hero container ──────────────────────────────────────────────────────────
.radio-hero {
position: relative;
width: 100%;
height: 100%;
overflow: hidden;
background: var(--mat-sys-surface);
}
// ─── Blurred backdrop (station logo, blown up) ──────────────────────────────
.backdrop {
position: absolute;
inset: 0;
background-size: cover;
background-position: center;
background-repeat: no-repeat;
filter: blur(60px) saturate(1.4) brightness(0.7);
transform: scale(1.6);
opacity: 0.55;
z-index: 0;
}
// ─── Vignette overlay (matches content-hero pattern) ─────────────────────────
.vignette {
position: absolute;
inset: 0;
z-index: 1;
pointer-events: none;
background:
radial-gradient(
ellipse 80% 70% at 50% 40%,
transparent 0%,
var(--mat-sys-surface) 100%
);
&::after {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(
to bottom,
transparent 0%,
color-mix(in srgb, var(--mat-sys-surface) 40%, transparent) 50%,
var(--mat-sys-surface) 85%
);
}
}
// ─── Floating content stage ──────────────────────────────────────────────────
.stage {
position: relative;
z-index: 2;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
gap: 20px;
padding: 32px 24px;
box-sizing: border-box;
animation: fadeUp 0.5s ease-out both;
}
// ─── Artwork card ────────────────────────────────────────────────────────────
.artwork {
max-width: 220px;
max-height: 220px;
border-radius: 18px;
overflow: hidden;
background: var(--mat-sys-surface-container);
box-shadow:
0 8px 40px rgba(0, 0, 0, 0.22),
0 2px 8px rgba(0, 0, 0, 0.12);
transition: box-shadow 0.4s ease, transform 0.4s ease;
flex-shrink: 0;
&.is-playing {
animation: artwork-pulse 3s ease-in-out infinite;
transform: translateY(-2px);
}
img {
display: block;
max-width: 220px;
max-height: 220px;
width: auto;
height: auto;
}
}
// Fallback keeps a fixed square since there's no image to dictate size
.artwork-fallback {
width: 200px;
height: 200px;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(
145deg,
var(--mat-sys-surface-container) 0%,
var(--app-selection-surface) 100%
);
mat-icon {
font-size: 64px;
width: 64px;
height: 64px;
color: var(--app-selection-color, var(--mat-sys-primary));
opacity: 0.5;
}
}
// ─── Station name ────────────────────────────────────────────────────────────
.station-name {
margin: 4px 0 0;
font-family: 'DM Sans', sans-serif;
font-size: clamp(1.15rem, 2.5vw, 1.5rem);
font-weight: 700;
letter-spacing: -0.015em;
line-height: 1.2;
color: var(--mat-sys-on-surface);
text-align: center;
max-width: 380px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
text-shadow: 0 1px 6px rgba(0, 0, 0, 0.15);
}
// ─── Live / Paused badge ─────────────────────────────────────────────────────
.station-badge {
display: inline-flex;
align-items: center;
gap: 6px;
font-family: 'DM Sans', sans-serif;
font-size: 0.65rem;
font-weight: 700;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--mat-sys-on-surface-variant);
padding: 3px 12px;
border-radius: 999px;
border: 1px solid var(--mat-sys-outline-variant);
background: color-mix(in srgb, var(--mat-sys-surface-container) 60%, transparent);
backdrop-filter: blur(6px);
&.live {
color: var(--radio-accent, var(--app-selection-color, var(--mat-sys-primary)));
border-color: color-mix(in srgb, var(--radio-accent, var(--app-selection-color)) 52%, transparent);
background: color-mix(in srgb, var(--radio-accent, var(--app-selection-color)) 14%, transparent);
}
}
.pulse {
width: 6px;
height: 6px;
border-radius: 50%;
background: currentColor;
animation: pulse-ring 1.8s ease-in-out infinite;
}
@keyframes pulse-ring {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.5; transform: scale(1.6); }
}
// ─── Transport controls ──────────────────────────────────────────────────────
.transport {
display: flex;
align-items: center;
gap: 24px;
margin-top: 4px;
}
.skip-btn {
color: var(--mat-sys-on-surface-variant);
transition: color 0.15s ease, transform 0.15s ease;
&:hover {
color: var(--mat-sys-on-surface);
transform: scale(1.08);
}
mat-icon {
font-size: 28px;
width: 28px;
height: 28px;
}
}
.play-btn {
--mdc-fab-container-color: var(--radio-accent, var(--app-selection-color, var(--mat-sys-primary)));
--mat-fab-container-color: var(--radio-accent, var(--app-selection-color, var(--mat-sys-primary)));
--mdc-fab-icon-color: #fff;
--mat-fab-foreground-color: #fff;
--mat-fab-icon-color: #fff;
--mat-fab-container-shape: 50%;
--mdc-fab-container-shape: 50%;
box-shadow: none !important;
transition: transform 0.2s ease;
&:hover {
transform: scale(1.06);
}
mat-icon {
font-size: 32px;
width: 32px;
height: 32px;
}
}
// ─── Volume row ──────────────────────────────────────────────────────────────
.volume-row {
display: flex;
align-items: center;
gap: 2px;
width: 200px;
margin-top: 4px;
}
.vol-icon {
flex-shrink: 0;
color: var(--mat-sys-on-surface-variant);
transform: scale(0.8);
}
.vol-slider {
flex: 1;
--mdc-slider-active-track-color: var(--radio-accent, var(--app-selection-color, var(--mat-sys-primary)));
--mdc-slider-handle-color: var(--radio-accent, var(--app-selection-color, var(--mat-sys-primary)));
--mat-slider-active-track-color: var(--radio-accent, var(--app-selection-color, var(--mat-sys-primary)));
--mat-slider-handle-color: var(--radio-accent, var(--app-selection-color, var(--mat-sys-primary)));
--mdc-slider-inactive-track-color: var(--mat-sys-outline-variant);
--mdc-slider-handle-width: 12px;
--mdc-slider-handle-height: 12px;
}
// ─── Entrance ────────────────────────────────────────────────────────────────
@keyframes fadeUp {
from { opacity: 0; transform: translateY(16px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes artwork-pulse {
0%, 100% {
box-shadow:
0 8px 40px rgba(0, 0, 0, 0.22),
0 0 0 1px color-mix(in srgb, var(--radio-accent, var(--app-selection-color)) 22%, transparent),
0 0 32px color-mix(in srgb, var(--radio-accent, var(--app-selection-color)) 34%, transparent);
}
50% {
box-shadow:
0 12px 56px rgba(0, 0, 0, 0.3),
0 0 0 1px color-mix(in srgb, var(--radio-accent, var(--app-selection-color)) 52%, transparent),
0 0 64px color-mix(in srgb, var(--radio-accent, var(--app-selection-color)) 34%, transparent);
}
}
// ─── Responsive ──────────────────────────────────────────────────────────────
@media only screen and (max-width: 599px) {
.artwork {
max-width: 160px;
max-height: 160px;
border-radius: 14px;
img { max-width: 160px; max-height: 160px; }
}
.artwork-fallback { width: 160px; height: 160px; }
.stage { gap: 16px; padding: 24px 16px; }
.transport { gap: 18px; }
}
@media only screen and (max-height: 480px) {
.artwork {
max-width: 120px;
max-height: 120px;
border-radius: 12px;
img { max-width: 120px; max-height: 120px; }
}
.artwork-fallback { width: 120px; height: 120px; }
.stage { gap: 10px; padding: 16px 12px; }
.station-name { font-size: 1rem; }
}
@@ -0,0 +1,246 @@
import {
Component,
DestroyRef,
ElementRef,
HostListener,
computed,
effect,
inject,
input,
signal,
viewChild,
} from '@angular/core';
import { extractDominantColor } from './extract-color';
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 class="radio-hero">
@if (displayIcon() && !logoError()) {
<div
class="backdrop"
[style.backgroundImage]="
'url(' + displayIcon() + ')'
"
></div>
}
<div class="vignette"></div>
<div class="stage">
<div
class="artwork"
[class.is-playing]="playState() === 'play'"
>
@if (displayIcon() && !logoError()) {
<img
[src]="displayIcon()"
alt=""
(error)="logoError.set(true)"
/>
} @else {
<div class="artwork-fallback">
<mat-icon>radio</mat-icon>
</div>
}
</div>
<h2 class="station-name">
{{ channelName() || 'Radio' }}
</h2>
<span class="station-badge" [class.live]="playState() === 'play'">
@if (playState() === 'play') {
<span class="pulse"></span> LIVE
} @else {
PAUSED
}
</span>
<div class="transport">
<button
mat-icon-button
class="skip-btn"
(click)="switchChannel('previous')"
>
<mat-icon>skip_previous</mat-icon>
</button>
<button
class="play-btn"
mat-fab
(click)="
playState() === 'play' ? stop() : play()
"
>
<mat-icon>{{
playState() === 'play'
? 'pause'
: 'play_arrow'
}}</mat-icon>
</button>
<button
mat-icon-button
class="skip-btn"
(click)="switchChannel('next')"
>
<mat-icon>skip_next</mat-icon>
</button>
</div>
<div class="volume-row">
<button
mat-icon-button
class="vol-icon"
(click)="mute()"
>
<mat-icon>{{ volumeIcon() }}</mat-icon>
</button>
<mat-slider
class="vol-slider"
min="0"
max="1"
step="0.05"
>
<input
matSliderThumb
[ngModel]="volume()"
(ngModelChange)="setVolume($event)"
/>
</mat-slider>
</div>
</div>
<audio preload="metadata" autoplay #audio></audio>
</div>
`,
styleUrls: ['./audio-player.component.scss'],
imports: [MatSliderModule, MatIconModule, MatButtonModule, FormsModule],
})
export class AudioPlayerComponent {
readonly icon = input<string>('');
readonly url = input.required<string>();
readonly channelName = input<string>('');
readonly playState = signal<'play' | 'paused'>('paused');
readonly volume = signal(1);
readonly isMuted = signal(false);
readonly logoError = signal(false);
readonly displayIcon = computed(() => this.icon() || null);
readonly volumeIcon = computed(() => {
const v = this.volume();
if (v === 0 || this.isMuted()) return 'volume_off';
return v < 0.5 ? 'volume_down' : 'volume_up';
});
readonly audioRef =
viewChild.required<ElementRef<HTMLAudioElement>>('audio');
private store = inject(Store);
private destroyRef = inject(DestroyRef);
private hostEl = inject(ElementRef);
private fallbackVolume = 1;
constructor() {
const saved = parseFloat(localStorage.getItem('volume') ?? '1');
if (!isNaN(saved)) {
this.volume.set(Math.max(0, Math.min(1, saved)));
}
effect(() => {
const url = this.url();
const audio = this.audioRef()?.nativeElement;
if (!audio || !url) return;
audio.src = url;
audio.volume = this.volume();
audio.load();
this.logoError.set(false);
this.play();
});
effect(() => {
const iconUrl = this.displayIcon();
this.hostEl.nativeElement.style.removeProperty('--radio-accent');
if (!iconUrl) return;
extractDominantColor(iconUrl).then((color) => {
if (color) {
this.hostEl.nativeElement.style.setProperty(
'--radio-accent',
color
);
}
});
});
this.destroyRef.onDestroy(() => {
this.audioRef()?.nativeElement?.pause();
});
}
@HostListener('document:keydown', ['$event'])
handleKeyboard(event: KeyboardEvent) {
if (
event.target instanceof HTMLInputElement ||
event.target instanceof HTMLTextAreaElement
)
return;
if (event.key === 'ArrowUp') {
event.preventDefault();
this.setVolume(this.volume() + 0.05);
} else if (event.key === 'ArrowDown') {
event.preventDefault();
this.setVolume(this.volume() - 0.05);
} else if (event.key === 'm' || event.key === 'M') {
event.preventDefault();
this.mute();
}
}
play() {
const audio = this.audioRef()?.nativeElement;
if (!audio) return;
audio.play().catch((err) => console.log(err));
this.playState.set('play');
}
stop() {
this.audioRef()?.nativeElement?.pause();
this.playState.set('paused');
}
setVolume(value: number) {
const clamped =
Math.round(Math.max(0, Math.min(1, value)) * 100) / 100;
this.volume.set(clamped);
const audio = this.audioRef()?.nativeElement;
if (audio) audio.volume = clamped;
localStorage.setItem('volume', String(clamped));
}
mute() {
const audio = this.audioRef()?.nativeElement;
if (!audio) return;
audio.muted = !audio.muted;
this.isMuted.set(audio.muted);
if (audio.muted) {
this.fallbackVolume = this.volume();
this.setVolume(0);
} else {
this.setVolume(this.fallbackVolume);
}
}
switchChannel(direction: 'next' | 'previous') {
this.store.dispatch(
ChannelActions.setAdjacentChannelAsActive({ direction })
);
}
}
@@ -0,0 +1,94 @@
/**
* Extracts the most vibrant dominant color from an image URL.
*
* Loads the image in a separate Image object with crossOrigin="anonymous"
* so the canvas isn't tainted. If the server doesn't support CORS,
* the extraction silently fails and returns null.
*
* Returns a promise that resolves to an `rgb(r, g, b)` string or `null`.
*/
export function extractDominantColor(url: string): Promise<string | null> {
return new Promise((resolve) => {
const img = new Image();
img.crossOrigin = 'anonymous';
img.onload = () => resolve(sampleColor(img));
img.onerror = () => resolve(null);
img.src = url;
});
}
function sampleColor(img: HTMLImageElement): string | null {
try {
const size = 64;
const canvas = document.createElement('canvas');
canvas.width = size;
canvas.height = size;
const ctx = canvas.getContext('2d', { willReadFrequently: true });
if (!ctx) return null;
ctx.drawImage(img, 0, 0, size, size);
const { data } = ctx.getImageData(0, 0, size, size);
// Bucket pixels into a 4x4x4 colour grid (64 buckets),
// then pick the most frequent bucket with enough saturation.
const buckets = new Map<
number,
{ r: number; g: number; b: number; count: number }
>();
for (let i = 0; i < data.length; i += 16) {
const r = data[i];
const g = data[i + 1];
const b = data[i + 2];
const a = data[i + 3];
if (a < 128) continue;
const lum = r * 0.299 + g * 0.587 + b * 0.114;
if (lum < 30 || lum > 230) continue;
const key = ((r >> 6) << 4) | ((g >> 6) << 2) | (b >> 6);
const existing = buckets.get(key);
if (existing) {
existing.r += r;
existing.g += g;
existing.b += b;
existing.count++;
} else {
buckets.set(key, { r, g, b, count: 1 });
}
}
if (buckets.size === 0) return null;
let best: { r: number; g: number; b: number; count: number } | null =
null;
let bestScore = 0;
for (const bucket of buckets.values()) {
const avg = {
r: bucket.r / bucket.count,
g: bucket.g / bucket.count,
b: bucket.b / bucket.count,
};
const max = Math.max(avg.r, avg.g, avg.b);
const min = Math.min(avg.r, avg.g, avg.b);
const saturation = max === 0 ? 0 : (max - min) / max;
const score = bucket.count * (0.3 + saturation * 0.7);
if (score > bestScore) {
bestScore = score;
best = bucket;
}
}
if (!best) return null;
const r = Math.round(best.r / best.count);
const g = Math.round(best.g / best.count);
const b = Math.round(best.b / best.count);
return `rgb(${r}, ${g}, ${b})`;
} catch {
return null;
}
}
@@ -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,28 @@
@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()"
[channelsLoading]="channelsLoading()"
[activeView]="activeView()"
[recentItems]="activePlaylist()?.recentlyViewed ?? []"
[sidebarWidth]="sidebarWidth()"
(sidebarWidthRequested)="onSidebarWidthRequested($event)"
(sidebarWidthRequestEnded)="onSidebarWidthRequestEnded($event)"
/>
@@ -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,67 @@
import { Component, computed, inject, input, output } 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 { TranslatePipe, TranslateService } from '@ngx-translate/core';
import { PlaylistSwitcherComponent } from '@iptvnator/playlist/shared/ui';
import { PlaylistContextFacade } from '@iptvnator/playlist/shared/util';
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 channelsLoading = input(false);
readonly showPlaylistHeader = input(true);
readonly activeView = input<string>('all');
readonly sidebarWidth = input<number | null>(null);
readonly sidebarWidthRequested = output<number>();
readonly sidebarWidthRequestEnded = output<number>();
private readonly playlistContext = inject(PlaylistContextFacade);
private readonly translate = inject(TranslateService);
readonly activePlaylist = this.playlistContext.activePlaylist;
readonly playlistTitle = computed(() => {
const playlist = this.activePlaylist();
return (
playlist?.title ||
playlist?.filename ||
playlist?.url ||
playlist?.portalUrl ||
'Untitled playlist'
);
});
readonly subtitle = computed(() => {
if (this.channelsLoading()) {
return this.translate.instant('CHANNELS.LOADING');
}
const count = this.channels()?.length ?? 0;
return `${count} ${this.translate.instant('HOME.PLAYLISTS.CHANNELS')}`;
});
onSidebarWidthRequested(width: number): void {
this.sidebarWidthRequested.emit(width);
}
onSidebarWidthRequestEnded(width: number): void {
this.sidebarWidthRequestEnded.emit(width);
}
}
@@ -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,49 @@
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,
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);
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,
};
}
}
+28
View File
@@ -0,0 +1,28 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"isolatedModules": true,
"target": "es2022",
"moduleResolution": "bundler",
"strict": true,
"noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"emitDecoratorMetadata": false,
"module": "preserve"
},
"angularCompilerOptions": {
"enableI18nLegacyMessageIdFormat": false,
"strictInjectionParameters": true,
"strictInputAccessModifiers": true,
"strictTemplates": true
},
"files": [],
"include": [],
"references": [
{
"path": "./tsconfig.lib.json"
}
]
}
+12
View File
@@ -0,0 +1,12 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../../dist/out-tsc",
"declaration": true,
"declarationMap": true,
"inlineSources": true,
"types": []
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.spec.ts", "src/**/*.test.ts"]
}