Proyecto App Linux
This commit is contained in:
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
+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,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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
Reference in New Issue
Block a user