├── README.md ├── api.php ├── assets ├── app │ ├── android.svg │ └── ios.svg ├── capital_logo.png ├── capitallogo_preto.png ├── cover.png ├── default_logo.png ├── favicon.png ├── favicon_demo.png ├── jailson.png ├── jailson_cover.png ├── jailson_logo.png └── radioplayer.svg ├── css └── main.min.css ├── custom.css ├── documentation.pdf ├── index.html ├── index_multiradio.html ├── js └── main.js └── manifest.json /README.md: -------------------------------------------------------------------------------- 1 | ## Single or Multi-Station Radio Player 2 | 3 | This document provides a detailed guide on the structure, configuration, and customization of a single / multi-station radio player built with HTML, CSS, and JavaScript. This player dynamically fetches song information and offers the flexibility to use a local API or a pre-configured web-based API. 4 | 5 | 6 | ## Demo Screenshots 7 | 8 | ![Demo Screenshot](https://i.imgur.com/oULEMgZ.jpeg) 9 | 10 | 11 | ### 1. Overview 12 | 13 | This radio player offers a user-friendly interface for enjoying online radio stations. It allows for the addition of multiple stations, each with its own live stream, song information, social media links, and more. Station configuration is done directly within the HTML, simplifying the customization process. 14 | 15 | ### 2. File Structure 16 | 17 | * **`index.html`:** Contains the main HTML for the player, including: 18 | * Visual structure and interactive elements. 19 | * Station configurations within a ` 78 | ``` 79 | 80 | #### 3.2. Local API (Optional) 81 | 82 | If you choose to use the local API (`api.php`), follow these instructions to set it up: 83 | 84 | * **Configuration:** 85 | * In the `api.php` file, the `$allowedUrls` variable should list all allowed stream URLs. 86 | * **Functionality:** 87 | * `getMp3StreamTitle()`: Extracts the song title from the stream metadata. 88 | * `extractArtistAndSong()`: Separates artist and song title. 89 | * `getAlbumArt()`: Fetches album art (currently set up to use the iTunes API). 90 | * `updateHistory()`: Maintains a history of played songs. 91 | 92 | **Note:** 93 | 94 | If the `api` field is left blank in the station configuration, will default to the pre-configured web API. Make sure the web API you are using is functioning and correctly set up within the JavaScript code. 95 | 96 | ### 4. Customization, Interface, Interaction, and Publication 97 | 98 | The sections regarding: 99 | 100 | * **Customizing visual styles** (`css/main.min.css` and `custom.css`) 101 | * **Using custom images and icons** (`assets/`) 102 | * **User interface elements** (header, station selector, history, etc.) 103 | * **User navigation and interaction** 104 | * **Publishing the player to a web server** 105 | 106 | #### 4.1. Key Elements 107 | 108 | * **Header:** Displays the station logo and buttons for accessing the history, station list, and mobile menu. 109 | * **Player Section:** Contains the album art, song information (artist and title), playback controls (play/pause, next/previous station), and volume control. 110 | * **Visualizer:** A simple audio visualizer that responds dynamically to the music. 111 | * **Off-Canvas Sidebar:** 112 | * **Station List:** Displays all available stations with thumbnails. 113 | * **History:** Shows a history of recently played songs. 114 | * **Lyrics Modal:** Displays the lyrics of the currently playing song (if available through the Vagalume API). 115 | 116 | #### 4.2. Navigation 117 | 118 | * **Station Selection:** Click on a station in the station list to begin playback. 119 | * **Song History:** Access the history through the button in the header. 120 | * **Song Lyrics:** Click the "Lyrics" button to open the lyrics modal. 121 | * **Mobile Menu:** The menu button in the header provides access to the same functionality on mobile devices. 122 | 123 | ### 5. Customization 124 | 125 | #### 5.1. Visual Styles (`css/main.min.css` and `custom.css`) 126 | 127 | * Colors, fonts, spacing, element sizes, and other visual properties can be customized by editing the CSS rules. 128 | 129 | #### 5.2. Images and Icons (`assets/`) 130 | 131 | * Replace the default images in the `assets` folder with your own to customize the station logo, album art, and icons. 132 | 133 | ### 6. Publication 134 | 135 | 1. Make sure the local API (`api.php`), if used, is configured correctly and accessible on your server. 136 | 2. Upload all files and folders (HTML, CSS, JavaScript, PHP, images) to your web server. 137 | 138 | ## Free Hosting 139 | 140 | [![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https://github.com/jailsonsb2/Radioplayer_api) 141 | [![Deploy to Netlify](https://www.netlify.com/img/deploy/button.svg)](https://app.netlify.com/start/deploy?repository=https://github.com/jailsonsb2/Radioplayer_api) 142 | 143 | ### 7. Additional Considerations 144 | 145 | * **Copyright:** Ensure that you have the rights to use all images, music, and other content used in your radio player. 146 | * **Stream Metadata:** The accuracy of song information is dependent on the quality of the metadata provided by the radio station's stream. 147 | 148 | 149 | 150 | 151 | 152 | -------------------------------------------------------------------------------- /api.php: -------------------------------------------------------------------------------- 1 | 'client_credentials' 18 | ]; 19 | 20 | $options = [ 21 | 'http' => [ 22 | 'header' => implode("\r\n", $headers), 23 | 'method' => 'POST', 24 | 'content' => http_build_query($data) 25 | ] 26 | ]; 27 | 28 | $context = stream_context_create($options); 29 | $response = @file_get_contents($url, false, $context); 30 | 31 | if ($response === false) { 32 | return null; 33 | } 34 | 35 | $tokenData = json_decode($response, true); 36 | return $tokenData['access_token'] ?? null; 37 | } 38 | 39 | function getMp3StreamTitle($streamingUrl, $interval) { 40 | $needle = 'StreamTitle='; 41 | $headers = [ 42 | 'Icy-MetaData: 1', 43 | 'User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/27.0.1453.110 Safari/537.36' 44 | ]; 45 | 46 | $context = stream_context_create([ 47 | 'http' => [ 48 | 'header' => implode("\r\n", $headers), 49 | 'timeout' => 60 // Incrementar timeout a 60 segundos 50 | ] 51 | ]); 52 | 53 | $stream = @fopen($streamingUrl, 'r', false, $context); 54 | if ($stream === false) { 55 | return null; 56 | } 57 | 58 | $metaDataInterval = null; 59 | foreach ($http_response_header as $header) { 60 | if (stripos($header, 'icy-metaint') !== false) { 61 | $metaDataInterval = (int)trim(explode(':', $header)[1]); 62 | break; 63 | } 64 | } 65 | 66 | if ($metaDataInterval === null) { 67 | fclose($stream); 68 | return null; 69 | } 70 | 71 | while (!feof($stream)) { 72 | fread($stream, $metaDataInterval); 73 | $buffer = fread($stream, $interval); 74 | $titleIndex = strpos($buffer, $needle); 75 | if ($titleIndex !== false) { 76 | $title = substr($buffer, $titleIndex + strlen($needle)); 77 | $title = substr($title, 0, strpos($title, ';')); 78 | fclose($stream); 79 | return trim($title, "' "); 80 | } 81 | } 82 | fclose($stream); 83 | return null; 84 | } 85 | 86 | function extractArtistAndSong($title) { 87 | $title = trim($title, "'"); 88 | if (strpos($title, '-') !== false) { 89 | [$artist, $song] = explode('-', $title, 2); 90 | return [trim($artist), trim($song)]; 91 | } 92 | return ['', trim($title)]; 93 | } 94 | 95 | function getAlbumInfo($artist, $song) { 96 | $token = getSpotifyToken(); 97 | if (!$token) { 98 | return [null, 'No disponible', 'No disponible', 'No disponible', 0]; 99 | } 100 | 101 | $url = 'https://api.spotify.com/v1/search?q=' . urlencode("track:$song artist:$artist") . '&type=track&limit=1'; 102 | $headers = [ 103 | 'Authorization: Bearer ' . $token 104 | ]; 105 | 106 | $options = [ 107 | 'http' => [ 108 | 'header' => implode("\r\n", $headers), 109 | 'method' => 'GET' 110 | ] 111 | ]; 112 | 113 | $context = stream_context_create($options); 114 | $response = @file_get_contents($url, false, $context); 115 | if ($response === false) { 116 | return [null, 'No disponible', 'No disponible', 'No disponible', 0]; 117 | } 118 | 119 | $data = json_decode($response, true); 120 | if (isset($data['tracks']['items'][0])) { 121 | $track = $data['tracks']['items'][0]; 122 | $album = $track['album']['name'] ?? 'No disponible'; 123 | $artworkUrl = $track['album']['images'][0]['url'] ?? null; 124 | $year = isset($track['album']['release_date']) ? substr($track['album']['release_date'], 0, 4) : 'No disponible'; 125 | 126 | // Duración en milisegundos 127 | $durationMs = $track['duration_ms'] ?? 0; 128 | 129 | // Obtener el género del artista 130 | $artistId = $track['artists'][0]['id']; 131 | $artistUrl = "https://api.spotify.com/v1/artists/$artistId"; 132 | $artistResponse = @file_get_contents($artistUrl, false, $context); 133 | $artistData = json_decode($artistResponse, true); 134 | $genres = $artistData['genres'] ?? []; 135 | $genre = !empty($genres) ? implode(', ', $genres) : 'No disponible'; 136 | 137 | return [$artworkUrl, $album, $year, $genre, $durationMs]; 138 | } 139 | 140 | return [null, 'No disponible', 'No disponible', 'No disponible', 0]; 141 | } 142 | 143 | function updateHistory($url, $artist, $song) { 144 | $historyFile = 'history_' . md5($url) . '.json'; 145 | $historyLimit = 10; 146 | 147 | if (!file_exists($historyFile)) { 148 | $history = []; 149 | } else { 150 | $history = json_decode(file_get_contents($historyFile), true); 151 | if ($history === null) { 152 | $history = []; 153 | } 154 | } 155 | 156 | $currentSong = ["title" => $song, "artist" => $artist]; 157 | $existingIndex = array_search($currentSong, array_column($history, 'song')); 158 | if ($existingIndex !== false) { 159 | array_splice($history, $existingIndex, 1); 160 | } 161 | 162 | array_unshift($history, ["song" => $currentSong]); 163 | $history = array_slice($history, 0, $historyLimit); 164 | file_put_contents($historyFile, json_encode($history)); 165 | 166 | return $history; 167 | } 168 | 169 | // Funcion Para Leer Las Canciones 170 | header('Content-Type: application/json'); 171 | 172 | // URL de streaming 173 | $url = isset($_GET['url']) ? $_GET['url'] : null; 174 | $interval = isset($_GET['interval']) ? (int)$_GET['interval'] : 19200; 175 | 176 | if ($url === null) { 177 | echo json_encode(["error" => "URL parameter is missing"]); // User-friendly error message 178 | exit; 179 | } 180 | 181 | // Intentar obtener el start_time desde el archivo 182 | $start_time_file = 'start_time_' . md5($url) . '.txt'; 183 | $previous_song_file = 'previous_song_' . md5($url) . '.txt'; 184 | 185 | if (file_exists($previous_song_file)) { 186 | // Leer la canción anterior desde el archivo 187 | $previous_song = file_get_contents($previous_song_file); 188 | } else { 189 | $previous_song = null; 190 | } 191 | 192 | if (file_exists($start_time_file)) { 193 | // Si el archivo existe, leer el start_time desde él 194 | $start_time = (int)file_get_contents($start_time_file); 195 | } else { 196 | // Si no existe, asignar un start_time basado en la hora actual 197 | $start_time = time(); 198 | // Guardar el start_time en el archivo 199 | file_put_contents($start_time_file, $start_time); 200 | } 201 | 202 | if (!filter_var($url, FILTER_VALIDATE_URL)) { 203 | echo json_encode(["error" => "Invalid URL format"]); // More specific error message 204 | exit; 205 | } 206 | 207 | 208 | $title = getMp3StreamTitle($url, $interval); 209 | if ($title) { 210 | [$artist, $song] = extractArtistAndSong($title); 211 | 212 | // Si la canción ha cambiado, reiniciar el start_time 213 | if ($song !== $previous_song) { 214 | // Reiniciar el start_time 215 | $start_time = time(); 216 | file_put_contents($start_time_file, $start_time); 217 | file_put_contents($previous_song_file, $song); // Guardar la canción actual 218 | } 219 | 220 | [$artUrl, $album, $year, $genre, $durationMs] = getAlbumInfo($artist, $song); 221 | 222 | // Convertimos la duración de la canción de milisegundos a segundos 223 | $duration = $durationMs / 1000; // Duración de la canción en segundos 224 | 225 | // Calcular el tiempo transcurrido desde que se inició la canción 226 | $elapsed = time() - $start_time; // Tiempo transcurrido en segundos 227 | $elapsed = min($elapsed, $duration); // Limitar el tiempo transcurrido al tiempo total de la canción 228 | 229 | // Calcular el tiempo restante 230 | $remaining = max(0, $duration - $elapsed); // Tiempo restante, no puede ser negativo 231 | 232 | // Convertir todo a enteros antes de enviar la respuesta 233 | $elapsed = (int) $elapsed; // Elapsed como entero 234 | $remaining = (int) $remaining; // Remaining como entero 235 | $duration = (int) $duration; // Duration como entero 236 | 237 | // Actualizar historial de canciones 238 | $history = updateHistory($url, $artist, $song); 239 | $filteredHistory = array_slice($history, 1); 240 | 241 | $response = [ 242 | "songtitle" => "$artist - $song", 243 | "artist" => $artist, 244 | "song" => $song, 245 | "source" => $url, 246 | "artwork" => $artUrl, 247 | "album" => $album, 248 | "year" => $year, 249 | "genre" => $genre, 250 | "song_history" => $filteredHistory, 251 | "now_playing" => [ 252 | "elapsed" => $elapsed, // Elapsed como entero 253 | "remaining" => $remaining, // Remaining como entero 254 | "duration" => $duration // Duration como entero 255 | ] 256 | ]; 257 | 258 | // Responder con la información en formato JSON 259 | echo json_encode($response); 260 | } else { 261 | echo json_encode(["error" => "The stream title could not be retrieved."]); 262 | } -------------------------------------------------------------------------------- /assets/app/android.svg: -------------------------------------------------------------------------------- 1 | 2 | 5 | 44 | -------------------------------------------------------------------------------- /assets/app/ios.svg: -------------------------------------------------------------------------------- 1 | 2 | 5 | 63 | -------------------------------------------------------------------------------- /assets/capital_logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jailsonsb2/Radioplayer_api/83bc7e378cccef30c8953381485606d51f66e6b4/assets/capital_logo.png -------------------------------------------------------------------------------- /assets/capitallogo_preto.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jailsonsb2/Radioplayer_api/83bc7e378cccef30c8953381485606d51f66e6b4/assets/capitallogo_preto.png -------------------------------------------------------------------------------- /assets/cover.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jailsonsb2/Radioplayer_api/83bc7e378cccef30c8953381485606d51f66e6b4/assets/cover.png -------------------------------------------------------------------------------- /assets/default_logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jailsonsb2/Radioplayer_api/83bc7e378cccef30c8953381485606d51f66e6b4/assets/default_logo.png -------------------------------------------------------------------------------- /assets/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jailsonsb2/Radioplayer_api/83bc7e378cccef30c8953381485606d51f66e6b4/assets/favicon.png -------------------------------------------------------------------------------- /assets/favicon_demo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jailsonsb2/Radioplayer_api/83bc7e378cccef30c8953381485606d51f66e6b4/assets/favicon_demo.png -------------------------------------------------------------------------------- /assets/jailson.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jailsonsb2/Radioplayer_api/83bc7e378cccef30c8953381485606d51f66e6b4/assets/jailson.png -------------------------------------------------------------------------------- /assets/jailson_cover.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jailsonsb2/Radioplayer_api/83bc7e378cccef30c8953381485606d51f66e6b4/assets/jailson_cover.png -------------------------------------------------------------------------------- /assets/jailson_logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jailsonsb2/Radioplayer_api/83bc7e378cccef30c8953381485606d51f66e6b4/assets/jailson_logo.png -------------------------------------------------------------------------------- /assets/radioplayer.svg: -------------------------------------------------------------------------------- 1 | Radio -------------------------------------------------------------------------------- /css/main.min.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --primary: #6c6fc6; 3 | --bg-body: #262626; 4 | --bg-app: #0a0a0a; 5 | --bg-inset: #404040; 6 | --bg-transparent: rgb(255 255 255 / 10%); 7 | --bg-modal: rgb(255 255 255 / 20%); 8 | --bg-dark: rgb(0 0 0 / 75%); 9 | --bg-gradient: linear-gradient(140deg, #a92bcd, #439bc1); 10 | --color-title: #ffffff; 11 | --color-text: rgb(255 255 255 / 50%); 12 | --duration: 0.3s; 13 | --container: 1480px; 14 | --spacer: 1rem; 15 | --shadow-l: 0px 8px 17px 2px rgba(0, 0, 0, 0.14), 0px 3px 14px 2px rgba(0, 0, 0, 0.12), 0px 5px 5px -3px rgba(0, 0, 0, 0.2); 16 | --shadow-xl: 0px 16px 24px 2px rgba(0, 0, 0, 0.14), 0px 6px 30px 5px rgba(0, 0, 0, 0.12), 0px 8px 10px -7px rgba(0, 0, 0, 0.2); 17 | --scrollbar-color: rgb(255 255 255 / 50%); 18 | --main-padding: 1rem; 19 | } 20 | html { 21 | line-height: 1.5; 22 | -webkit-text-size-adjust: 100%; 23 | -webkit-font-smoothing: antialiased; 24 | } 25 | *, 26 | ::after, 27 | ::before { 28 | box-sizing: border-box; 29 | } 30 | body, 31 | html { 32 | height: 100%; 33 | } 34 | * { 35 | margin: 0; 36 | } 37 | fieldset, 38 | legend { 39 | padding: 0; 40 | } 41 | fieldset, 42 | iframe { 43 | border-width: 0; 44 | } 45 | a { 46 | color: inherit; 47 | text-decoration: none; 48 | color: var(--primary); 49 | transition: color var(--duration); 50 | } 51 | h1, 52 | h2, 53 | h3, 54 | h4, 55 | h5, 56 | h6 { 57 | font-size: inherit; 58 | font-weight: inherit; 59 | overflow-wrap: break-word; 60 | } 61 | address { 62 | font-style: normal; 63 | line-height: inherit; 64 | } 65 | abbr[title] { 66 | text-decoration: underline dotted; 67 | } 68 | small { 69 | font-size: 80%; 70 | } 71 | sub, 72 | sup { 73 | font-size: 75%; 74 | line-height: 0; 75 | position: relative; 76 | vertical-align: baseline; 77 | } 78 | sub { 79 | bottom: -0.25em; 80 | } 81 | sup { 82 | top: -0.5em; 83 | } 84 | button, 85 | input, 86 | optgroup, 87 | select, 88 | textarea { 89 | padding: 0; 90 | border-width: 0; 91 | font-size: 100%; 92 | font-family: inherit; 93 | line-height: inherit; 94 | color: inherit; 95 | } 96 | input:focus, 97 | textarea:focus { 98 | outline: 0; 99 | } 100 | textarea { 101 | resize: vertical; 102 | } 103 | button, 104 | select { 105 | text-transform: none; 106 | } 107 | [type="button"], 108 | [type="reset"], 109 | [type="submit"], 110 | button { 111 | -webkit-appearance: button; 112 | background-color: transparent; 113 | display: inline-block; 114 | vertical-align: middle; 115 | } 116 | [type="button"]:not(:disabled), 117 | [type="reset"]:not(:disabled), 118 | [type="submit"]:not(:disabled), 119 | button:not(:disabled) { 120 | cursor: pointer; 121 | } 122 | progress { 123 | vertical-align: baseline; 124 | } 125 | ::-webkit-file-upload-button { 126 | -webkit-appearance: button; 127 | font: inherit; 128 | } 129 | summary { 130 | display: list-item; 131 | } 132 | [hidden] { 133 | display: none; 134 | } 135 | dd, 136 | dl, 137 | ol, 138 | ul { 139 | list-style: none; 140 | padding: 0; 141 | } 142 | table { 143 | border-collapse: collapse; 144 | max-width: 100%; 145 | } 146 | tbody, 147 | td, 148 | th, 149 | thead, 150 | tr { 151 | border-width: 0; 152 | text-align: inherit; 153 | } 154 | tr > * { 155 | padding: 0.75rem; 156 | word-break: normal; 157 | } 158 | canvas, 159 | img, 160 | svg, 161 | video { 162 | height: auto; 163 | } 164 | source { 165 | display: none; 166 | } 167 | canvas, 168 | embed, 169 | iframe, 170 | img, 171 | object, 172 | svg, 173 | video { 174 | display: block; 175 | max-width: 100%; 176 | } 177 | audio, 178 | video { 179 | width: 100%; 180 | } 181 | body { 182 | background-size: cover; 183 | background-color: var(--bg-body); 184 | color: var(--color-text); 185 | font-family: Montserrat, sans-serif; 186 | transition: background-color var(--duration); 187 | } 188 | body.preload * { 189 | transition: none !important; 190 | } 191 | @media (max-width: 575px) { 192 | body, 193 | html { 194 | overflow: hidden; 195 | } 196 | } 197 | ::-webkit-resizer { 198 | display: none; 199 | } 200 | b, 201 | strong { 202 | font-weight: 700; 203 | color: var(--color-title); 204 | } 205 | .btn { 206 | display: inline-flex; 207 | align-items: center; 208 | justify-content: center; 209 | column-gap: var(--btn-gap, 0.5rem); 210 | background-color: var(--btn-bg, var(--bg-transparent)); 211 | padding: var(--btn-padding, 0.75rem); 212 | color: var(--btn-color, var(--color-title)); 213 | font-size: var(--btn-fs, 0.875rem); 214 | font-weight: 700; 215 | border-radius: 999px; 216 | line-height: 1.5; 217 | transition-property: box-shadow, background-color, color; 218 | transition-duration: var(--duration); 219 | text-transform: uppercase; 220 | } 221 | .btn:hover { 222 | color: var(--btn-color-hover, var(--color-title)); 223 | } 224 | .btn-full { 225 | width: 100%; 226 | justify-content: center; 227 | } 228 | .truncate { 229 | overflow: hidden; 230 | text-overflow: ellipsis; 231 | white-space: nowrap; 232 | } 233 | .truncate-line { 234 | display: -webkit-box; 235 | -webkit-line-clamp: var(--line-clamp, 2); 236 | -webkit-box-orient: vertical; 237 | overflow: hidden; 238 | } 239 | .app { 240 | background-color: var(--bg-app); 241 | position: relative; 242 | overflow: hidden; 243 | height: 100vh; 244 | width: 100vw; 245 | } 246 | .app::after { 247 | content: ""; 248 | inset: 0; 249 | position: absolute; 250 | background-image: linear-gradient(transparent 70%, #000); 251 | z-index: 5; 252 | } 253 | .header { 254 | position: absolute; 255 | width: 100%; 256 | z-index: 50; 257 | } 258 | .header-wrapper { 259 | padding: var(--main-padding); 260 | } 261 | .header-logo-img { 262 | height: 80px; 263 | } 264 | .toggle-options { 265 | gap: 0.5rem; 266 | } 267 | @media (min-width: 992px) { 268 | :root { 269 | --main-padding: 3vw; 270 | } 271 | .btn { 272 | --btn-fs: 0.875vw; 273 | --btn-padding: 0.75vw; 274 | --i-size: 1.25vw; 275 | --btn-gap: 0.5vw; 276 | } 277 | .header-logo-img { 278 | max-width: 10vw; 279 | height: auto; 280 | } 281 | .toggle-options { 282 | gap: 0.5vw; 283 | } 284 | } 285 | @media (max-width: 991px) { 286 | .header { 287 | background: var(--accent, var(--bg-gradient)); 288 | box-shadow: var(--shadow-l); 289 | } 290 | .toggle-options { 291 | --btn-fs: 0; 292 | --btn-gap: 0; 293 | --i-size: 16px; 294 | } 295 | } 296 | .main > * + * { 297 | margin-top: 3rem; 298 | } 299 | .scrollbar { 300 | overflow: auto; 301 | scrollbar-color: var(--scrollbar-color) transparent; 302 | scrollbar-width: thin; 303 | } 304 | .scrollbar::-webkit-scrollbar { 305 | width: 5px; 306 | height: 5px; 307 | background-color: transparent; 308 | } 309 | .scrollbar::-webkit-scrollbar-track { 310 | background-color: transparent; 311 | border-radius: 5px; 312 | } 313 | .scrollbar::-webkit-scrollbar-thumb { 314 | background-color: var(--scrollbar-color); 315 | border-radius: 10px; 316 | } 317 | .dropdown { 318 | position: absolute; 319 | width: 140px; 320 | background-color: var(--bg-dark); 321 | padding: 1.5rem; 322 | border-radius: 1rem; 323 | left: 50%; 324 | box-shadow: var(--shadow-l); 325 | transform: translateX(-50%); 326 | bottom: calc(100% + 0.5rem); 327 | transition: opacity var(--duration), transform var(--duration); 328 | } 329 | .dropdown:not(.is-active) { 330 | pointer-events: none; 331 | opacity: 0; 332 | transform: translateX(-50%) translateY(-1rem); 333 | } 334 | @media (min-width: 992px) { 335 | .footer { 336 | position: absolute; 337 | padding: var(--main-padding); 338 | bottom: 0; 339 | left: 0; 340 | display: inline-flex; 341 | z-index: 10; 342 | } 343 | .footer-wrapper { 344 | gap: 1.25vw; 345 | } 346 | } 347 | .footer small { 348 | font-size: 1rem; 349 | } 350 | @media (max-width: 991px) { 351 | .footer-app { 352 | padding: var(--main-padding); 353 | border-top: 1px solid; 354 | border-bottom: 1px solid; 355 | justify-content: center; 356 | } 357 | .footer-copyright { 358 | padding: var(--main-padding); 359 | text-align: center; 360 | } 361 | .footer-tv { 362 | padding-bottom: 1rem; 363 | text-align: center; 364 | } 365 | .mobile-menu { 366 | position: fixed; 367 | height: 100vh; 368 | padding-top: calc(2rem + 72px); 369 | z-index: 40; 370 | background: var(--accent, var(--bg-gradient)); 371 | transition: transform var(--duration); 372 | width: 100%; 373 | } 374 | .mobile-menu:not(.is-active) { 375 | pointer-events: none; 376 | transform: translateY(-100%); 377 | } 378 | .player-social { 379 | justify-content: center; 380 | padding: var(--main-padding); 381 | } 382 | } 383 | .i { 384 | stroke-width: var(--i-stroke, 2); 385 | width: var(--i-size, 24px); 386 | height: var(--i-size, 24px); 387 | stroke: currentColor; 388 | stroke-linecap: round; 389 | stroke-linejoin: round; 390 | fill: none; 391 | } 392 | @keyframes pulse { 393 | from { 394 | opacity: 0; 395 | } 396 | 50% { 397 | opacity: 0.2; 398 | } 399 | to { 400 | transform: scale(1.5); 401 | opacity: 0; 402 | } 403 | } 404 | .player { 405 | padding: 2rem; 406 | position: fixed; 407 | inset: 0; 408 | z-index: 10; 409 | overflow-y: auto; 410 | } 411 | @media (max-width: 991px) { 412 | .player { 413 | padding-top: calc(2rem + 72px); 414 | } 415 | } 416 | .player-cover-title { 417 | text-shadow: 0 0.052vw 0.052vw #000; 418 | } 419 | .player-cover-image { 420 | --cover-blurred: 1rem; 421 | position: absolute; 422 | z-index: 0; 423 | object-fit: cover; 424 | object-position: center; 425 | transition: opacity calc(var(--duration) * 3); 426 | filter: blur(var(--cover-blurred)); 427 | max-width: initial; 428 | inset: calc(var(--cover-blurred) * -5); 429 | width: calc(100% + var(--cover-blurred) * 10); 430 | height: calc(100% + var(--cover-blurred) * 10); 431 | } 432 | .player-wrapper { 433 | margin-top: auto; 434 | margin-bottom: auto; 435 | position: relative; 436 | z-index: 10; 437 | } 438 | .player-artwork { 439 | background-color: var(--primary); 440 | border-radius: calc(1rem + 1vw); 441 | box-shadow: var(--shadow-l); 442 | overflow: hidden; 443 | width: 100%; 444 | max-width: 400px; 445 | aspect-ratio: 1/1; 446 | display: flex; 447 | } 448 | .player-artwork img { 449 | transition: transform calc(var(--duration) * 3); 450 | } 451 | .player-controller { 452 | display: flex; 453 | align-items: center; 454 | gap: 1rem; 455 | } 456 | .player-volume { 457 | position: absolute; 458 | inset: 0; 459 | opacity: 0; 460 | pointer-events: none; 461 | } 462 | .player-range-fill { 463 | position: absolute; 464 | top: 0; 465 | left: 0; 466 | height: 100%; 467 | transition: background-color var(--duration); 468 | background-color: var(--accent, var(--primary)); 469 | } 470 | .player-range-wrapper { 471 | position: relative; 472 | height: 2px; 473 | width: 100%; 474 | background-color: rgba(255, 255, 255, 0.25); 475 | } 476 | .player-range-thumb { 477 | width: 15px; 478 | height: 15px; 479 | transition: background-color var(--duration); 480 | background-color: var(--accent, var(--primary)); 481 | border-radius: 5rem; 482 | top: 50%; 483 | position: absolute; 484 | transform: translateY(-50%); 485 | cursor: pointer; 486 | } 487 | .player-button { 488 | color: rgba(255, 255, 255, 0.75); 489 | transition: color var(--duration), background-color var(--duration); 490 | position: relative; 491 | } 492 | @media (min-width: 992px) { 493 | .footer small { 494 | font-size: 0.8vw; 495 | } 496 | .player-controller { 497 | padding-top: 1.5rem; 498 | gap: 2rem; 499 | } 500 | .player-button { 501 | --i-size: 1.5vw; 502 | } 503 | .player-section-audio { 504 | max-width: 390px; 505 | } 506 | } 507 | .player-button-volume { 508 | display: flex; 509 | align-items: center; 510 | } 511 | @media (max-width: 991px) { 512 | .player-button-volume { 513 | opacity: 0.25; 514 | pointer-events: none; 515 | } 516 | } 517 | .player-button.is-active, 518 | .player-button:hover { 519 | color: #fff; 520 | } 521 | .player-button-play { 522 | padding: 1rem; 523 | border-radius: 999px; 524 | transition: background-color var(--duration); 525 | background-color: var(--accent, var(--bg-transparent)); 526 | } 527 | .player-button-play::after, 528 | .player-button-play::before { 529 | pointer-events: none; 530 | content: ""; 531 | position: absolute; 532 | height: 100%; 533 | width: 100%; 534 | background-color: #fff; 535 | border-radius: 50%; 536 | z-index: -1; 537 | inset: 0; 538 | opacity: 0; 539 | animation: 2s ease-out infinite pulse; 540 | display: var(--pulse-state, none); 541 | } 542 | .player-button-play:after { 543 | animation-delay: 1s; 544 | } 545 | .player-button-play:active, 546 | .player-button-play:focus { 547 | outline: 0; 548 | } 549 | .player-button-play.is-active { 550 | --pulse-state: block; 551 | } 552 | .player-section-audio { 553 | flex: none; 554 | } 555 | .player-section-meta { 556 | width: 100%; 557 | } 558 | .player-social { 559 | filter: drop-shadow(0 2px 2px rgba(0, 0, 0, 0.1)) drop-shadow(0 3px 1px rgba(0, 0, 0, 0.075)); 560 | gap: 0.5rem; 561 | } 562 | @media (min-width: 992px) { 563 | .player-social { 564 | position: absolute; 565 | padding: var(--main-padding); 566 | z-index: 50; 567 | max-width: 40vw; 568 | bottom: 1vw; 569 | right: 0; 570 | gap: 1.125vw; 571 | } 572 | } 573 | .player-social-item { 574 | border: 1px solid #fff; 575 | border-radius: 999px; 576 | padding: 0.75rem; 577 | --i-size: 20px; 578 | } 579 | .player-social-item:not(:hover) { 580 | color: #fff; 581 | } 582 | .player-apps-item { 583 | transition: filter var(--duration); 584 | } 585 | @media (min-width: 992px) { 586 | .player-social-item { 587 | padding: 0.75vw; 588 | --i-size: 1.25vw; 589 | } 590 | .player-apps-item img { 591 | width: auto; 592 | height: 3vw; 593 | } 594 | } 595 | .player-apps-item:hover { 596 | filter: drop-shadow(0 0px 10px white); 597 | } 598 | .player-program { 599 | position: absolute; 600 | display: flex; 601 | flex-direction: column; 602 | justify-content: center; 603 | align-items: center; 604 | width: 100%; 605 | padding: 1rem; 606 | color: #fff; 607 | text-transform: uppercase; 608 | background-image: linear-gradient(transparent, rgba(0, 0, 0, 0.6)); 609 | z-index: 10; 610 | inset: auto 0 0; 611 | } 612 | .player-program-badge { 613 | font-size: 0.75rem; 614 | padding: 0.125rem 0.5rem; 615 | background-color: #c62828; 616 | border-radius: 0.5rem; 617 | } 618 | .player-program-time-container { 619 | display: flex; 620 | align-items: center; 621 | gap: 0.5rem; 622 | } 623 | .player-program-name { 624 | font-weight: 700; 625 | font-family: "Akira Expanded", sans-serif; 626 | } 627 | .player-program-description { 628 | font-size: 0.875rem; 629 | } 630 | .station { 631 | transition: opacity var(--duration); 632 | } 633 | .station-img { 634 | width: 120px; 635 | aspect-ratio: 1/1; 636 | box-shadow: var(--shadow-l); 637 | border-radius: 0.5rem; 638 | } 639 | .station:not(.is-active) { 640 | opacity: 0.5; 641 | } 642 | .station:hover { 643 | opacity: 1; 644 | } 645 | .history { 646 | --cols-min: 20rem; 647 | } 648 | .history-item { 649 | padding: 0.75rem; 650 | background-color: rgba(255, 255, 255, 0.1); 651 | border-radius: 0.5rem; 652 | box-shadow: var(--shadow-l); 653 | width: 100%; 654 | position: relative; 655 | padding-right: calc(0.75rem + 35px); 656 | max-width: 290px; 657 | overflow: hidden; 658 | z-index: 1; 659 | } 660 | .history-item::before { 661 | content: ""; 662 | inset: 0; 663 | position: absolute; 664 | z-index: -1; 665 | background: var(--accent); 666 | opacity: 0.5; 667 | } 668 | .history-spotify { 669 | bottom: 0.75rem; 670 | right: 0.75rem; 671 | position: absolute; 672 | color: #fff; 673 | transition: opacity var(--duration); 674 | } 675 | .history-spotify:not(:hover) { 676 | opacity: 0.5; 677 | } 678 | .history-spotify[href="#not-found"] { 679 | opacity: 0.1; 680 | pointer-events: none; 681 | } 682 | .history-image { 683 | width: 64px; 684 | aspect-ratio: 1/1; 685 | } 686 | .history-image img { 687 | object-fit: cover; 688 | height: 100%; 689 | width: 100%; 690 | } 691 | .visualizer { 692 | position: absolute; 693 | filter: url(#gooey); 694 | inset: auto -20px -20px; 695 | z-index: 0; 696 | pointer-events: none; 697 | display: flex; 698 | align-items: flex-end; 699 | justify-content: space-around; 700 | height: 100%; 701 | opacity: 0.5; 702 | } 703 | .visualizer-filter { 704 | display: none; 705 | } 706 | .modal { 707 | position: fixed; 708 | max-width: 900px; 709 | margin: 0 auto; 710 | z-index: 120; 711 | inset: 1rem; 712 | transition: opacity var(--duration); 713 | display: flex; 714 | } 715 | .modal:not(.is-active) { 716 | pointer-events: none; 717 | opacity: 0; 718 | } 719 | .modal-content { 720 | max-height: 100%; 721 | width: 100%; 722 | background-color: var(--bg-dark); 723 | border-radius: 0.5rem; 724 | box-shadow: var(--shadow-xl); 725 | display: flex; 726 | flex-direction: column; 727 | padding: var(--main-padding); 728 | margin: auto; 729 | } 730 | .modal-title { 731 | margin-bottom: 1.5rem; 732 | line-height: 1; 733 | padding-bottom: 1.5rem; 734 | border-bottom: 1px solid rgba(255, 255, 255, 0.2); 735 | } 736 | .modal-body { 737 | font-size: 1.125rem; 738 | } 739 | .modal-overlay { 740 | opacity: var(--modal-overlay-opacity, 0); 741 | z-index: 100; 742 | position: absolute; 743 | inset: 0; 744 | pointer-events: none; 745 | background: rgba(0, 0, 0, 0.5); 746 | backdrop-filter: blur(1rem); 747 | transition: opacity var(--duration); 748 | } 749 | .modal.is-active ~ * { 750 | --modal-overlay-opacity: 1; 751 | } 752 | .modal-video { 753 | inset: 50% auto auto 50%; 754 | position: absolute; 755 | margin: auto; 756 | transform: translate(-50%, -50%); 757 | width: 100%; 758 | max-width: 880px; 759 | padding: 1rem; 760 | background-color: var(--bg-modal); 761 | border-radius: 0.5rem; 762 | z-index: 150; 763 | box-shadow: var(--shadow-xl); 764 | transition: opacity var(--duration); 765 | } 766 | .modal-video [data-close] { 767 | position: absolute; 768 | right: -1.25rem; 769 | top: -1.25rem; 770 | } 771 | .modal-video:not(.is-active) { 772 | visibility: hidden; 773 | pointer-events: none; 774 | opacity: 0; 775 | } 776 | .modal-video iframe { 777 | aspect-ratio: 16/9; 778 | width: 100%; 779 | height: auto; 780 | } 781 | .modal-video.is-active ~ * { 782 | --modal-overlay-opacity: 1; 783 | } 784 | .offcanvas { 785 | background-color: var(--bg-modal); 786 | inset: 0 0 0 auto; 787 | position: absolute; 788 | padding: 1.5rem; 789 | z-index: 120; 790 | box-shadow: var(--shadow-xl); 791 | transition: transform var(--duration), opacity var(--duration); 792 | backdrop-filter: blur(1rem); 793 | } 794 | .offcanvas:not(.is-active) { 795 | transform: translateX(110%); 796 | pointer-events: none; 797 | opacity: 0; 798 | } 799 | .offcanvas [data-close] { 800 | margin-bottom: 1rem; 801 | } 802 | .absolute { 803 | position: absolute; 804 | } 805 | .relative { 806 | position: relative; 807 | } 808 | .fixed { 809 | position: fixed; 810 | } 811 | .sticky { 812 | position: sticky; 813 | } 814 | .z-10 { 815 | z-index: 10; 816 | } 817 | .z-20 { 818 | z-index: 20; 819 | } 820 | .z-30 { 821 | z-index: 30; 822 | } 823 | .z-40 { 824 | z-index: 40; 825 | } 826 | .z-50 { 827 | z-index: 50; 828 | } 829 | .z-60 { 830 | z-index: 60; 831 | } 832 | .z-70 { 833 | z-index: 70; 834 | } 835 | .z-80 { 836 | z-index: 80; 837 | } 838 | .z-90 { 839 | z-index: 90; 840 | } 841 | .z-100 { 842 | z-index: 100; 843 | } 844 | .g-0\.25 { 845 | gap: 0.25rem; 846 | } 847 | .g-0\.5 { 848 | gap: 0.5rem; 849 | } 850 | .g-0\.75 { 851 | gap: 0.75rem; 852 | } 853 | .g-0\.875 { 854 | gap: 0.875rem; 855 | } 856 | .g-1 { 857 | gap: 1rem; 858 | } 859 | .g-1\.25 { 860 | gap: 1.25rem; 861 | } 862 | .g-1\.5 { 863 | gap: 1.5rem; 864 | } 865 | .g-1\.75 { 866 | gap: 1.75rem; 867 | } 868 | .g-2 { 869 | gap: 2rem; 870 | } 871 | .block { 872 | display: block; 873 | } 874 | .inline-block { 875 | display: inline-block; 876 | } 877 | .inline { 878 | display: inline; 879 | } 880 | .flex { 881 | display: flex; 882 | } 883 | .inline-flex { 884 | display: inline-flex; 885 | } 886 | .grid { 887 | display: grid; 888 | } 889 | .inline-grid { 890 | display: inline-grid; 891 | } 892 | .none { 893 | display: none; 894 | } 895 | .items-start { 896 | align-items: flex-start; 897 | } 898 | .items-end { 899 | align-items: flex-end; 900 | } 901 | .items-center { 902 | align-items: center; 903 | } 904 | .justify-start { 905 | justify-content: flex-start; 906 | } 907 | .justify-end { 908 | justify-content: flex-end; 909 | } 910 | .justify-center { 911 | justify-content: center; 912 | } 913 | .justify-between { 914 | justify-content: space-between; 915 | } 916 | .justify-around { 917 | justify-content: space-around; 918 | } 919 | .justify-evenly { 920 | justify-content: space-evenly; 921 | } 922 | .row { 923 | flex-direction: row; 924 | } 925 | .column { 926 | flex-direction: column; 927 | } 928 | @media (min-width: 576px) { 929 | .s\:g-0\.25 { 930 | gap: 0.25rem; 931 | } 932 | .s\:g-0\.5 { 933 | gap: 0.5rem; 934 | } 935 | .s\:g-0\.75 { 936 | gap: 0.75rem; 937 | } 938 | .s\:g-0\.875 { 939 | gap: 0.875rem; 940 | } 941 | .s\:g-1 { 942 | gap: 1rem; 943 | } 944 | .s\:g-1\.25 { 945 | gap: 1.25rem; 946 | } 947 | .s\:g-1\.5 { 948 | gap: 1.5rem; 949 | } 950 | .s\:g-1\.75 { 951 | gap: 1.75rem; 952 | } 953 | .s\:g-2 { 954 | gap: 2rem; 955 | } 956 | .s\:block { 957 | display: block; 958 | } 959 | .s\:inline-block { 960 | display: inline-block; 961 | } 962 | .s\:inline { 963 | display: inline; 964 | } 965 | .s\:flex { 966 | display: flex; 967 | } 968 | .s\:inline-flex { 969 | display: inline-flex; 970 | } 971 | .s\:grid { 972 | display: grid; 973 | } 974 | .s\:inline-grid { 975 | display: inline-grid; 976 | } 977 | .s\:none { 978 | display: none; 979 | } 980 | .s\:row { 981 | flex-direction: row; 982 | } 983 | .s\:column { 984 | flex-direction: column; 985 | } 986 | } 987 | @media (min-width: 768px) { 988 | .m\:g-0\.25 { 989 | gap: 0.25rem; 990 | } 991 | .m\:g-0\.5 { 992 | gap: 0.5rem; 993 | } 994 | .m\:g-0\.75 { 995 | gap: 0.75rem; 996 | } 997 | .m\:g-0\.875 { 998 | gap: 0.875rem; 999 | } 1000 | .m\:g-1 { 1001 | gap: 1rem; 1002 | } 1003 | .m\:g-1\.25 { 1004 | gap: 1.25rem; 1005 | } 1006 | .m\:g-1\.5 { 1007 | gap: 1.5rem; 1008 | } 1009 | .m\:g-1\.75 { 1010 | gap: 1.75rem; 1011 | } 1012 | .m\:g-2 { 1013 | gap: 2rem; 1014 | } 1015 | .m\:block { 1016 | display: block; 1017 | } 1018 | .m\:inline-block { 1019 | display: inline-block; 1020 | } 1021 | .m\:inline { 1022 | display: inline; 1023 | } 1024 | .m\:flex { 1025 | display: flex; 1026 | } 1027 | .m\:inline-flex { 1028 | display: inline-flex; 1029 | } 1030 | .m\:grid { 1031 | display: grid; 1032 | } 1033 | .m\:inline-grid { 1034 | display: inline-grid; 1035 | } 1036 | .m\:none { 1037 | display: none; 1038 | } 1039 | .m\:row { 1040 | flex-direction: row; 1041 | } 1042 | .m\:column { 1043 | flex-direction: column; 1044 | } 1045 | } 1046 | @media (min-width: 992px) { 1047 | .l\:g-0\.25 { 1048 | gap: 0.25rem; 1049 | } 1050 | .l\:g-0\.5 { 1051 | gap: 0.5rem; 1052 | } 1053 | .l\:g-0\.75 { 1054 | gap: 0.75rem; 1055 | } 1056 | .l\:g-0\.875 { 1057 | gap: 0.875rem; 1058 | } 1059 | .l\:g-1 { 1060 | gap: 1rem; 1061 | } 1062 | .l\:g-1\.25 { 1063 | gap: 1.25rem; 1064 | } 1065 | .l\:g-1\.5 { 1066 | gap: 1.5rem; 1067 | } 1068 | .l\:g-1\.75 { 1069 | gap: 1.75rem; 1070 | } 1071 | .l\:g-2 { 1072 | gap: 2rem; 1073 | } 1074 | .l\:block { 1075 | display: block; 1076 | } 1077 | .l\:inline-block { 1078 | display: inline-block; 1079 | } 1080 | .l\:inline { 1081 | display: inline; 1082 | } 1083 | .l\:flex { 1084 | display: flex; 1085 | } 1086 | .l\:inline-flex { 1087 | display: inline-flex; 1088 | } 1089 | .l\:grid { 1090 | display: grid; 1091 | } 1092 | .l\:inline-grid { 1093 | display: inline-grid; 1094 | } 1095 | .l\:none { 1096 | display: none; 1097 | } 1098 | .l\:row { 1099 | flex-direction: row; 1100 | } 1101 | .l\:column { 1102 | flex-direction: column; 1103 | } 1104 | } 1105 | @media (min-width: 1200px) { 1106 | .xl\:g-0\.25 { 1107 | gap: 0.25rem; 1108 | } 1109 | .xl\:g-0\.5 { 1110 | gap: 0.5rem; 1111 | } 1112 | .xl\:g-0\.75 { 1113 | gap: 0.75rem; 1114 | } 1115 | .xl\:g-0\.875 { 1116 | gap: 0.875rem; 1117 | } 1118 | .xl\:g-1 { 1119 | gap: 1rem; 1120 | } 1121 | .xl\:g-1\.25 { 1122 | gap: 1.25rem; 1123 | } 1124 | .xl\:g-1\.5 { 1125 | gap: 1.5rem; 1126 | } 1127 | .xl\:g-1\.75 { 1128 | gap: 1.75rem; 1129 | } 1130 | .xl\:g-2 { 1131 | gap: 2rem; 1132 | } 1133 | .xl\:block { 1134 | display: block; 1135 | } 1136 | .xl\:inline-block { 1137 | display: inline-block; 1138 | } 1139 | .xl\:inline { 1140 | display: inline; 1141 | } 1142 | .xl\:flex { 1143 | display: flex; 1144 | } 1145 | .xl\:inline-flex { 1146 | display: inline-flex; 1147 | } 1148 | .xl\:grid { 1149 | display: grid; 1150 | } 1151 | .xl\:inline-grid { 1152 | display: inline-grid; 1153 | } 1154 | .xl\:none { 1155 | display: none; 1156 | } 1157 | .xl\:row { 1158 | flex-direction: row; 1159 | } 1160 | .xl\:column { 1161 | flex-direction: column; 1162 | } 1163 | } 1164 | .wrap { 1165 | flex-wrap: wrap; 1166 | } 1167 | .wrap-reverse { 1168 | flex-wrap: wrap-reverse; 1169 | } 1170 | .nowrap { 1171 | flex-wrap: nowrap; 1172 | } 1173 | .flex-1 { 1174 | flex: 1 1 0; 1175 | } 1176 | .flex-auto { 1177 | flex: auto; 1178 | } 1179 | .flex-initial { 1180 | flex: initial; 1181 | } 1182 | .flex-none { 1183 | flex: none; 1184 | } 1185 | .content-start { 1186 | align-content: flex-start; 1187 | } 1188 | .content-end { 1189 | align-content: flex-end; 1190 | } 1191 | .content-center { 1192 | align-content: center; 1193 | } 1194 | .content-between { 1195 | align-content: space-between; 1196 | } 1197 | .content-around { 1198 | align-content: space-around; 1199 | } 1200 | .content-evenly { 1201 | align-content: space-evenly; 1202 | } 1203 | .auto-fill { 1204 | grid-template-columns: repeat(auto-fill, minmax(min(100%, var(--cols-min, 16rem)), 1fr)); 1205 | } 1206 | .auto-fit { 1207 | grid-template-columns: repeat(auto-fit, minmax(min(100%, var(--cols-min, 16rem)), 1fr)); 1208 | } 1209 | .o-auto { 1210 | overflow: auto; 1211 | } 1212 | .o-hidden { 1213 | overflow: hidden; 1214 | } 1215 | .ox-auto { 1216 | overflow-x: auto; 1217 | } 1218 | .ox-hidden { 1219 | overflow-x: hidden; 1220 | } 1221 | .oy-auto { 1222 | overflow-y: auto; 1223 | } 1224 | .oy-hidden { 1225 | overflow-y: hidden; 1226 | } 1227 | .events-none { 1228 | pointer-events: none; 1229 | } 1230 | .events-auto { 1231 | pointer-events: auto; 1232 | } 1233 | .color-primary { 1234 | color: var(--primary); 1235 | } 1236 | .color-text { 1237 | color: var(--color-text); 1238 | } 1239 | .color-title { 1240 | color: var(--color-title); 1241 | } 1242 | .fs-1 { 1243 | font-size: 2.5rem; 1244 | } 1245 | .fs-2 { 1246 | font-size: 1.75rem; 1247 | } 1248 | .fs-3 { 1249 | font-size: 1.5rem; 1250 | } 1251 | .fs-4 { 1252 | font-size: 1.25rem; 1253 | } 1254 | .fs-5 { 1255 | font-size: 1.125rem; 1256 | } 1257 | .fs-6 { 1258 | font-size: 1rem; 1259 | } 1260 | .fs-7 { 1261 | font-size: 0.875rem; 1262 | } 1263 | .fs-8 { 1264 | font-size: 0.75rem; 1265 | } 1266 | @media (min-width: 576px) { 1267 | .s\:fs-1 { 1268 | font-size: 2.5rem; 1269 | } 1270 | .s\:fs-2 { 1271 | font-size: 1.75rem; 1272 | } 1273 | .s\:fs-3 { 1274 | font-size: 1.5rem; 1275 | } 1276 | .s\:fs-4 { 1277 | font-size: 1.25rem; 1278 | } 1279 | .s\:fs-5 { 1280 | font-size: 1.125rem; 1281 | } 1282 | .s\:fs-6 { 1283 | font-size: 1rem; 1284 | } 1285 | .s\:fs-7 { 1286 | font-size: 0.875rem; 1287 | } 1288 | .s\:fs-8 { 1289 | font-size: 0.75rem; 1290 | } 1291 | } 1292 | @media (min-width: 768px) { 1293 | .m\:fs-1 { 1294 | font-size: 2.5rem; 1295 | } 1296 | .m\:fs-2 { 1297 | font-size: 1.75rem; 1298 | } 1299 | .m\:fs-3 { 1300 | font-size: 1.5rem; 1301 | } 1302 | .m\:fs-4 { 1303 | font-size: 1.25rem; 1304 | } 1305 | .m\:fs-5 { 1306 | font-size: 1.125rem; 1307 | } 1308 | .m\:fs-6 { 1309 | font-size: 1rem; 1310 | } 1311 | .m\:fs-7 { 1312 | font-size: 0.875rem; 1313 | } 1314 | .m\:fs-8 { 1315 | font-size: 0.75rem; 1316 | } 1317 | } 1318 | @media (min-width: 992px) { 1319 | .l\:fs-1 { 1320 | font-size: 2.5rem; 1321 | } 1322 | .l\:fs-2 { 1323 | font-size: 1.75rem; 1324 | } 1325 | .l\:fs-3 { 1326 | font-size: 1.5rem; 1327 | } 1328 | .l\:fs-4 { 1329 | font-size: 1.25rem; 1330 | } 1331 | .l\:fs-5 { 1332 | font-size: 1.125rem; 1333 | } 1334 | .l\:fs-6 { 1335 | font-size: 1rem; 1336 | } 1337 | .l\:fs-7 { 1338 | font-size: 0.875rem; 1339 | } 1340 | .l\:fs-8 { 1341 | font-size: 0.75rem; 1342 | } 1343 | } 1344 | @media (min-width: 1200px) { 1345 | .xl\:fs-1 { 1346 | font-size: 2.5rem; 1347 | } 1348 | .xl\:fs-2 { 1349 | font-size: 1.75rem; 1350 | } 1351 | .xl\:fs-3 { 1352 | font-size: 1.5rem; 1353 | } 1354 | .xl\:fs-4 { 1355 | font-size: 1.25rem; 1356 | } 1357 | .xl\:fs-5 { 1358 | font-size: 1.125rem; 1359 | } 1360 | .xl\:fs-6 { 1361 | font-size: 1rem; 1362 | } 1363 | .xl\:fs-7 { 1364 | font-size: 0.875rem; 1365 | } 1366 | .xl\:fs-8 { 1367 | font-size: 0.75rem; 1368 | } 1369 | } 1370 | .fw-100 { 1371 | font-weight: 100; 1372 | } 1373 | .fw-200 { 1374 | font-weight: 200; 1375 | } 1376 | .fw-300 { 1377 | font-weight: 300; 1378 | } 1379 | .fw-400 { 1380 | font-weight: 400; 1381 | } 1382 | .fw-500 { 1383 | font-weight: 500; 1384 | } 1385 | .fw-600 { 1386 | font-weight: 600; 1387 | } 1388 | .fw-700 { 1389 | font-weight: 700; 1390 | } 1391 | .fw-800 { 1392 | font-weight: 800; 1393 | } 1394 | .fw-900 { 1395 | font-weight: 900; 1396 | } 1397 | .text-center { 1398 | text-align: center; 1399 | } 1400 | .text-left { 1401 | text-align: left; 1402 | } 1403 | .text-right { 1404 | text-align: right; 1405 | } 1406 | .text-justify { 1407 | text-align: justify; 1408 | } 1409 | .capitalize { 1410 | text-transform: capitalize; 1411 | } 1412 | .uppercase { 1413 | text-transform: uppercase; 1414 | } 1415 | .lowercase { 1416 | text-transform: lowercase; 1417 | } 1418 | .underline { 1419 | text-decoration: underline; 1420 | } 1421 | .line-through { 1422 | text-decoration: line-through; 1423 | } 1424 | -------------------------------------------------------------------------------- /custom.css: -------------------------------------------------------------------------------- 1 | .station-description { 2 | color: #fff; 3 | opacity: 0.6; 4 | } 5 | .station-img { 6 | border: 3px solid #ffffff00; 7 | padding: 0.15rem; 8 | } 9 | .player-artwork { 10 | padding: 0.75rem; 11 | border-radius: 1rem; 12 | background-color: #ffffff00; 13 | } 14 | .player-artwork img { 15 | border-radius: 0.65rem; 16 | box-shadow: var(--shadow-xl); 17 | } 18 | .player-cover-image { 19 | animation: bga 60s linear infinite; 20 | } 21 | @keyframes bga { 22 | 50% { 23 | transform: scale(2); 24 | } 25 | } 26 | .items-start { 27 | align-items: flex-start; 28 | margin-top: 15px; 29 | } 30 | 31 | 32 | -------------------------------------------------------------------------------- /documentation.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jailsonsb2/Radioplayer_api/83bc7e378cccef30c8953381485606d51f66e6b4/documentation.pdf -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Jailson Webradio 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 59 | 60 | 61 | 62 | 63 |
64 | cover 65 | 66 |
67 |
68 | 71 | 72 |
73 | 81 | 88 | 93 |
94 |
95 |
96 | 97 |
98 |
99 |
100 |
101 | Artist 102 | Song 103 |
104 |
105 | artwork 106 |
107 |
108 | 109 |
110 |
111 | 116 | 123 |
124 | 125 | 130 | 131 | 136 | 137 | 142 | 143 | 149 |
150 |
151 |
152 |
153 | 154 | 155 | 156 | 157 | 158 | 159 | 160 | 161 | 162 | 163 |
164 | 165 | 173 | 174 | 182 | 195 | 201 | 202 |
203 |
204 | 211 |
212 | 213 |
214 |
215 |
216 | 217 | 218 | -------------------------------------------------------------------------------- /index_multiradio.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Multiradio 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 118 | 119 | 120 | 121 | 122 |
123 | cover 124 | 125 |
126 |
127 | 130 | 131 |
132 | 140 | 147 | 152 |
153 |
154 |
155 | 156 |
157 |
158 |
159 |
160 | Artist 161 | Song 162 |
163 |
164 | artwork 165 |
166 |
167 | 168 |
169 |
170 | 175 | 182 |
183 | 184 | 189 | 190 | 195 | 196 | 201 | 202 | 208 |
209 |
210 |
211 |
212 | 213 | 214 | 215 | 216 | 217 | 218 | 219 | 220 | 221 | 222 |
223 | 224 | 232 | 233 | 241 | 254 | 260 | 261 |
262 |
263 | 270 |
271 | 272 |
273 |
274 |
275 | 276 | 277 | -------------------------------------------------------------------------------- /js/main.js: -------------------------------------------------------------------------------- 1 | (function () { 2 | "use strict"; 3 | 4 | // --- [CONFIGURAÇÕES] ----------------------------------------------- 5 | 6 | const API_KEY_LYRICS = "1637b78dc3b129e6843ed674489a92d0"; 7 | const API_URL = "https://free.radioapi.lat/met?url="; 8 | const TIME_TO_REFRESH = window?.streams?.timeRefresh || 10000; 9 | 10 | // --- [CONSTANTES E VARIÁVEIS] -------------------------------------- 11 | 12 | const buttons = document.querySelectorAll("[data-outside]"); 13 | const ACTIVE_CLASS = "is-active"; 14 | const cache = {}; 15 | 16 | const playButton = document.querySelector(".player-button-play"); 17 | const visualizerContainer = document.querySelector(".visualizer"); 18 | const songNow = document.querySelector(".song-now"); 19 | const stationsList = document.getElementById("stations"); 20 | const stationName = document.querySelector(".station-name"); 21 | const stationDescription = document.querySelector(".station-description"); 22 | const headerLogoImg = document.querySelector(".header-logo-img"); 23 | const playerArtwork = document.querySelector(".player-artwork img:first-child"); 24 | const playerCoverImg = document.querySelector(".player-cover-image"); 25 | const playerSocial = document.querySelector(".player-social"); 26 | const playerApps = document.querySelector(".footer-app"); 27 | const playerTv = document.querySelector(".footer-tv"); 28 | const playerTvModal = document.getElementById("modal-tv"); 29 | const playerProgram = document.querySelector(".player-program"); 30 | const lyricsContent = document.getElementById("lyrics"); 31 | const history = document.getElementById("history"); 32 | 33 | let currentStation; 34 | let activeButton; 35 | let currentSongPlaying; 36 | let timeoutId; 37 | 38 | const audio = new Audio(); 39 | audio.crossOrigin = "anonymous"; 40 | let hasVisualizer = false; 41 | 42 | 43 | // --- [FUNÇÕES UTILITÁRIAS] ----------------------------------------- 44 | 45 | function createElementFromHTML(htmlString) { 46 | const div = document.createElement("div"); 47 | div.innerHTML = htmlString.trim(); 48 | return div.firstChild; 49 | } 50 | 51 | function sanitizeText(text) { 52 | return text.replace(/^\d+\.\)\s/, "").replace(/
$/, ""); 53 | } 54 | 55 | function changeImageSize(url, size) { 56 | return url.replace(/100x100/, size); 57 | } 58 | 59 | function createTempImage(src) { 60 | return new Promise((resolve, reject) => { 61 | const img = document.createElement("img"); 62 | img.crossOrigin = "Anonymous"; 63 | img.src = `https://images.weserv.nl/?url=${src}`; 64 | img.onload = () => resolve(img); 65 | img.onerror = reject; 66 | }); 67 | } 68 | 69 | // --- [FUNÇÕES DE MANIPULAÇÃO DE ÁUDIO] ---------------------------- 70 | 71 | function handlePlayPause() { 72 | if (audio.paused) { 73 | play(audio); 74 | } else { 75 | pause(audio); 76 | } 77 | } 78 | 79 | function play(audio, newSource = null) { 80 | if (newSource) { 81 | audio.src = newSource; 82 | } 83 | 84 | // Adiciona evento 'canplay' para garantir que o áudio pode ser reproduzido 85 | audio.addEventListener('canplay', () => { 86 | audio.play(); 87 | playButton.innerHTML = icons.pause; 88 | playButton.classList.add("is-active"); 89 | document.body.classList.add("is-playing"); 90 | }); 91 | 92 | if (!hasVisualizer) { 93 | visualizer(audio, visualizerContainer); 94 | hasVisualizer = true; 95 | } 96 | 97 | audio.load(); 98 | } 99 | 100 | function pause(audio) { 101 | audio.pause(); 102 | playButton.innerHTML = icons.play; 103 | playButton.classList.remove("is-active"); 104 | document.body.classList.remove("is-playing"); 105 | } 106 | 107 | // --- [ÍCONES] ------------------------------------------------------ 108 | 109 | const icons = { 110 | play: '', 111 | pause: '', 112 | facebook: '', 113 | twitter: '', 114 | instagram: '', 115 | youtube: '', 116 | tiktok: '', 117 | whatsapp: '', 118 | telegram: '', 119 | tv: '', 120 | }; 121 | 122 | function outsideClick(button) { 123 | if (!button) return; 124 | const target = document.getElementById(button.dataset.outside); 125 | if (!target) return; 126 | button.addEventListener("click", () => { 127 | button.classList.toggle(ACTIVE_CLASS); 128 | target.classList.toggle(ACTIVE_CLASS); 129 | }); 130 | const clickOutside = (event) => { 131 | if (!target.contains(event.target) && !button.contains(event.target)) { 132 | button.classList.remove(ACTIVE_CLASS); 133 | target.classList.remove(ACTIVE_CLASS); 134 | } 135 | }; 136 | document.addEventListener("click", clickOutside); 137 | const close = target.querySelector("[data-close]"); 138 | if (close) { 139 | close.onclick = function () { 140 | button.classList.remove(ACTIVE_CLASS); 141 | target.classList.remove(ACTIVE_CLASS); 142 | }; 143 | } 144 | } 145 | 146 | buttons.forEach((button) => { 147 | outsideClick(button); 148 | }); 149 | 150 | function initCanvas(container) { 151 | const canvas = document.createElement("canvas"); 152 | canvas.setAttribute("id", "visualizerCanvas"); 153 | canvas.setAttribute("class", "visualizer-item"); 154 | container.appendChild(canvas); 155 | canvas.width = container.clientWidth; 156 | canvas.height = container.clientHeight; 157 | return canvas; 158 | } 159 | 160 | function resizeCanvas(canvas, container) { 161 | canvas.width = container.clientWidth; 162 | canvas.height = container.clientHeight; 163 | } 164 | 165 | const visualizer = (audio, container) => { 166 | if (!audio || !container) { 167 | return; 168 | } 169 | const options = { 170 | fftSize: container.dataset.fftSize || 2048, 171 | numBars: container.dataset.bars || 40, 172 | maxHeight: container.dataset.maxHeight || 255, 173 | }; 174 | const ctx = new AudioContext(); 175 | const audioSource = ctx.createMediaElementSource(audio); 176 | const analyzer = ctx.createAnalyser(); 177 | audioSource.connect(analyzer); 178 | audioSource.connect(ctx.destination); 179 | const frequencyData = new Uint8Array(analyzer.frequencyBinCount); 180 | const canvas = initCanvas(container); 181 | const canvasCtx = canvas.getContext("2d"); 182 | 183 | const renderBars = () => { 184 | resizeCanvas(canvas, container); 185 | analyzer.getByteFrequencyData(frequencyData); 186 | if (options.fftSize) { 187 | analyzer.fftSize = options.fftSize; 188 | } 189 | canvasCtx.clearRect(0, 0, canvas.width, canvas.height); 190 | for (let i = 0; i < options.numBars; i++) { 191 | const index = Math.floor((i + 10) * (i < options.numBars / 2 ? 2 : 1)); 192 | const fd = frequencyData[index]; 193 | const barHeight = Math.max(4, fd || 0) + options.maxHeight / 255; 194 | const barWidth = canvas.width / options.numBars; 195 | const x = i * barWidth; 196 | const y = canvas.height - barHeight; 197 | canvasCtx.fillStyle = "white"; 198 | canvasCtx.fillRect(x, y, barWidth + 1, barHeight); 199 | } 200 | requestAnimationFrame(renderBars); 201 | }; 202 | renderBars(); 203 | 204 | // Listener del cambio de espacio en la ventana 205 | window.addEventListener("resize", () => { 206 | resizeCanvas(canvas, container); 207 | }); 208 | }; 209 | 210 | async function getDataFrom({ artist, title, art, cover }) { 211 | let dataFrom = {}; 212 | let text = artist ? `${artist} - ${title}` : title; 213 | 214 | const cacheKey = text.toLowerCase(); 215 | if (cache[cacheKey]) { 216 | return cache[cacheKey]; 217 | } 218 | 219 | try { 220 | // 1. Tenta buscar no novo endpoint de busca primeiro 221 | dataFrom = await getDataFromSearch(artist, title, art, cover); 222 | 223 | // Se getDataFromSearch falhou e retornou #not-found, tenta o iTunes 224 | if (dataFrom.stream_url === "#not-found") { 225 | console.warn("Novo endpoint falhou, buscando no iTunes..."); 226 | dataFrom = await getDataFromITunes(artist, title, art, cover); 227 | } 228 | 229 | } catch (error) { 230 | console.error("Erro ao buscar dados:", error); 231 | // 2. Em caso de erro geral, tenta o iTunes como último recurso 232 | dataFrom = await getDataFromITunes(artist, title, art, cover); 233 | } 234 | 235 | cache[cacheKey] = dataFrom; 236 | return dataFrom; 237 | } 238 | 239 | 240 | async function getDataFromSearch(artist, title, defaultArt, defaultCover) { 241 | let text = artist ? `${artist} - ${title}` : title; 242 | const cacheKey = text.toLowerCase(); 243 | 244 | if (cache[cacheKey]) { 245 | return cache[cacheKey]; 246 | } 247 | 248 | try { 249 | const response = await fetch(`https://api.twj.es/search.php?query=${encodeURIComponent(text)}`); 250 | if (!response.ok) { 251 | throw new Error(`Erro na requisição para o novo endpoint de busca. Status: ${response.status}`); 252 | } 253 | const data = await response.json(); 254 | 255 | if (data.results) { 256 | const searchResults = data.results; 257 | const results = { 258 | title: searchResults.title || title, 259 | artist: searchResults.artist || artist, 260 | thumbnail: searchResults.artwork || defaultArt, 261 | art: searchResults.artwork || defaultArt, 262 | cover: searchResults.artwork || defaultCover, 263 | stream_url: searchResults.stream_url || "#not-found", 264 | }; 265 | cache[cacheKey] = results; 266 | return results; 267 | } else { 268 | console.log("Nenhum resultado encontrado no novo endpoint de busca."); 269 | return { 270 | title, 271 | artist, 272 | art: defaultArt, 273 | cover: defaultCover, 274 | stream_url: "#not-found", 275 | }; 276 | } 277 | } catch (error) { 278 | console.error("Erro ao buscar dados do novo endpoint de busca:", error); 279 | return { 280 | title, 281 | artist, 282 | art: defaultArt, 283 | cover: defaultCover, 284 | stream_url: "#not-found", 285 | }; 286 | } 287 | } 288 | 289 | async function getDataFromITunes(artist, title, defaultArt, defaultCover) { 290 | let text = artist ? `${artist} - ${title}` : title; 291 | const cacheKey = text.toLowerCase(); 292 | 293 | if (cache[cacheKey]) { 294 | return cache[cacheKey]; 295 | } 296 | 297 | try { 298 | const response = await fetch(`https://itunes.apple.com/search?term=${encodeURIComponent(text)}&media=music&limit=1`); 299 | if (!response.ok) { 300 | throw new Error(`Erro na requisição para o iTunes. Status: ${response.status}`); 301 | } 302 | const data = await response.json(); 303 | 304 | if (data.resultCount > 0) { 305 | const itunesData = data.results[0]; 306 | const results = { 307 | title: itunesData.trackName || title, 308 | artist: itunesData.artistName || artist, 309 | thumbnail: itunesData.artworkUrl100 || defaultArt, 310 | art: itunesData.artworkUrl100 311 | ? changeImageSize(itunesData.artworkUrl100, "600x600") 312 | : defaultArt, 313 | cover: itunesData.artworkUrl100 314 | ? changeImageSize(itunesData.artworkUrl100, "1500x1500") 315 | : defaultCover, 316 | stream_url: itunesData.trackViewUrl || "#not-found", // Adicionado trackViewUrl 317 | }; 318 | cache[cacheKey] = results; 319 | return results; 320 | } else { 321 | console.log("Nenhum resultado encontrado no iTunes."); 322 | return { 323 | title, 324 | artist, 325 | art: defaultArt, 326 | cover: defaultCover, 327 | stream_url: "#not-found", 328 | }; 329 | } 330 | } catch (error) { 331 | console.error("Erro ao buscar dados do iTunes:", error); 332 | return { 333 | title, 334 | artist, 335 | art: defaultArt, 336 | cover: defaultCover, 337 | stream_url: "#not-found", 338 | }; 339 | } 340 | } 341 | 342 | 343 | const getLyrics = async (artist, name) => { 344 | try { 345 | const response = await fetch(`https://api.vagalume.com.br/search.php?apikey=${API_KEY_LYRICS}&art=${encodeURIComponent(artist)}&mus=${encodeURIComponent(name)}`); 346 | const data = await response.json(); 347 | if (data.type === "exact" || data.type === "aprox") { 348 | const lyrics = data.mus[0].text; 349 | return lyrics; 350 | } else { 351 | return "Letra no disponible"; 352 | } 353 | } catch (error) { 354 | console.error("Error fetching lyrics:", error); 355 | return "Letra no disponible"; 356 | } 357 | }; 358 | 359 | function normalizeTitle(api) { 360 | let title; 361 | let artist; 362 | 363 | // Verifica se a API retorna informações no formato "last_played" 364 | if (api.last_played) { 365 | title = api.last_played.song; 366 | artist = api.last_played.artist; 367 | // Verifica se a API retorna informações diretamente em "song" e "artist" 368 | } else if (api.song && api.artist) { 369 | title = api.song; 370 | artist = api.artist; 371 | } else if (api.songtitle && api.songtitle.includes(" - ")) { 372 | title = api.songtitle.split(" - ")[0]; 373 | artist = api.songtitle.split(" - ")[1]; 374 | } else if (api.now_playing) { 375 | title = api.now_playing.song.title; 376 | artist = api.now_playing.song.artist; 377 | } else if (api.artist && api.title) { 378 | title = api.title; 379 | artist = api.artist; 380 | } else if (api.currenttrack_title) { 381 | title = api.currenttrack_title; 382 | artist = api.currenttrack_artist; 383 | } else if (api.title && api.djprofile && api.djusername) { 384 | title = api.title.split(" - ")[1]; 385 | artist = api.title.split(" - ")[0]; 386 | } else { 387 | title = api.currentSong; 388 | artist = api.currentArtist; 389 | } 390 | 391 | return { 392 | title, 393 | artist, 394 | }; 395 | } 396 | 397 | 398 | function normalizeHistory(api) { 399 | let artist; 400 | let song; 401 | let history = api.song_history || api.history || api.songHistory || []; 402 | history = history.slice(0, 4); 403 | 404 | const historyNormalized = history.map((item) => { 405 | if (api.song_history) { 406 | artist = item.song.artist; 407 | song = item.song.title; 408 | } else if (api.history) { 409 | artist = sanitizeText(item.artist || ""); 410 | song = sanitizeText(item.song || ""); 411 | } else if (api.songHistory) { 412 | // Corrigido: Acessando as propriedades dentro do objeto 'song' 413 | artist = item.song.artist; 414 | song = item.song.title; 415 | } 416 | return { 417 | artist, 418 | song, 419 | }; 420 | }); 421 | 422 | return historyNormalized; 423 | } 424 | 425 | 426 | // --- [FUNÇÕES DE MANIPULAÇÃO DA INTERFACE] ------------------------ 427 | 428 | function setAccentColor(image, colorThief) { 429 | const dom = document.documentElement; 430 | const metaThemeColor = document.querySelector("meta[name=theme-color]"); 431 | if (image.complete) { 432 | dom.setAttribute("style", `--accent: rgb(${colorThief.getColor(image)})`); 433 | metaThemeColor.setAttribute("content", `rgb(${colorThief.getColor(image)})`); 434 | } else { 435 | console.log("imagen no completa"); 436 | image.addEventListener("load", function () { 437 | dom.setAttribute("style", `--accent: rgb(${colorThief.getColor(image)})`); 438 | metaThemeColor.setAttribute("content", `rgb(${colorThief.getColor(image)})`); 439 | }); 440 | } 441 | } 442 | 443 | function createOpenTvButton(url) { 444 | const $button = document.createElement("button"); 445 | $button.classList.add("player-button-tv", "btn"); 446 | $button.innerHTML = icons.tv + "Tv ao vivo"; 447 | $button.addEventListener("click", () => { 448 | playerTvModal.classList.add("is-active"); 449 | pause(audio); 450 | const modalBody = playerTvModal.querySelector(".modal-body-video"); 451 | const closeButton = playerTvModal.querySelector("[data-close]"); 452 | const $iframe = document.createElement("iframe"); 453 | $iframe.src = url; 454 | $iframe.allowFullscreen = true; 455 | modalBody.appendChild($iframe); 456 | closeButton.addEventListener("click", () => { 457 | playerTvModal.classList.remove("is-active"); 458 | 459 | // al terminar de cerrar el modal, eliminar el iframe 460 | $iframe.remove(); 461 | }); 462 | }); 463 | playerTv.appendChild($button); 464 | } 465 | 466 | function createProgram(program) { 467 | if (!program) return; 468 | if (program.time) { 469 | const $div = document.createElement("div"); 470 | const $span = document.createElement("span"); 471 | $div.classList.add("player-program-time-container"); 472 | $span.classList.add("player-program-badge"); 473 | $span.textContent = "On Air"; 474 | $div.appendChild($span); 475 | const $time = document.createElement("span"); 476 | $time.classList.add("player-program-time"); 477 | $time.textContent = program.time; 478 | $div.appendChild($time); 479 | playerProgram.appendChild($div); 480 | } 481 | if (program.name) { 482 | const $name = document.createElement("span"); 483 | $name.classList.add("player-program-name"); 484 | $name.textContent = program.name; 485 | playerProgram.appendChild($name); 486 | } 487 | if (program.description) { 488 | const $description = document.createElement("span"); 489 | $description.classList.add("player-program-description"); 490 | $description.textContent = program.description; 491 | playerProgram.appendChild($description); 492 | } 493 | } 494 | 495 | function createSocialItem(url, icon) { 496 | const $a = document.createElement("a"); 497 | $a.classList.add("player-social-item"); 498 | $a.href = url; 499 | $a.target = "_blank"; 500 | $a.innerHTML = icons[icon]; 501 | return $a; 502 | } 503 | 504 | function createAppsItem(url, name) { 505 | const $a = document.createElement("a"); 506 | $a.classList.add("player-apps-item"); 507 | $a.href = url; 508 | $a.target = "_blank"; 509 | $a.innerHTML = `${name}`; 510 | return $a; 511 | } 512 | 513 | function createStreamItem(station, index, currentStation, callback) { 514 | const $button = document.createElement("button"); 515 | $button.classList.add("station"); 516 | $button.innerHTML = `station`; 517 | $button.dataset.index = index; 518 | $button.dataset.hash = station.hash; 519 | if (currentStation.stream_url === station.stream_url) { 520 | $button.classList.add("is-active"); 521 | activeButton = $button; 522 | } 523 | $button.addEventListener("click", () => { 524 | if ($button.classList.contains("is-active")) return; 525 | 526 | // Eliminar la clase "active" del botón activo anterior, si existe 527 | if (activeButton) { 528 | activeButton.classList.remove("is-active"); 529 | } 530 | 531 | // Agregar la clase "active" al botón actualmente presionado 532 | $button.classList.add("is-active"); 533 | activeButton = $button; // Actualizar el botón activo 534 | 535 | setAssetsInPage(station); 536 | play(audio, station.stream_url); 537 | if (history) { 538 | history.innerHTML = ""; 539 | } 540 | 541 | // Llamar a la función de devolución de llamada (callback) si se proporciona 542 | if (typeof callback === "function") { 543 | callback(station); 544 | } 545 | }); 546 | return $button; 547 | } 548 | 549 | function createStations(stations, currentStation, callback) { 550 | if (!stationsList) return; 551 | stationsList.innerHTML = ""; 552 | stations.forEach(async (station, index) => { 553 | const $fragment = document.createDocumentFragment(); 554 | const $button = createStreamItem(station, index, currentStation, callback); 555 | $fragment.appendChild($button); 556 | stationsList.appendChild($fragment); 557 | }); 558 | } 559 | 560 | // --- [ATUALIZA ELEMENTOS DA PÁGINA DA ESTAÇÃO] ------------------ 561 | 562 | function setAssetsInPage(station) { 563 | playerSocial.innerHTML = ""; 564 | playerApps.innerHTML = ""; 565 | playerProgram.innerHTML = ""; 566 | playerTv.innerHTML = ""; 567 | headerLogoImg.src = station.logo; 568 | playerArtwork.src = station.album; 569 | playerCoverImg.src = station.cover || station.album; 570 | stationName.textContent = station.name; 571 | stationDescription.textContent = station.description; 572 | if (station.social && playerSocial) { 573 | Object.keys(station.social).forEach((key) => { 574 | playerSocial.appendChild(createSocialItem(station.social[key], key)); 575 | }); 576 | } 577 | if (station.apps && playerApps) { 578 | Object.keys(station.apps).forEach((key) => { 579 | playerApps.appendChild(createAppsItem(station.apps[key], key)); 580 | }); 581 | } 582 | if (station.program && playerProgram) { 583 | createProgram(station.program); 584 | } 585 | if (station.tv_url && playerTv) { 586 | createOpenTvButton(station.tv_url); 587 | } 588 | } 589 | 590 | // --- [FUNÇÕES DE ATUALIZAÇÃO DE CONTEÚDO] ----------------------- 591 | 592 | function mediaSession(data) { 593 | const { title, artist, album, art } = data; 594 | if ("mediaSession" in navigator) { 595 | navigator.mediaSession.metadata = new MediaMetadata({ 596 | title, 597 | artist, 598 | album, 599 | artwork: [ 600 | { 601 | src: art, 602 | sizes: "512x512", 603 | type: "image/png", 604 | }, 605 | ], 606 | }); 607 | navigator.mediaSession.setActionHandler("play", () => { 608 | play(); 609 | }); 610 | navigator.mediaSession.setActionHandler("pause", () => { 611 | pause(); 612 | }); 613 | } 614 | } 615 | 616 | function currentSong(data) { 617 | const content = songNow; 618 | content.querySelector(".song-name").textContent = data.title; 619 | content.querySelector(".song-artist").textContent = data.artist; 620 | const artwork = content.querySelector(".player-artwork"); 621 | if (artwork) { 622 | const $img = document.createElement("img"); 623 | $img.src = data.art; 624 | $img.width = 600; 625 | $img.height = 600; 626 | 627 | // Cuando la imagen se haya cargado, insertarla en artwork 628 | $img.addEventListener("load", () => { 629 | artwork.appendChild($img); 630 | 631 | // eslint-disable-next-line no-undef 632 | const colorThief = new ColorThief(); 633 | 634 | // Ejecutar cada vez que cambie la imagen 635 | // Crear una imagen temporal para evitar errores de CORS 636 | createTempImage($img.src).then((img) => { 637 | setAccentColor(img, colorThief); 638 | }); 639 | 640 | // Animar la imagen para desplazarla hacia la izquierda con transform 641 | setTimeout(() => { 642 | artwork.querySelectorAll("img").forEach((img) => { 643 | // Establecer la transición 644 | img.style.transform = `translateX(${-img.width}px)`; 645 | 646 | // Esperar a que la animación termine 647 | img.addEventListener("transitionend", () => { 648 | // Eliminar todas las imágenes excepto la última 649 | artwork.querySelectorAll("img:not(:last-child)").forEach((img) => { 650 | img.remove(); 651 | }); 652 | img.style.transition = "none"; 653 | img.style.transform = "none"; 654 | setTimeout(() => { 655 | img.removeAttribute("style"); 656 | }, 1000); 657 | }); 658 | }); 659 | }, 100); 660 | }); 661 | } 662 | if (playerCoverImg) { 663 | const tempImg = new Image(); 664 | tempImg.src = data.cover || data.art; 665 | tempImg.addEventListener("load", () => { 666 | playerCoverImg.style.opacity = 0; 667 | 668 | // Esperar a que la animación termine 669 | playerCoverImg.addEventListener("transitionend", () => { 670 | playerCoverImg.src = data.cover || data.art; 671 | playerCoverImg.style.opacity = 1; 672 | }); 673 | }); 674 | } 675 | } 676 | 677 | function setHistory(data, current, server) { 678 | if (!history) return; 679 | history.innerHTML = historyTemplate.replace("{{art}}", pixel).replace("{{song}}", "Cargando historial...").replace("{{artist}}", "Artista").replace("{{stream_url}}", "#not-found"); 680 | if (!data) return; 681 | 682 | // max 10 items 683 | data = data.slice(0, 10); 684 | const promises = data.map(async (item) => { 685 | const { artist, song } = item; 686 | const { album, cover } = current; 687 | const dataFrom = await getDataFrom({ 688 | artist, 689 | title: song, 690 | art: album, 691 | cover, 692 | server, 693 | }); 694 | // Verifica se é Deezer e se stream_url é inválida 695 | if (server === 'deezer' && !dataFrom.stream_url) { 696 | dataFrom.stream_url = '#'; // Define como '#' para evitar link inválido 697 | } 698 | return historyTemplate 699 | .replace("{{art}}", dataFrom.thumbnail || dataFrom.art) 700 | .replace("{{song}}", dataFrom.title) 701 | .replace("{{artist}}", dataFrom.artist) 702 | .replace("{{stream_url}}", dataFrom.stream_url); 703 | }); 704 | Promise.all(promises) 705 | .then((itemsHTML) => { 706 | const $fragment = document.createDocumentFragment(); 707 | itemsHTML.forEach((itemHTML) => { 708 | $fragment.appendChild(createElementFromHTML(itemHTML)); 709 | }); 710 | history.innerHTML = ""; 711 | history.appendChild($fragment); 712 | }) 713 | .catch((error) => { 714 | console.error("Error:", error); 715 | }); 716 | } 717 | 718 | function setLyrics(artist, title) { 719 | if (!lyricsContent) return; 720 | getLyrics(artist, title) 721 | .then((lyrics) => { 722 | const $p = document.createElement("p"); 723 | $p.innerHTML = lyrics.replace(/\n/g, "
"); 724 | lyricsContent.innerHTML = ""; 725 | lyricsContent.appendChild($p); 726 | }) 727 | .catch((error) => { 728 | console.error("Error:", error); 729 | }); 730 | } 731 | 732 | // --- [INICIALIZAÇÃO DA APLICAÇÃO] ------------------------------- 733 | 734 | function initApp() { 735 | // Variables para almacenar información que se actualizará 736 | let currentSongPlaying; 737 | let timeoutId; 738 | const json = window.streams || {}; 739 | const stations = json.stations; 740 | currentStation = stations[0]; 741 | 742 | // Establecer los assets de la página 743 | setAssetsInPage(currentStation); 744 | 745 | // Establecer la fuente de audio 746 | audio.src = currentStation.stream_url; 747 | 748 | // Define o evento de clique para o botão play/pause 749 | if (playButton !== null) { 750 | playButton.addEventListener("click", handlePlayPause); 751 | } 752 | 753 | // Iniciar o stream ( atualizado para evitar valor undefined ) 754 | function init(current) { 755 | // Cancelar o timeout anterior 756 | if (timeoutId) clearTimeout(timeoutId); 757 | 758 | // Se a url da estação atual for diferente da estação atual, atualiza a informação 759 | if (currentStation.stream_url !== current.stream_url) { 760 | currentStation = current; 761 | } 762 | const server = currentStation.server || "itunes"; 763 | //const jsonUri = currentStation.api || API_URL + encodeURIComponent(current.stream_url); 764 | const jsonUri = currentStation.api || API_URL + current.stream_url; 765 | fetch(jsonUri) 766 | .then((response) => response.json()) 767 | .then(async (res) => { 768 | console.log(res); 769 | const current = normalizeTitle(res); 770 | console.log(current); 771 | 772 | // Se currentSong for diferente da música atual, atualiza a informação 773 | const title = current.title; 774 | if (currentSongPlaying !== title) { 775 | // Atualizar a música atual 776 | currentSongPlaying = title; 777 | let artist = current.artist; 778 | const art = currentStation.album; 779 | const cover = currentStation.cover; 780 | const history = normalizeHistory(res); 781 | 782 | // Verificar se o título e o artista não são undefined 783 | if (title && artist) { 784 | const dataFrom = await getDataFrom({ 785 | artist, 786 | title, 787 | art, 788 | cover, 789 | server, 790 | }); 791 | 792 | // Estabelecer dados da música atual 793 | currentSong(dataFrom); 794 | mediaSession(dataFrom); 795 | setLyrics(dataFrom.artist, dataFrom.title); 796 | setHistory(history, currentStation, server); 797 | } else { 798 | console.log("Título ou artista undefined, não será feita a busca pela capa do álbum."); 799 | } 800 | } 801 | }) 802 | .catch((error) => console.log(error)); 803 | timeoutId = setTimeout(() => { 804 | init(current); 805 | }, TIME_TO_REFRESH); 806 | } 807 | 808 | 809 | init(currentStation); 810 | createStations(stations, currentStation, (station) => { 811 | init(station); 812 | }); 813 | const nextStation = document.querySelector(".player-button-forward-step"); 814 | const prevStation = document.querySelector(".player-button-backward-step"); 815 | if (nextStation) { 816 | nextStation.addEventListener("click", () => { 817 | const next = stationsList.querySelector(".is-active").nextElementSibling; 818 | if (next) { 819 | next.click(); 820 | } 821 | }); 822 | } 823 | if (prevStation) { 824 | prevStation.addEventListener("click", () => { 825 | const prev = stationsList.querySelector(".is-active").previousElementSibling; 826 | if (prev) { 827 | prev.click(); 828 | } 829 | }); 830 | } 831 | 832 | // --- [CONTROLE DE VOLUME] -------------------------------------- 833 | 834 | const range = document.querySelector(".player-volume"); 835 | const rangeFill = document.querySelector(".player-range-fill"); 836 | const rangeWrapper = document.querySelector(".player-range-wrapper"); 837 | const rangeThumb = document.querySelector(".player-range-thumb"); 838 | let currentVolume = parseInt(localStorage.getItem("volume") || "100", 10) || 100; 839 | 840 | // Rango recorrido 841 | function setRangeWidth(percent) { 842 | rangeFill.style.width = `${percent}%`; 843 | } 844 | 845 | // Posición del thumb 846 | function setThumbPosition(percent) { 847 | const compensatedWidth = rangeWrapper.offsetWidth - rangeThumb.offsetWidth; 848 | const thumbPosition = (percent / 100) * compensatedWidth; 849 | rangeThumb.style.left = `${thumbPosition}px`; 850 | } 851 | 852 | // Actualiza el volumen al cambiar el rango 853 | function updateVolume(value) { 854 | range.value = value; 855 | setRangeWidth(value); 856 | setThumbPosition(value); 857 | localStorage.setItem("volume", value); 858 | audio.volume = value / 100; 859 | } 860 | 861 | // Valor inicial 862 | if (range !== null) { 863 | updateVolume(currentVolume); 864 | 865 | // Escucha el cambio del rango 866 | range.addEventListener("input", (event) => { 867 | updateVolume(parseInt(event.target.value, 10)); 868 | }); 869 | 870 | // Escucha el click en el rango 871 | rangeWrapper.addEventListener("mousedown", (event) => { 872 | const rangeRect = range.getBoundingClientRect(); 873 | const clickX = event.clientX - rangeRect.left; 874 | const percent = (clickX / range.offsetWidth) * 100; 875 | const value = Math.round((range.max - range.min) * (percent / 100)) + parseInt(range.min); 876 | updateVolume(value); 877 | }); 878 | 879 | // Escucha el movimiento del mouse 880 | rangeThumb.addEventListener("mousedown", () => { 881 | document.addEventListener("mousemove", handleThumbDrag); 882 | }); 883 | } 884 | 885 | // Mueve el thumb y actualiza el volumen 886 | function handleThumbDrag(event) { 887 | const rangeRect = range.getBoundingClientRect(); 888 | const clickX = event.clientX - rangeRect.left; 889 | let percent = (clickX / range.offsetWidth) * 100; 890 | percent = Math.max(0, Math.min(100, percent)); 891 | const value = Math.round((range.max - range.min) * (percent / 100)) + parseInt(range.min); 892 | updateVolume(value); 893 | } 894 | 895 | // Deja de escuchar el movimiento del mouse 896 | document.addEventListener("mouseup", () => { 897 | document.removeEventListener("mousemove", handleThumbDrag); 898 | }); 899 | 900 | // --- [FIM DO CONTROLE DE VOLUME] ----------------------------- 901 | 902 | } 903 | 904 | // --- [POP-UP DE INÍCIO E HANDLERS] -------------------------------- 905 | 906 | const pixel = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAAXNSR0IArs4c6QAAAA1JREFUGFdj+P//PxcACQYDCF0ysWYAAAAASUVORK5CYII="; 907 | const historyTemplate = `
908 |
909 | 910 |
911 |
912 | {{song}} 913 | {{artist}} 914 |
915 | 916 | 917 | 918 | 919 | 920 | 921 |
`; 922 | 923 | 924 | window.addEventListener("DOMContentLoaded", () => { 925 | document.body.classList.remove("preload"); 926 | // Adiciona o event listener para iniciar o audio após primeiro click na tela 927 | let hasClicked = false; 928 | document.body.addEventListener('click', () => { 929 | if (!hasClicked && !audio.playing) { 930 | handlePlayPause(); 931 | hasClicked = true; 932 | } 933 | }); 934 | }); 935 | 936 | // --- [INICIALIZA A APLICAÇÃO] ------------------------------------- 937 | initApp(); 938 | 939 | })(); 940 | -------------------------------------------------------------------------------- /manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Jailson Webradio", 3 | "short_name": "Jailson Webradio", 4 | "description": "Música sem Gospel sem parar!", 5 | "icons": [ 6 | { 7 | "src": "assets/jailson_cover.png", 8 | "type": "image/png", 9 | "sizes": "192x192" 10 | }, 11 | { 12 | "src": "assets/jailson_cover.png", 13 | "type": "image/png", 14 | "sizes": "512x512" 15 | } 16 | ], 17 | "start_url": "./?mode=pwa", 18 | "theme_color": "#FFF", 19 | "background_color": "#FFF", 20 | "orientation": "any", 21 | "display": "standalone", 22 | "display_override": [ 23 | "standalone" 24 | ], 25 | "lang": "pt-BR", 26 | "dir": "ltr", 27 | "categories": [ 28 | "music" 29 | ], 30 | "screenshots": [ 31 | { 32 | "src": "assets/jailson_cover.png", 33 | "type": "image/png", 34 | "sizes": "540x720", 35 | "form_factor": "narrow" 36 | }, 37 | { 38 | "src": "assets/jailson_cover.png", 39 | "type": "image/png", 40 | "sizes": "540x720", 41 | "form_factor": "narrow" 42 | }, 43 | { 44 | "src": "assets/jailson.png", 45 | "type": "image/png", 46 | "sizes": "720x540", 47 | "form_factor": "wide" 48 | } 49 | ], 50 | "related_applications": [ 51 | { 52 | "platform": "play", 53 | "url": "https://play.google.com/store/apps/details?id=com.jbcast.jwradio", 54 | "id": "com.jbcast.jwradio" 55 | } 56 | ] 57 | } 58 | --------------------------------------------------------------------------------