feat: enhance audio player and video player for radio support

- Updated video player component to conditionally render audio player for radio channels.
- Added radio-specific styles to video player and unified live tab components.
- Introduced new layout classes for radio and external players.
- Enhanced audio player component with improved UI and volume controls.
- Implemented logic to preserve radio flag in M3U playback details.
- Updated tests to cover new radio functionality and ensure proper rendering.
This commit is contained in:
4gray committed 2026-04-06 22:45:46 +02:00
1 parent 595213d3dc
commit 03eadb2ba6
15 files changed
+703 -264

No files matched your search

@@ -27,12 +27,13 @@
<div class="content-container">
@if (activeChannel(); as activeChannel) {
@if (shouldShowInlinePlayer(activeChannel)) {
@if (activeChannel.radio === 'true' || shouldShowInlinePlayer(activeChannel)) {
<div class="video-player">
@if (activeChannel.radio === 'true') {
<app-audio-player
[url]="activeChannel.url"
[icon]="activeChannel?.tvg?.logo!"
[channelName]="activeChannel.name || activeChannel?.tvg?.name || ''"
/>
} @else {
@if (playerSettings.player === 'videojs') {
@@ -69,11 +70,13 @@
</div>
}
<div class="epg">
<div class="epg-content">
<app-epg-list />
@if (activeChannel.radio !== 'true') {
<div class="epg">
<div class="epg-content">
<app-epg-list />
</div>
</div>
</div>
}
@if (showChannelNumberOverlay) {
<div class="channel-number-overlay">
@@ -23,6 +23,10 @@
.video-player {
background: #000;
&:has(> app-audio-player) {
background: var(--mat-sys-surface);
}
> app-audio-player,
> app-vjs-player,
> app-html-video-player,
@@ -1,9 +1,5 @@
@if (activeDetail()) {
<div
[class]="
isEmbeddedPlayer() ? 'itv-container' : 'itv-container--external'
"
>
<div [class]="layoutClass()">
<div
class="sidebar"
appResizable
@@ -25,77 +21,104 @@
/>
</div>
@if (isEmbeddedPlayer()) {
<div class="video-player">
<div class="player-toolbar">
<span class="player-title">{{
activeDetail()?.playback?.title
}}</span>
@if (shouldUseInlinePlayer()) {
<div class="video-player" [class.video-player--radio]="isRadioSelection()">
@if (isRadioSelection()) {
<button
mat-icon-button
(click)="onClose()"
class="close-btn"
class="radio-close-btn"
aria-label="Close player"
>
<mat-icon>close</mat-icon>
</button>
</div>
@if (player() === 'videojs') {
<app-vjs-player
[options]="{
sources: [
{
src: currentStreamUrl(),
type: 'application/x-mpegURL',
},
],
}"
[volume]="1"
/>
}
@if (player() === 'html5') {
<app-html-video-player
[channel]="activeChannelForOverlay()!"
[volume]="1"
[showCaptions]="false"
/>
}
@if (player() === 'artplayer') {
<app-art-player
[channel]="activeChannelForOverlay()!"
[volume]="1"
[showCaptions]="false"
<app-audio-player
[url]="currentStreamUrl()"
[icon]="
activeRadioChannel()?.tvg?.logo ??
activeDetail()?.playback?.thumbnail ??
''
"
[channelName]="
activeRadioChannel()?.name ||
activeDetail()?.playback?.title ||
''
"
/>
} @else {
<div class="player-toolbar">
<span class="player-title">{{
activeDetail()?.playback?.title
}}</span>
<button
mat-icon-button
(click)="onClose()"
class="close-btn"
>
<mat-icon>close</mat-icon>
</button>
</div>
@if (player() === 'videojs') {
<app-vjs-player
[options]="{
sources: [
{
src: currentStreamUrl(),
type: 'application/x-mpegURL',
},
],
}"
[volume]="1"
/>
}
@if (player() === 'html5') {
<app-html-video-player
[channel]="activeChannelForOverlay()!"
[volume]="1"
[showCaptions]="false"
/>
}
@if (player() === 'artplayer') {
<app-art-player
[channel]="activeChannelForOverlay()!"
[volume]="1"
[showCaptions]="false"
/>
}
}
</div>
}
<div class="epg">
@if (!isEmbeddedPlayer()) {
<div class="player-toolbar">
<span class="player-title">{{
activeDetail()?.playback?.title
}}</span>
<button
mat-icon-button
(click)="onClose()"
class="close-btn"
>
<mat-icon>close</mat-icon>
</button>
</div>
}
<div class="epg-content">
@if (isM3uSelection()) {
<app-epg-list
[controlledChannel]="currentM3uChannel()"
[controlledPrograms]="currentM3uPrograms()"
/>
} @else {
<app-epg-view [epgItems]="currentPortalEpgItems()" />
@if (!isRadioSelection()) {
<div class="epg">
@if (!shouldUseInlinePlayer()) {
<div class="player-toolbar">
<span class="player-title">{{
activeDetail()?.playback?.title
}}</span>
<button
mat-icon-button
(click)="onClose()"
class="close-btn"
>
<mat-icon>close</mat-icon>
</button>
</div>
}
<div class="epg-content">
@if (isM3uSelection()) {
<app-epg-list
[controlledChannel]="currentM3uChannel()"
[controlledPrograms]="currentM3uPrograms()"
/>
} @else {
<app-epg-view [epgItems]="currentPortalEpgItems()" />
}
</div>
</div>
</div>
}
</div>
} @else {
<div class="full-list">
@@ -16,6 +16,12 @@
height: 100%;
}
.itv-container--radio {
grid-template-columns: auto minmax(0, 1fr);
grid-template-rows: minmax(0, 1fr);
grid-template-areas: 'sidebar video-player';
}
.itv-container--external {
display: grid;
grid-template-columns: auto 1fr;
@@ -57,6 +63,11 @@
flex-direction: column;
}
.video-player--radio {
position: relative;
background: var(--mat-sys-surface);
}
.player-toolbar {
display: flex;
align-items: center;
@@ -83,9 +94,24 @@
flex-shrink: 0;
}
.radio-close-btn {
position: absolute;
top: 16px;
right: 16px;
z-index: 4;
color: var(--mat-sys-on-surface);
background: color-mix(
in srgb,
var(--mat-sys-surface-container-high) 72%,
transparent
);
backdrop-filter: blur(12px);
}
.video-player > app-vjs-player,
.video-player > app-html-video-player,
.video-player > app-art-player {
.video-player > app-art-player,
.video-player > app-audio-player {
flex: 1;
min-height: 0;
display: block;
@@ -112,4 +138,3 @@
flex: 1;
min-height: 0;
}
@@ -25,6 +25,7 @@ import {
import { EpgListComponent } from '@iptvnator/ui/epg';
import { GlobalFavoritesListComponent } from '../global-favorites-list/global-favorites-list.component';
import {
AudioPlayerComponent,
ArtPlayerComponent,
HtmlVideoPlayerComponent,
VjsPlayerComponent,
@@ -40,6 +41,7 @@ import { EpgViewComponent } from 'shared-portals';
styleUrl: './unified-live-tab.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
AudioPlayerComponent,
ArtPlayerComponent,
EpgListComponent,
EpgViewComponent,
@@ -104,6 +106,23 @@ export class UnifiedLiveTabComponent {
return detail.channel ?? null;
});
readonly activeRadioChannel = computed(() => {
const channel = this.currentM3uChannel();
return channel?.radio === 'true' ? channel : null;
});
readonly isRadioSelection = computed(() => this.activeRadioChannel() !== null);
readonly shouldUseInlinePlayer = computed(() => {
return this.isRadioSelection() || this.isEmbeddedPlayer();
});
readonly layoutClass = computed(() => {
if (this.isRadioSelection()) {
return 'itv-container itv-container--radio';
}
return this.shouldUseInlinePlayer()
? 'itv-container'
: 'itv-container--external';
});
readonly activeChannelForOverlay = computed((): Channel | undefined => {
const detail = this.activeDetail();
@@ -276,7 +295,7 @@ export class UnifiedLiveTabComponent {
void this.hydrateSelectedM3uPrograms(item, detail, requestId);
}
if (!this.portalPlayer.isEmbeddedPlayer()) {
if (this.shouldOpenExternalPlayback(detail)) {
void this.portalPlayer.openResolvedPlayback(detail.playback);
}
@@ -305,6 +324,18 @@ export class UnifiedLiveTabComponent {
}
}
private shouldOpenExternalPlayback(
detail: ResolvedLiveCollectionDetail
): boolean {
return !this.isRadioDetail(detail) && !this.portalPlayer.isEmbeddedPlayer();
}
private isRadioDetail(
detail: ResolvedLiveCollectionDetail | null | undefined
): boolean {
return detail?.epgMode === 'm3u' && detail.channel?.radio === 'true';
}
private async hydrateSelectedM3uPrograms(
item: UnifiedCollectionItem,
detail: ResolvedLiveCollectionDetail,
@@ -176,6 +176,37 @@ describe('StreamResolverService', () => {
expect(window.electron.getChannelPrograms).not.toHaveBeenCalled();
});
it('preserves the radio flag when M3U playback falls back to item metadata', async () => {
playlistsService.getPlaylistById.mockReturnValue(
of({
_id: 'm3u-1',
playlist: {
items: [],
},
} satisfies Partial<Playlist>)
);
const detail = await service.resolveM3uPlaybackDetail({
uid: 'm3u::m3u-1::radio-channel',
name: 'Radio Channel',
contentType: 'live',
sourceType: 'm3u',
playlistId: 'm3u-1',
playlistName: 'M3U List',
streamUrl: 'https://example.com/radio.m3u8',
channelId: 'radio-channel',
tvgId: 'radio-id',
logo: 'radio.png',
radio: 'true',
} satisfies UnifiedCollectionItem);
expect(detail.channel).toMatchObject({
id: 'radio-channel',
url: 'https://example.com/radio.m3u8',
radio: 'true',
});
});
it('returns Xtream live detail with shared EPG items', async () => {
playlistsService.getPlaylistById.mockReturnValue(
of({
@@ -723,7 +723,7 @@ export class StreamResolverService {
'user-agent': '',
origin: '',
},
radio: 'false',
radio: item.radio ?? 'false',
epgParams: '',
};
}
@@ -42,6 +42,9 @@ export interface UnifiedCollectionItem {
/** M3U playlist channel id used to rehydrate the full channel object */
channelId?: string;
/** M3U radio flag used to preserve the dedicated radio player layout */
radio?: string;
/** Xtream numeric stream ID */
xtreamId?: number;
@@ -54,7 +54,7 @@ describe('UnifiedFavoritesDataService', () => {
rec: '',
},
http: { referrer: '', 'user-agent': '', origin: '' },
radio: 'false',
radio: 'true',
epgParams: '',
},
];
@@ -229,6 +229,7 @@ describe('UnifiedFavoritesDataService', () => {
'https://example.com/1.m3u8',
]);
expect(items[1].channelId).toBe('channel-1');
expect(items[0].radio).toBe('true');
});
it('persists M3U playlist reorders through setFavorites', async () => {
@@ -238,6 +238,7 @@ export class UnifiedFavoritesDataService {
logo: channel.tvg?.logo ?? null,
streamUrl: channel.url,
channelId: channel.id,
radio: channel.radio,
tvgId: channel.tvg?.id || channel.tvg?.name || channel.name,
addedAt: new Date(0).toISOString(),
position: index,
@@ -12,6 +12,7 @@ import {
UnifiedLiveTabComponent,
} from '@iptvnator/portal/shared/ui';
import {
AudioPlayerComponent,
ArtPlayerComponent,
HtmlVideoPlayerComponent,
VjsPlayerComponent,
@@ -108,6 +109,17 @@ class StubArtPlayerComponent {
readonly showCaptions = input(false);
}
@Component({
selector: 'app-audio-player',
template: '<div class="stub-audio-player"></div>',
changeDetection: ChangeDetectionStrategy.OnPush,
})
class StubAudioPlayerComponent {
readonly icon = input('');
readonly url = input.required<string>();
readonly channelName = input('');
}
describe('UnifiedLiveTabComponent', () => {
let fixture: ComponentFixture<UnifiedLiveTabComponent>;
let component: UnifiedLiveTabComponent;
@@ -157,6 +169,7 @@ describe('UnifiedLiveTabComponent', () => {
.overrideComponent(UnifiedLiveTabComponent, {
remove: {
imports: [
AudioPlayerComponent,
ArtPlayerComponent,
EpgListComponent,
EpgViewComponent,
@@ -168,6 +181,7 @@ describe('UnifiedLiveTabComponent', () => {
},
add: {
imports: [
StubAudioPlayerComponent,
StubArtPlayerComponent,
StubEpgListComponent,
StubEpgViewComponent,
@@ -296,6 +310,59 @@ describe('UnifiedLiveTabComponent', () => {
await fixture.whenStable();
});
it('renders inline audio for M3U radio items and skips external playback', async () => {
const item = {
...buildLiveItem('m3u'),
radio: 'true',
} satisfies UnifiedCollectionItem;
streamResolver.resolveM3uPlaybackDetail.mockResolvedValue({
epgMode: 'm3u',
playback: {
streamUrl: 'https://example.com/radio.m3u8',
title: 'M3U Radio',
thumbnail: 'radio.png',
},
channel: {
id: 'm3u-channel',
name: 'M3U Radio',
url: 'https://example.com/radio.m3u8',
group: { title: 'Radio' },
tvg: {
id: 'm3u-channel',
name: 'M3U Radio',
url: '',
logo: 'radio.png',
rec: '',
},
http: { referrer: '', 'user-agent': '', origin: '' },
radio: 'true',
epgParams: '',
},
epgPrograms: [],
});
recentData.recordLivePlayback.mockResolvedValue({
...item,
viewedAt: '2026-03-26T12:00:00.000Z',
});
fixture.componentRef.setInput('items', [item]);
fixture.componentRef.setInput('mode', 'recent');
fixture.detectChanges();
await fixture.whenStable();
await component.onChannelSelected(component.channelsForList()[0]);
fixture.detectChanges();
await fixture.whenStable();
expect(recentData.recordLivePlayback).toHaveBeenCalledWith(item);
expect(portalPlayer.openResolvedPlayback).not.toHaveBeenCalled();
expect(
fixture.nativeElement.querySelector('app-audio-player')
).not.toBeNull();
expect(fixture.nativeElement.querySelector('app-epg-list')).toBeNull();
expect(fixture.nativeElement.querySelector('app-epg-view')).toBeNull();
});
it('renders shared EPG view for Xtream items and records recent history', async () => {
const item = buildLiveItem('xtream');
streamResolver.resolveLiveDetail.mockResolvedValue({
@@ -434,6 +501,7 @@ function buildLiveItem(
channelId: 'm3u-channel',
tvgId: 'm3u-channel',
logo: 'm3u.png',
radio: 'false',
};
}
@@ -79,7 +79,7 @@ describe('UnifiedRecentDataService', () => {
rec: '',
},
http: { referrer: '', 'user-agent': '', origin: '' },
radio: 'false',
radio: 'true',
epgParams: '',
},
];
@@ -148,6 +148,7 @@ describe('UnifiedRecentDataService', () => {
name: 'Recent Channel',
tvgId: 'recent-two',
logo: 'two.png',
radio: 'true',
});
});
@@ -390,6 +390,7 @@ export class UnifiedRecentDataService {
logo: channel.tvg?.logo ?? recentItem.poster_url ?? null,
streamUrl: channel.url,
channelId: channel.id,
radio: channel.radio,
tvgId:
channel.tvg?.id ||
recentItem.tvg_id ||
@@ -1,123 +1,271 @@
// ─── 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: flex;
justify-content: center;
margin: 20px 0;
display: block;
width: 100%;
height: 100%;
overflow: hidden;
}
#audio-player {
// ─── 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;
gap: 10px;
align-items: center;
}
.radio-logo {
border-radius: 14px;
height: 300px;
width: 300px;
display: flex;
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 {
width: 200px;
height: 200px;
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 {
box-shadow:
0 12px 56px rgba(0, 0, 0, 0.28),
0 0 0 1px var(--app-selection-surface),
0 0 48px var(--app-selection-glow);
transform: translateY(-2px);
}
img {
max-width: 300px;
max-height: 300px;
}
}
.volume-panel {
display: flex;
align-self: center;
align-items: center;
input {
flex: 1;
}
}
.controls {
height: 100px;
align-items: center;
display: flex;
gap: 8px;
justify-content: center;
}
.playing {
width: 36px;
height: 36px;
border-radius: 0.3rem;
display: flex;
justify-content: space-between;
align-items: flex-end;
padding: 0.5rem;
box-sizing: border-box;
}
.playing-bar {
display: inline-block;
background: white;
width: 30%;
height: 100%;
animation: up-and-down 1.3s ease infinite alternate;
}
.playing-bar-stopped {
display: inline-block;
background: white;
width: 30%;
height: 100%;
}
.playing-bar1 {
height: 60%;
}
.playing-bar2 {
height: 30%;
animation-delay: -2.2s;
}
.playing-bar3 {
height: 75%;
animation-delay: -3.7s;
}
@keyframes up-and-down {
10% {
height: 30%;
}
30% {
width: 100%;
height: 100%;
}
60% {
height: 50%;
}
80% {
height: 75%;
}
100% {
height: 60%;
object-fit: cover;
display: block;
}
}
.icon-button-large {
transform: scale(2);
margin: 0 20px;
.artwork-fallback {
width: 100%;
height: 100%;
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(--app-selection-color, var(--mat-sys-primary));
border-color: var(--app-selection-border);
background: var(--app-selection-surface);
}
}
.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(--app-selection-color, var(--mat-sys-primary));
--mdc-fab-icon-color: #fff;
--mat-fab-foreground-color: #fff;
transition: transform 0.2s ease, box-shadow 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(--app-selection-color, var(--mat-sys-primary));
--mdc-slider-handle-color: 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); }
}
// ─── Responsive ──────────────────────────────────────────────────────────────
@media only screen and (max-width: 599px) {
.radio-logo {
height: 200px;
width: 200px;
img {
max-width: 200px;
max-height: 200px;
}
.artwork {
width: 160px;
height: 160px;
border-radius: 14px;
}
.stage { gap: 16px; padding: 24px 16px; }
.transport { gap: 18px; }
}
@media only screen and (max-height: 480px) {
.artwork {
width: 120px;
height: 120px;
border-radius: 12px;
}
.stage { gap: 10px; padding: 16px 12px; }
.station-name { font-size: 1rem; }
}
@@ -1,11 +1,14 @@
import {
Component,
DestroyRef,
ElementRef,
HostListener,
computed,
effect,
inject,
Input,
OnChanges,
SimpleChanges,
ViewChild,
input,
signal,
viewChild,
} from '@angular/core';
import { FormsModule } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
@@ -17,114 +20,210 @@ import { ChannelActions } from 'm3u-state';
@Component({
selector: 'app-audio-player',
template: `
<div id="audio-player">
<div class="radio-logo">
<img [src]="icon" alt="radio icon" />
</div>
<audio preload="metadata" autoplay #audio>
<source [src]="url" />
</audio>
<div class="controls">
<button mat-icon-button (click)="switchChannel('previous')">
<mat-icon>skip_previous</mat-icon>
</button>
<div class="radio-hero">
@if (displayIcon() && !logoError()) {
<div
class="backdrop"
[style.backgroundImage]="
'url(' + displayIcon() + ')'
"
></div>
}
<div class="vignette"></div>
@if (playState === 'paused') {
<button
class="icon-button-large"
mat-icon-button
(click)="play()"
>
<mat-icon>play_arrow</mat-icon>
</button>
}
@if (playState === 'play') {
<button
class="icon-button-large"
mat-icon-button
(click)="stop()"
>
<mat-icon>pause</mat-icon>
</button>
}
<button mat-icon-button (click)="switchChannel('next')">
<mat-icon>skip_next</mat-icon>
</button>
</div>
<div class="volume-panel">
@if (playState === 'play') {
<div class="playing">
<span class="playing-bar playing-bar1"></span>
<span class="playing-bar playing-bar2"></span>
<span class="playing-bar playing-bar3"></span>
</div>
}
@if (playState === 'paused') {
<div class="playing">
<span class="playing-bar-stopped playing-bar1"></span>
<span class="playing-bar-stopped playing-bar2"></span>
<span class="playing-bar-stopped playing-bar3"></span>
</div>
}
<mat-slider min="0" max="1" step="0.1" color="accent">
<input matSliderThumb [(ngModel)]="audio.volume" />
</mat-slider>
<button mat-icon-button (click)="mute()">
@if (audio.volume > 0 && !audio.muted) {
<mat-icon>volume_up</mat-icon>
<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>
}
@if (audio.volume === 0 || audio.muted) {
<mat-icon>volume_off</mat-icon>
</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
}
</button>
</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 implements OnChanges {
@Input() icon!: string;
@Input({ required: true }) url!: string;
export class AudioPlayerComponent {
readonly icon = input<string>('');
readonly url = input.required<string>();
readonly channelName = input<string>('');
playState: 'play' | 'paused' = 'paused';
readonly playState = signal<'play' | 'paused'>('paused');
readonly volume = signal(1);
readonly isMuted = signal(false);
readonly logoError = signal(false);
fallbackVolume!: number;
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';
});
@ViewChild('audio', { static: true }) audio!: ElementRef<HTMLAudioElement>;
readonly audioRef =
viewChild.required<ElementRef<HTMLAudioElement>>('audio');
private store = inject(Store);
private destroyRef = inject(DestroyRef);
private fallbackVolume = 1;
ngOnChanges(changes: SimpleChanges): void {
this.audio.nativeElement.src = changes['url'].currentValue;
this.audio.nativeElement.load();
this.play();
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();
});
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 playPromise = this.audio.nativeElement.play();
if (playPromise !== undefined) {
playPromise.catch((error) => {
console.log(error);
});
}
this.playState = 'play';
const audio = this.audioRef()?.nativeElement;
if (!audio) return;
audio.play().catch((err) => console.log(err));
this.playState.set('play');
}
stop() {
this.audio.nativeElement.pause();
this.playState = 'paused';
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() {
this.audio.nativeElement.muted = !this.audio.nativeElement.muted;
if (this.audio.nativeElement.muted) {
this.fallbackVolume = this.audio.nativeElement.volume;
this.audio.nativeElement.volume = 0;
} else this.audio.nativeElement.volume = this.fallbackVolume;
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 }));
this.store.dispatch(
ChannelActions.setAdjacentChannelAsActive({ direction })
);
}
}