diff --git a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.html b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.html index 83b04d635..54aeee093 100644 --- a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.html +++ b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.html @@ -27,12 +27,13 @@
@if (activeChannel(); as activeChannel) { - @if (shouldShowInlinePlayer(activeChannel)) { + @if (activeChannel.radio === 'true' || shouldShowInlinePlayer(activeChannel)) {
@if (activeChannel.radio === 'true') { } @else { @if (playerSettings.player === 'videojs') { @@ -69,11 +70,13 @@
} -
-
- + @if (activeChannel.radio !== 'true') { +
+
+ +
-
+ } @if (showChannelNumberOverlay) {
diff --git a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.scss b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.scss index 924b1303d..b73dac0a6 100644 --- a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.scss +++ b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.scss @@ -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, diff --git a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.html b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.html index ef62a8bef..59369bd04 100644 --- a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.html +++ b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.html @@ -1,9 +1,5 @@ @if (activeDetail()) { -
+
- @if (isEmbeddedPlayer()) { -
-
- {{ - activeDetail()?.playback?.title - }} + @if (shouldUseInlinePlayer()) { +
+ @if (isRadioSelection()) { -
- @if (player() === 'videojs') { - - } - @if (player() === 'html5') { - - } - @if (player() === 'artplayer') { - + } @else { +
+ {{ + activeDetail()?.playback?.title + }} + +
+ + @if (player() === 'videojs') { + + } + @if (player() === 'html5') { + + } + @if (player() === 'artplayer') { + + } }
} -
- @if (!isEmbeddedPlayer()) { -
- {{ - activeDetail()?.playback?.title - }} - -
- } -
- @if (isM3uSelection()) { - - } @else { - + @if (!isRadioSelection()) { +
+ @if (!shouldUseInlinePlayer()) { +
+ {{ + activeDetail()?.playback?.title + }} + +
} +
+ @if (isM3uSelection()) { + + } @else { + + } +
-
+ }
} @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: ` -
- - -
- +
+ @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 }) + ); } }