mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-10 18:36:15 -08:00
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:
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,
|
||||
|
||||
+85
-62
@@ -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">
|
||||
|
||||
+27
-2
@@ -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;
|
||||
}
|
||||
|
||||
+32
-1
@@ -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 })
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user