feat: auth screen with auto-redirect, sync favorites/history with backend

This commit is contained in:
nk
2026-06-02 19:12:07 +03:00
parent d4adb1e7be
commit a83672b455
2934 changed files with 97351 additions and 163 deletions

102
docs/superpowers/memory.md Normal file
View File

@@ -0,0 +1,102 @@
# radiOLA — Project Memory
## Overview
Android online radio app combining **Radio Record** (Russian dance radio network) with **Radio Browser API** (`radio-browser.info`) for genre filtering across thousands of internet radio stations.
**Stack:** Kotlin 2.0, Compose BOM 2024.06, Material3, Hilt 2.51.1, Media3 ExoPlayer 1.3.1, Room 2.6.1, Retrofit + Kotlinx Serialization, Coil 2.6.0, DataStore, Lucide icons.
---
## Architecture
Clean Architecture with these layers:
- `domain/` — models (`Station`, `Track`, `DeeplinkService`), repository interfaces, use cases
- `data/remote/` — Retrofit APIs (`RecordApi`, `RadioBrowserApi`), DTOs, mappers
- `data/local/` — Room (`AppDatabase` v2), DAOs (`StationDao`, `TagDao`, `TrackHistoryDao`)
- `data/repository/` — impls (`StationRepositoryImpl`, `RadioBrowserRepositoryImpl`, etc.)
- `di/` — Hilt `AppModule` (two Retrofit instances: Record + Radio Browser)
- `ui/` — Compose screens (`StationsScreen`, `HistoryScreen`, `SettingsScreen`, `PlayerBottomSheet`)
- `service/``PlayerController` (ExoPlayer singleton), `PlayerService` (`MediaSessionService`)
- `widget/``PlayerWidgetProvider` (4×1 home screen widget)
---
## Current State
### ✅ Working
- **Radio Record stations** load from `https://www.radiorecord.ru/api/stations/` → cached in Room
- **Stations grid** (`LazyVerticalGrid`, 2 columns) with cover, name, favorite heart
- **Playback** — real stream URLs via ExoPlayer, background service, notification, lock screen
- **Favorites** — Room toggle, context-aware next/prev (all vs favorites only)
- **History** — auto-saves tracks to `TrackHistoryRepository` on every `_currentTrack` change
- **Audio devices** — pauses on BT/wired/USB removal, resumes on reconnect
- **Deeplink buttons** — search current track in Yandex, Spotify, etc.
- **Genre chips** — `FilterChips` row with music genres only
- **Radio Browser integration** — loads stations by tag when VPN is active
- **Icy metadata parsing** for Radio Browser streams (`Artist - Song` format)
### ⚠️ Known Issues / Limitations
1. **Radio Browser API blocked** by some ISPs in Russia (IP `91.98.4.78` times out). App detects this and disables Radio Browser features gracefully. **Workaround: use VPN**.
2. **Radio Record API** does not return per-station tags. Tags are global (`StationsResult.tags`). We map them to Room `TagEntity` table via `TagDao`.
3. **Record station-to-genre mapping** is approximate — uses keyword matching on `tooltip`/`genre` field because API lacks structured genre data per station.
4. **Widget** uses `RemoteViews` with limited Compose support; `widget_player.xml` avoids AppCompat attrs.
5. **Deprecated `Divider`** in `HistoryScreen.kt:60` — should be `HorizontalDivider`.
6. **KSP `NonExistentClass`** — clean build required if Hilt constructor params change.
---
## Key Data Flows
### Station Loading
1. `StationsViewModel.init``refreshStationsUseCase()``StationRepositoryImpl.refreshStations()`
2. `RecordApi.getStations()` returns `StationsResponse(result = StationsResult(stations, tags))`
3. `StationDao.insertAll()` + `TagDao.clearAll()` + `TagDao.insertAll()`
4. `StationsViewModel.tags` combines `stationRepository.getTags()` + `_radioBrowserTags`
### Playback
1. `PlayerViewModel.play(station)``PlayerController.play(url, prefix, name)`
2. **Record source**`getNowPlayingUseCase(id)` polls `/api/stations/now/` every 10s
3. **Radio Browser source** → listens to `PlayerController.icyTitle` (ExoPlayer `onMediaMetadataChanged`)
4. `_currentTrack` updates → auto-saved to history + lock screen metadata via `updateMetadata()`
### Genre Filtering
- `tag == null` → show all Record stations
- `tag != null` → show Radio Browser stations for tag + Record stations whose `genre`/`name` matches keywords (`tagKeywords` map in `StationsViewModel`)
---
## Critical Code Rules
### ExoPlayer Lifecycle
`PlayerController` is `@Singleton`. **`PlayerService.onDestroy()` must NOT call `playerController.release()`** — releasing ExoPlayer kills its internal thread. Subsequent `play()` calls throw `IllegalStateException: dead thread` after OS restarts the service.
### Room Migrations
`AppDatabase` is version 2. `MIGRATION_1_2` creates `tags` table. Always add migration + bump version when adding entities.
### Radio Browser Null-Safety
`RadioBrowserStationDto` fields are nullable (`urlResolved`, `tags`, `country`, `codec`). Mapper falls back: `streamUrl = urlResolved ?: url ?: ""`.
---
## File Inventory
| File | Responsibility |
|------|----------------|
| `di/AppModule.kt` | Two Retrofit instances, Room DB with migration, repository bindings |
| `data/repository/StationRepositoryImpl.kt` | Fetch Record API, cache stations + tags in Room |
| `data/repository/RadioBrowserRepositoryImpl.kt` | Fetch Radio Browser stations/tags, swallow network errors |
| `ui/stations/StationsViewModel.kt` | Genre filtering, tag loading, search, Radio Browser availability flag |
| `ui/stations/StationsScreen.kt` | Grid, chips, search bar, empty state with "Show All" button |
| `service/PlayerController.kt` | ExoPlayer, audio device callback, Icy title capture, MediaSession wrapper |
| `service/PlayerService.kt` | Background service, MediaSession, notification, lock screen |
| `ui/player/PlayerViewModel.kt` | Playback orchestration, now playing polling, Icy parsing, playlist |
| `data/remote/dto/RadioBrowserDto.kt` | DTOs with nullable fields for Radio Browser API |
| `data/local/AppDatabase.kt` | Room v2 with `StationEntity`, `TrackHistoryEntity`, `TagEntity` |
---
## Next Steps / Open Items
- [ ] Replace deprecated `Divider` with `HorizontalDivider` in `HistoryScreen`
- [ ] Add empty-state illustration or better UX when Radio Browser is unavailable
- [ ] Consider caching Radio Browser stations per tag in Room for offline reuse
- [ ] Evaluate if `tagKeywords` mapping for Record stations needs tuning per user feedback
- [ ] Add error retry / exponential backoff for Radio Browser API calls when VPN is unstable

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,428 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>radiOLA — Wireframe Options</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #0a0a0a;
color: #fff;
padding: 40px;
display: flex;
flex-direction: column;
align-items: center;
gap: 60px;
}
h1 { font-size: 32px; margin-bottom: 10px; text-align: center; }
p.subtitle { color: #888; margin-bottom: 40px; text-align: center; }
.options { display: flex; gap: 40px; flex-wrap: wrap; justify-content: center; }
.option { display: flex; flex-direction: column; align-items: center; gap: 16px; }
.option-label {
font-size: 18px;
font-weight: 600;
color: #ccc;
text-align: center;
}
.phone {
width: 320px;
height: 640px;
border: 12px solid #333;
border-radius: 40px;
overflow: hidden;
position: relative;
background: #121212;
}
.phone-notch {
position: absolute;
top: 0;
left: 50%;
transform: translateX(-50%);
width: 120px;
height: 28px;
background: #333;
border-radius: 0 0 16px 16px;
z-index: 10;
}
.screen { padding: 40px 16px 80px; height: 100%; overflow-y: auto; }
/* Variant A: Spotify Dark */
.vA .screen { background: linear-gradient(180deg, #1a1a2e 0%, #121212 40%); }
.vA .header { font-size: 22px; font-weight: 700; margin-bottom: 16px; }
.vA .search { background: #2a2a2a; border-radius: 8px; padding: 10px 14px; color: #888; font-size: 14px; margin-bottom: 16px; }
.vA .chips { display: flex; gap: 8px; margin-bottom: 16px; flex-wrap: wrap; }
.vA .chip { background: #2a2a2a; padding: 6px 14px; border-radius: 16px; font-size: 12px; color: #ccc; }
.vA .grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
.vA .card { background: #1e1e1e; border-radius: 12px; padding: 12px; aspect-ratio: 1; display: flex; flex-direction: column; gap: 8px; }
.vA .cover { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); border-radius: 8px; flex: 1; }
.vA .name { font-size: 13px; font-weight: 500; }
.vA .miniplayer {
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 56px;
background: #1e1e1e;
border-top: 1px solid #333;
display: flex;
align-items: center;
padding: 0 16px;
gap: 12px;
}
.vA .mini-cover { width: 40px; height: 40px; background: linear-gradient(135deg, #667eea, #764ba2); border-radius: 6px; }
.vA .mini-info { flex: 1; }
.vA .mini-title { font-size: 13px; font-weight: 500; }
.vA .mini-artist { font-size: 11px; color: #888; }
.vA .mini-btn { width: 32px; height: 32px; background: #fff; border-radius: 50%; }
.vA .nav {
position: absolute;
bottom: 56px;
left: 0;
right: 0;
height: 56px;
background: #121212;
border-top: 1px solid #222;
display: flex;
justify-content: space-around;
align-items: center;
font-size: 10px;
color: #888;
}
.vA .nav-item { display: flex; flex-direction: column; align-items: center; gap: 4px; }
.vA .nav-icon { width: 24px; height: 24px; background: #444; border-radius: 4px; }
/* Variant B: Apple Music Vibrant */
.vB .screen { background: #000; }
.vB .header { font-size: 28px; font-weight: 700; margin-bottom: 16px; }
.vB .search { background: #1c1c1e; border-radius: 10px; padding: 10px 14px; color: #888; font-size: 14px; margin-bottom: 16px; }
.vB .section-title { font-size: 20px; font-weight: 700; margin-bottom: 12px; }
.vB .horizontal { display: flex; gap: 12px; overflow-x: hidden; margin-bottom: 16px; }
.vB .h-card { min-width: 140px; }
.vB .h-cover { width: 140px; height: 140px; background: linear-gradient(135deg, #ff6b6b, #feca57); border-radius: 12px; margin-bottom: 8px; }
.vB .h-name { font-size: 13px; font-weight: 500; }
.vB .grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
.vB .card { background: #1c1c1e; border-radius: 16px; padding: 12px; aspect-ratio: 1; display: flex; flex-direction: column; gap: 8px; }
.vB .cover { background: linear-gradient(135deg, #48dbfb, #0abde3); border-radius: 12px; flex: 1; }
.vB .name { font-size: 13px; font-weight: 500; }
.vB .miniplayer {
position: absolute;
bottom: 0;
left: 8px;
right: 8px;
height: 64px;
background: rgba(28,28,30,0.9);
backdrop-filter: blur(20px);
border-radius: 16px 16px 0 0;
display: flex;
align-items: center;
padding: 0 16px;
gap: 12px;
}
.vB .mini-cover { width: 44px; height: 44px; background: linear-gradient(135deg, #ff6b6b, #feca57); border-radius: 10px; }
.vB .mini-info { flex: 1; }
.vB .mini-title { font-size: 13px; font-weight: 500; }
.vB .mini-artist { font-size: 11px; color: #888; }
.vB .mini-btn { width: 32px; height: 32px; background: #fff; border-radius: 50%; }
.vB .nav {
position: absolute;
bottom: 64px;
left: 0;
right: 0;
height: 56px;
background: #000;
display: flex;
justify-content: space-around;
align-items: center;
font-size: 10px;
color: #888;
}
.vB .nav-item { display: flex; flex-direction: column; align-items: center; gap: 4px; }
.vB .nav-icon { width: 24px; height: 24px; background: #444; border-radius: 4px; }
/* Variant C: Radio Record Neon */
.vC .screen { background: #0d0d0d; }
.vC .header { font-size: 22px; font-weight: 700; margin-bottom: 12px; color: #ff0055; }
.vC .search { background: #1a1a1a; border: 1px solid #333; border-radius: 8px; padding: 10px 14px; color: #888; font-size: 14px; margin-bottom: 12px; }
.vC .chips { display: flex; gap: 8px; margin-bottom: 12px; flex-wrap: wrap; }
.vC .chip { background: #1a1a1a; border: 1px solid #ff0055; padding: 6px 14px; border-radius: 16px; font-size: 12px; color: #ff0055; }
.vC .grid { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 10px; }
.vC .card { display: flex; flex-direction: column; align-items: center; gap: 6px; }
.vC .cover { width: 100%; aspect-ratio: 1; background: linear-gradient(135deg, #ff0055, #ff5500); border-radius: 12px; display: flex; align-items: center; justify-content: center; font-size: 24px; }
.vC .name { font-size: 11px; text-align: center; }
.vC .miniplayer {
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 52px;
background: #1a1a1a;
border-top: 2px solid #ff0055;
display: flex;
align-items: center;
padding: 0 16px;
gap: 12px;
}
.vC .mini-cover { width: 36px; height: 36px; background: linear-gradient(135deg, #ff0055, #ff5500); border-radius: 6px; }
.vC .mini-info { flex: 1; }
.vC .mini-title { font-size: 13px; font-weight: 500; }
.vC .mini-artist { font-size: 11px; color: #888; }
.vC .mini-btn { width: 28px; height: 28px; background: #ff0055; border-radius: 50%; }
.vC .nav {
position: absolute;
bottom: 52px;
left: 0;
right: 0;
height: 52px;
background: #0d0d0d;
border-top: 1px solid #222;
display: flex;
justify-content: space-around;
align-items: center;
font-size: 10px;
color: #888;
}
.vC .nav-item { display: flex; flex-direction: column; align-items: center; gap: 4px; }
.vC .nav-icon { width: 22px; height: 22px; background: #444; border-radius: 4px; }
.player-screen {
display: none;
position: absolute;
top: 0; left: 0; right: 0; bottom: 0;
background: #121212;
z-index: 20;
padding: 60px 24px;
flex-direction: column;
align-items: center;
gap: 20px;
}
.player-screen.active { display: flex; }
.player-big-cover {
width: 240px; height: 240px;
background: linear-gradient(135deg, #667eea, #764ba2);
border-radius: 20px;
margin-top: 20px;
}
.player-station { font-size: 14px; color: #888; }
.player-track { font-size: 22px; font-weight: 700; text-align: center; }
.player-artist { font-size: 16px; color: #888; }
.deeplink-row { display: flex; gap: 12px; margin-top: 20px; flex-wrap: wrap; justify-content: center; }
.dl-btn { width: 48px; height: 48px; background: #2a2a2a; border-radius: 12px; display: flex; align-items: center; justify-content: center; font-size: 10px; color: #888; }
.controls { display: flex; gap: 24px; margin-top: 30px; }
.ctrl-btn { width: 56px; height: 56px; background: #fff; border-radius: 50%; }
.ctrl-btn.play { width: 72px; height: 72px; }
.close-player {
position: absolute;
top: 16px;
right: 16px;
width: 32px; height: 32px;
background: #333;
border-radius: 50%;
cursor: pointer;
}
.legend {
margin-top: 20px;
background: #1a1a1a;
padding: 20px;
border-radius: 16px;
max-width: 900px;
width: 100%;
}
.legend h3 { margin-bottom: 12px; color: #fff; }
.legend ul { color: #aaa; line-height: 1.8; padding-left: 20px; }
</style>
</head>
<body>
<h1>📱 radiOLA — Wireframe Options</h1>
<p class="subtitle">Выбери стиль, который ближе. Нажми на экран, чтобы увидеть Player-экран.</p>
<div class="options">
<!-- Variant A -->
<div class="option vA">
<div class="option-label">A. Spotify Dark<br><span style="font-size:13px;color:#888;font-weight:400">Тёмный минимализм, градиенты, крупные карточки</span></div>
<div class="phone" onclick="togglePlayer('pA')">
<div class="phone-notch"></div>
<div class="screen">
<div class="header">Радио</div>
<div class="search">Поиск станции...</div>
<div class="chips">
<div class="chip">Все</div>
<div class="chip">Транс</div>
<div class="chip">Хаус</div>
<div class="chip">Басс</div>
</div>
<div class="grid">
<div class="card"><div class="cover"></div><div class="name">Record</div></div>
<div class="card"><div class="cover" style="background:linear-gradient(135deg,#f093fb,#f5576c)"></div><div class="name">Trance</div></div>
<div class="card"><div class="cover" style="background:linear-gradient(135deg,#4facfe,#00f2fe)"></div><div class="name">Deep</div></div>
<div class="card"><div class="cover" style="background:linear-gradient(135deg,#43e97b,#38f9d7)"></div><div class="name">Bass</div></div>
</div>
</div>
<div class="nav">
<div class="nav-item"><div class="nav-icon"></div>Радио</div>
<div class="nav-item"><div class="nav-icon"></div>Избранное</div>
<div class="nav-item"><div class="nav-icon"></div>История</div>
<div class="nav-item"><div class="nav-icon"></div>Настройки</div>
</div>
<div class="miniplayer">
<div class="mini-cover"></div>
<div class="mini-info"><div class="mini-title">Record</div><div class="mini-artist">Armin van Buuren — Blah Blah</div></div>
<div class="mini-btn"></div>
</div>
<div id="pA" class="player-screen">
<div class="close-player" onclick="event.stopPropagation();togglePlayer('pA')"></div>
<div class="player-big-cover"></div>
<div class="player-station">Record</div>
<div class="player-track">Blah Blah</div>
<div class="player-artist">Armin van Buuren</div>
<div class="deeplink-row">
<div class="dl-btn">Яндекс</div>
<div class="dl-btn">VK</div>
<div class="dl-btn">Spotify</div>
<div class="dl-btn">Apple</div>
</div>
<div class="controls">
<div class="ctrl-btn"></div>
<div class="ctrl-btn play"></div>
<div class="ctrl-btn"></div>
</div>
</div>
</div>
</div>
<!-- Variant B -->
<div class="option vB">
<div class="option-label">B. Apple Music Vibrant<br><span style="font-size:13px;color:#888;font-weight:400">Яркие цвета, горизонтальный скролл, glassmorphism</span></div>
<div class="phone" onclick="togglePlayer('pB')">
<div class="phone-notch"></div>
<div class="screen">
<div class="header">Радио</div>
<div class="search">Поиск станции...</div>
<div class="section-title">Популярное</div>
<div class="horizontal">
<div class="h-card"><div class="h-cover"></div><div class="h-name">Record</div></div>
<div class="h-card"><div class="h-cover" style="background:linear-gradient(135deg,#48dbfb,#0abde3)"></div><div class="h-name">Trance</div></div>
<div class="h-card"><div class="h-cover" style="background:linear-gradient(135deg,#ff9ff3,#f368e0)"></div><div class="h-name">Deep</div></div>
</div>
<div class="section-title">Все станции</div>
<div class="grid">
<div class="card"><div class="cover"></div><div class="name">Record</div></div>
<div class="card"><div class="cover" style="background:linear-gradient(135deg,#ff6b6b,#feca57)"></div><div class="name">Trance</div></div>
<div class="card"><div class="cover" style="background:linear-gradient(135deg,#48dbfb,#0abde3)"></div><div class="name">Deep</div></div>
<div class="card"><div class="cover" style="background:linear-gradient(135deg,#1dd1a1,#10ac84)"></div><div class="name">Bass</div></div>
</div>
</div>
<div class="nav">
<div class="nav-item"><div class="nav-icon"></div>Радио</div>
<div class="nav-item"><div class="nav-icon"></div>Избранное</div>
<div class="nav-item"><div class="nav-icon"></div>История</div>
<div class="nav-item"><div class="nav-icon"></div>Настройки</div>
</div>
<div class="miniplayer">
<div class="mini-cover"></div>
<div class="mini-info"><div class="mini-title">Record</div><div class="mini-artist">Blah Blah — Armin</div></div>
<div class="mini-btn"></div>
</div>
<div id="pB" class="player-screen">
<div class="close-player" onclick="event.stopPropagation();togglePlayer('pB')"></div>
<div class="player-big-cover" style="background:linear-gradient(135deg,#ff6b6b,#feca57)"></div>
<div class="player-station">Record</div>
<div class="player-track">Blah Blah</div>
<div class="player-artist">Armin van Buuren</div>
<div class="deeplink-row">
<div class="dl-btn">Яндекс</div>
<div class="dl-btn">VK</div>
<div class="dl-btn">Spotify</div>
<div class="dl-btn">Apple</div>
</div>
<div class="controls">
<div class="ctrl-btn"></div>
<div class="ctrl-btn play"></div>
<div class="ctrl-btn"></div>
</div>
</div>
</div>
</div>
<!-- Variant C -->
<div class="option vC">
<div class="option-label">C. Radio Record Neon<br><span style="font-size:13px;color:#888;font-weight:400">Брендовый розовый, сетка 3×3, компактный плеер</span></div>
<div class="phone" onclick="togglePlayer('pC')">
<div class="phone-notch"></div>
<div class="screen">
<div class="header">RADIO RECORD</div>
<div class="search">Поиск...</div>
<div class="chips">
<div class="chip">Все</div>
<div class="chip">Транс</div>
<div class="chip">Хаус</div>
</div>
<div class="grid">
<div class="card"><div class="cover">R</div><div class="name">Record</div></div>
<div class="card"><div class="cover" style="background:linear-gradient(135deg,#ff5500,#ffaa00)">T</div><div class="name">Trance</div></div>
<div class="card"><div class="cover" style="background:linear-gradient(135deg,#00d2ff,#3a7bd5)">D</div><div class="name">Deep</div></div>
<div class="card"><div class="cover" style="background:linear-gradient(135deg,#00ff88,#00cc66)">B</div><div class="name">Bass</div></div>
<div class="card"><div class="cover" style="background:linear-gradient(135deg,#ff0055,#ff5588)">P</div><div class="name">Pirate</div></div>
<div class="card"><div class="cover" style="background:linear-gradient(135deg,#aa00ff,#ff00ff)">G</div><div class="name">GOA</div></div>
</div>
</div>
<div class="nav">
<div class="nav-item"><div class="nav-icon"></div>Радио</div>
<div class="nav-item"><div class="nav-icon"></div>Избранное</div>
<div class="nav-item"><div class="nav-icon"></div>История</div>
<div class="nav-item"><div class="nav-icon"></div>Настройки</div>
</div>
<div class="miniplayer">
<div class="mini-cover"></div>
<div class="mini-info"><div class="mini-title">Record</div><div class="mini-artist">Blah Blah — Armin</div></div>
<div class="mini-btn"></div>
</div>
<div id="pC" class="player-screen">
<div class="close-player" onclick="event.stopPropagation();togglePlayer('pC')"></div>
<div class="player-big-cover" style="background:linear-gradient(135deg,#ff0055,#ff5500)"></div>
<div class="player-station">Record</div>
<div class="player-track">Blah Blah</div>
<div class="player-artist">Armin van Buuren</div>
<div class="deeplink-row">
<div class="dl-btn">Яндекс</div>
<div class="dl-btn">VK</div>
<div class="dl-btn">Spotify</div>
<div class="dl-btn">Apple</div>
</div>
<div class="controls">
<div class="ctrl-btn"></div>
<div class="ctrl-btn play"></div>
<div class="ctrl-btn"></div>
</div>
</div>
</div>
</div>
</div>
<div class="legend">
<h3>📋 Общие элементы для всех вариантов</h3>
<ul>
<li><strong>StationsScreen</strong> — поисковая строка, чипы фильтров по жанрам, сетка/список станций с обложками</li>
<li><strong>Player (expanded)</strong> — большая обложка, название станции, трек, исполнитель, ряд кнопок deep link (Яндекс, VK, Spotify, Apple, и т.д.), play/pause</li>
<li><strong>Mini-player (collapsed)</strong> — обложка 40-48dp, название станции, текущий трек, play/pause</li>
<li><strong>Bottom Nav</strong> — 4 таба: Радио, Избранное, История, Настройки</li>
<li><strong>FavoritesScreen</strong> — сетка избранных станций с ⭐ и drag-sort</li>
<li><strong>HistoryScreen</strong> — список последних 200 треков с timestamp, поиском, swipe-to-delete</li>
<li><strong>SettingsScreen</strong> — таймер сна, эквалайзер, настройка deep links, переключатель записи</li>
</ul>
</div>
<script>
function togglePlayer(id) {
const el = document.getElementById(id);
el.classList.toggle('active');
}
</script>
</body>
</html>