- @if (!isEmbeddedPlayer()) {
-
- {{
- activeDetail()?.playback?.title
- }}
-
-
- }
-
- @if (isM3uSelection()) {
-
- } @else {
-
+ @if (!isRadioSelection()) {
+
+ @if (!shouldUseInlinePlayer()) {
+
+ {{
+ activeDetail()?.playback?.title
+ }}
+
+
}
+
+ @if (isM3uSelection()) {
+
+ } @else {
+
+ }
+
-
+ }
diff --git a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.scss b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.scss
index 8acec06f2..8ca76c9e3 100644
--- a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.scss
+++ b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.scss
@@ -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;
}
-
diff --git a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.ts b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.ts
index 25219e131..3c5214ed6 100644
--- a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.ts
+++ b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.ts
@@ -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,
diff --git a/libs/portal/shared/util/src/lib/collection/stream-resolver.service.spec.ts b/libs/portal/shared/util/src/lib/collection/stream-resolver.service.spec.ts
index c0d536ea9..6c1e7938b 100644
--- a/libs/portal/shared/util/src/lib/collection/stream-resolver.service.spec.ts
+++ b/libs/portal/shared/util/src/lib/collection/stream-resolver.service.spec.ts
@@ -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
)
+ );
+
+ 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({
diff --git a/libs/portal/shared/util/src/lib/collection/stream-resolver.service.ts b/libs/portal/shared/util/src/lib/collection/stream-resolver.service.ts
index b5843ae43..de7e22c3a 100644
--- a/libs/portal/shared/util/src/lib/collection/stream-resolver.service.ts
+++ b/libs/portal/shared/util/src/lib/collection/stream-resolver.service.ts
@@ -723,7 +723,7 @@ export class StreamResolverService {
'user-agent': '',
origin: '',
},
- radio: 'false',
+ radio: item.radio ?? 'false',
epgParams: '',
};
}
diff --git a/libs/portal/shared/util/src/lib/collection/unified-collection-item.interface.ts b/libs/portal/shared/util/src/lib/collection/unified-collection-item.interface.ts
index 1c6d29d82..4966a87b8 100644
--- a/libs/portal/shared/util/src/lib/collection/unified-collection-item.interface.ts
+++ b/libs/portal/shared/util/src/lib/collection/unified-collection-item.interface.ts
@@ -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;
diff --git a/libs/portal/shared/util/src/lib/collection/unified-favorites-data.service.spec.ts b/libs/portal/shared/util/src/lib/collection/unified-favorites-data.service.spec.ts
index a4f2e329c..73210e424 100644
--- a/libs/portal/shared/util/src/lib/collection/unified-favorites-data.service.spec.ts
+++ b/libs/portal/shared/util/src/lib/collection/unified-favorites-data.service.spec.ts
@@ -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 () => {
diff --git a/libs/portal/shared/util/src/lib/collection/unified-favorites-data.service.ts b/libs/portal/shared/util/src/lib/collection/unified-favorites-data.service.ts
index 9f4a5519c..445bc293d 100644
--- a/libs/portal/shared/util/src/lib/collection/unified-favorites-data.service.ts
+++ b/libs/portal/shared/util/src/lib/collection/unified-favorites-data.service.ts
@@ -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,
diff --git a/libs/portal/shared/util/src/lib/collection/unified-live-tab.component.spec.ts b/libs/portal/shared/util/src/lib/collection/unified-live-tab.component.spec.ts
index 57c59b5d9..6fc34f7dd 100644
--- a/libs/portal/shared/util/src/lib/collection/unified-live-tab.component.spec.ts
+++ b/libs/portal/shared/util/src/lib/collection/unified-live-tab.component.spec.ts
@@ -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: '',
+ changeDetection: ChangeDetectionStrategy.OnPush,
+})
+class StubAudioPlayerComponent {
+ readonly icon = input('');
+ readonly url = input.required();
+ readonly channelName = input('');
+}
+
describe('UnifiedLiveTabComponent', () => {
let fixture: ComponentFixture;
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',
};
}
diff --git a/libs/portal/shared/util/src/lib/collection/unified-recent-data.service.spec.ts b/libs/portal/shared/util/src/lib/collection/unified-recent-data.service.spec.ts
index 45c983921..52e6319b5 100644
--- a/libs/portal/shared/util/src/lib/collection/unified-recent-data.service.spec.ts
+++ b/libs/portal/shared/util/src/lib/collection/unified-recent-data.service.spec.ts
@@ -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',
});
});
diff --git a/libs/portal/shared/util/src/lib/collection/unified-recent-data.service.ts b/libs/portal/shared/util/src/lib/collection/unified-recent-data.service.ts
index 8bc8c39ae..47d190158 100644
--- a/libs/portal/shared/util/src/lib/collection/unified-recent-data.service.ts
+++ b/libs/portal/shared/util/src/lib/collection/unified-recent-data.service.ts
@@ -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 ||
diff --git a/libs/ui/playback/src/lib/audio-player/audio-player.component.scss b/libs/ui/playback/src/lib/audio-player/audio-player.component.scss
index 049e98d9e..26789c8bd 100644
--- a/libs/ui/playback/src/lib/audio-player/audio-player.component.scss
+++ b/libs/ui/playback/src/lib/audio-player/audio-player.component.scss
@@ -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; }
}
diff --git a/libs/ui/playback/src/lib/audio-player/audio-player.component.ts b/libs/ui/playback/src/lib/audio-player/audio-player.component.ts
index 77a278219..15c3f89df 100644
--- a/libs/ui/playback/src/lib/audio-player/audio-player.component.ts
+++ b/libs/ui/playback/src/lib/audio-player/audio-player.component.ts
@@ -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: `
-
-
-
![radio icon]()
-
-
-
-
+
+ @if (displayIcon() && !logoError()) {
+
+ }
+
- @if (playState === 'paused') {
-
- }
- @if (playState === 'play') {
-
- }
-
-
-
- @if (playState === 'play') {
-
-
-
-
-
- }
- @if (playState === 'paused') {
-
-
-
-
-
- }
-
-
-
-
`,
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
('');
+ readonly url = input.required();
+ readonly channelName = input('');
- 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;
+ readonly audioRef =
+ viewChild.required>('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 })
+ );
}
}