├── LICENSE.md ├── README.md ├── assets ├── waveform-example.jpg └── waveform-preview.png ├── manifest.json └── waveform.js /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) [year] [fullname] 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Spicetify Waveform Seekbar 2 | 3 | > ⚠️ **DEPRECATED - NO LONGER MAINTAINED** 4 | > 5 | > **As of January 2025, this extension is no longer functional or maintained.** 6 | > 7 | > Due to [changes in the Spotify Web API](https://developer.spotify.com/blog/2024-11-27-changes-to-the-web-api), the audio analysis endpoints required for this extension are no longer available. As a result, this extension cannot function as intended and will not be maintained further. 8 | > 9 | > This repository has been archived for historical reference. No further updates or support will be provided. 10 | > 11 | > Thank you to everyone who used and supported this project! 12 | 13 | --- 14 | 15 |

16 | Waveform Seekbar Example 17 |

18 | 19 | ## Description 20 | 21 | Waveform is a extension for Spicetify that replaces the default seekbar in the Spotify player with a dynamic waveform visualization. This extension fetches audio analysis data from Spotify's API and generates a visual representation of the track's waveform, similar to the SoundCloud player and basically all DJ software. 22 | 23 | ## Features 24 | 25 | - **Dynamic Waveform Visualization**: Replaces the standard seekbar with a waveform representation of the current track. 26 | - **Real-time Playback Progress**: The waveform updates in real-time to show the current playback position. 27 | - **Interactive Seeking**: Click anywhere on the waveform to seek to that position in the track. 28 | - **Hover Timestamps**: Displays the time at the cursor position when hovering over the waveform. 29 | - **Adaptive Coloring**: Automatically adjusts to Spicetify's color scheme. 30 | - **Loading Animation**: Shows a dynamic loading animation while fetching track data. 31 | - **Error Handling**: Gracefully falls back to the original seekbar if unable to fetch waveform data. 32 | 33 | ## Installation 34 | 35 | 1. Ensure you have [Spicetify](https://github.com/khanhas/spicetify-cli) installed. 36 | 2. Download `waveform.js` from this repository. 37 | 3. Place `waveform.js` in your Spicetify extensions directory: 38 | - Windows: `%appdata%\spicetify\Extensions\` 39 | - Linux: `~/.config/spicetify/Extensions/` 40 | - MacOS: `~/.config/spicetify/Extensions/` 41 | 4. Add the extension name to your Spicetify config: `spicetify config extensions waveform.js` 42 | 5. Apply the changes: `spicetify apply` 43 | 44 | Or, you can simply install this extension from the Spicetify Marketplace. 45 | 46 | ## Usage 47 | 48 | Once installed and enabled, the extension will automatically replace the default seekbar with the waveform visualization for each track. No additional user action is required. 49 | 50 | - **Seeking**: Click anywhere on the waveform to jump to that position in the track. 51 | - **Time Preview**: Hover over the waveform to see the time at that position. 52 | 53 | ## Customization 54 | 55 | The extension includes several customizable parameters: 56 | 57 | - `DEBUG`: Set to `true` for verbose console logging. 58 | - `SIMULATE_API_ERROR`: Set to `true` to test error handling. 59 | - `contrastFactor`: Adjust to change the contrast of the waveform (default: 4.0). 60 | - `maxRetryAttempts`: Number of retry attempts for API calls (default: 3). 61 | - `retryDelay`: Delay between retry attempts in milliseconds (default: 2000). 62 | 63 | To customize these, edit the values in the `waveform.js` file. 64 | 65 | ## Compatibility 66 | 67 | This extension is designed to work with the latest version of Spicetify. It may require updates to maintain compatibility with future Spicetify or Spotify client updates. 68 | 69 | ## Known Issues 70 | 71 | - The extension may not work if Spotify's audio analysis API changes or becomes unavailable. 72 | - Some tracks may not have audio analysis data available, in which case the original seekbar will be used. 73 | - This extension will likely not work with other extensions which also modify the seekbar. 74 | - If the player is paused and the waveform seekbar is clicked, the progress will not update until the track begins playing again. 75 | - Theme color changes may not apply while the track is paused. Playing the track will trigger the color updates. 76 | 77 | ## Contributing 78 | 79 | Contributions, issues, and feature requests are welcome. Feel free to check the [issues page](https://github.com/SPOTLAB-Live/Spicetify-waveform/issues) if you want to contribute. 80 | 81 | ## License 82 | 83 | This project is licensed under the [MIT License](https://github.com/SPOTLAB-Live/Spicetify-waveform/blob/main/LICENSE.md). Feel free to use, modify, and distribute the code as per the terms of this license. 84 | 85 | ## Acknowledgements 86 | 87 | - Inspired by this wonderful [concept by Lee Martin.](https://medium.com/swlh/creating-waveforms-out-of-spotify-tracks-b22030dd442b) 88 | - Thanks to the Spicetify community for their tools and support. 89 | -------------------------------------------------------------------------------- /assets/waveform-example.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SPOTLAB-Live/Spicetify-waveform/2851e4bd4951f93a4de727be483549fdf1b25202/assets/waveform-example.jpg -------------------------------------------------------------------------------- /assets/waveform-preview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SPOTLAB-Live/Spicetify-waveform/2851e4bd4951f93a4de727be483549fdf1b25202/assets/waveform-preview.png -------------------------------------------------------------------------------- /manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Waveform", 3 | "description": "Waveform seekbar generated from Spotify audio analysis API.", 4 | "preview": "assets/waveform-preview.png", 5 | "main": "waveform.js", 6 | "readme": "README.md", 7 | "authors": [ 8 | { 9 | "name": "SPOTLAB", 10 | "url": "https://github.com/SPOTLAB-Live" 11 | } 12 | ], 13 | "tags": [ 14 | "spicetify", 15 | "waveform", 16 | "wave", 17 | "dj", 18 | "soundcloud" 19 | ] 20 | } 21 | -------------------------------------------------------------------------------- /waveform.js: -------------------------------------------------------------------------------- 1 | // @ts-nocheck 2 | // NAME: Waveform 3 | // AUTHOR: SPOTLAB 4 | // VERSION: 1.0.1 5 | // DESCRIPTION: Waveform seekbar generated from Spotify audio analysis API. 6 | 7 | /// 8 | 9 | (function() { 10 | // Debug flag 11 | const DEBUG = false; // Set this to true for verbose console logging 12 | const SIMULATE_API_ERROR = false; // Set this to true to simulate an API error 13 | 14 | function debug(message) { 15 | if (DEBUG) { 16 | console.log(`Waveform extension: ${message}`); 17 | } 18 | } 19 | 20 | function error(message) { 21 | console.error(`Waveform extension: ${message}`); 22 | } 23 | 24 | // Wait for Spicetify to be fully loaded 25 | function waitForSpicetify() { 26 | debug("Waiting for Spicetify to load..."); 27 | if (!Spicetify.Player.data || !Spicetify.URI || !Spicetify.CosmosAsync) { 28 | setTimeout(waitForSpicetify, 300); 29 | return; 30 | } 31 | debug("Spicetify loaded. Initializing waveform seekbar."); 32 | initializeWaveformSeekbar(); 33 | } 34 | 35 | function initializeWaveformSeekbar() { 36 | debug("Initializing WaveformSeekbar class"); 37 | class WaveformSeekbar { 38 | constructor() { 39 | debug("WaveformSeekbar constructor called"); 40 | this.currentTrack = null; 41 | this.waveformData = null; 42 | this.canvas = null; 43 | this.seekBar = null; 44 | this.customSeekBar = null; 45 | this.originalSeekBar = null; 46 | this.originalSeekBarParent = null; 47 | this.originalSeekBarNextSibling = null; 48 | this.waveformDrawn = false; 49 | this.retryAttempts = 0; 50 | this.maxRetryAttempts = 3; 51 | this.retryDelay = 2000; // 2 seconds 52 | this.contrastFactor = 4.0; // Higher contrast, adjust as needed 53 | this.loadingAnimationFrame = null; 54 | this.usingCustomSeekBar = false; 55 | this.seekheadMarker = null; 56 | this.seekheadTime = null; 57 | 58 | this.updateColors = this.updateColors.bind(this); 59 | this.handleTrackChange = this.handleTrackChange.bind(this); 60 | Spicetify.Player.addEventListener("appchange", this.updateColors); 61 | 62 | this.initializeExtension().catch(err => error(`Initialization error: ${err}`)); 63 | } 64 | 65 | async initializeExtension() { 66 | debug("Initializing extension"); 67 | this.findSeekBar(); 68 | this.addEventListeners(); 69 | await this.processInitialTrack(); 70 | this.updateColors(); 71 | debug("Extension initialization complete"); 72 | } 73 | 74 | findSeekBar() { 75 | debug("Finding seek bar"); 76 | this.seekBar = document.querySelector('.playback-bar'); 77 | if (!this.seekBar) { 78 | error("Seek bar not found"); 79 | } else { 80 | debug("Seek bar found"); 81 | } 82 | } 83 | 84 | async processInitialTrack() { 85 | debug("Processing initial track"); 86 | const initialURI = this.getCurrentURI(); 87 | if (initialURI) { 88 | debug(`Initial track URI: ${initialURI}`); 89 | await this.showAnalysisForUri(initialURI); 90 | } else { 91 | debug("No initial track found"); 92 | } 93 | } 94 | 95 | addEventListeners() { 96 | debug("Adding event listeners"); 97 | Spicetify.Player.addEventListener("songchange", this.handleTrackChange); 98 | Spicetify.Player.addEventListener("onprogress", this.updatePlaybackPosition.bind(this)); 99 | } 100 | 101 | handleTrackChange() { 102 | const newURI = this.getCurrentURI(); 103 | if (newURI !== this.currentTrack) { 104 | this.showAnalysisForUri(newURI); 105 | } 106 | } 107 | 108 | getCurrentURI() { 109 | const data = Spicetify.Player.origin?.getState?.(); 110 | const uri = data?.item?.uri || null; 111 | debug(`Current URI: ${uri}`); 112 | return uri; 113 | } 114 | 115 | async showAnalysisForUri(URI) { 116 | debug(`Showing analysis for URI: ${URI}`); 117 | if (!URI || URI === this.currentTrack) { 118 | debug("URI unchanged or null, skipping analysis"); 119 | return; 120 | } 121 | 122 | this.currentTrack = URI; 123 | this.waveformData = null; 124 | this.waveformDrawn = false; 125 | 126 | if (!this.customSeekBar) { 127 | this.replaceSeekBar(); 128 | } else { 129 | this.usingCustomSeekBar = true; 130 | } 131 | 132 | this.drawLoadingAnimation(); // Start the loading animation 133 | this.retryAttempts = 0; 134 | 135 | this.resetSeekheadVisibility(); 136 | 137 | try { 138 | await this.fetchAudioAnalysisWithRetry(URI); 139 | this.drawWaveform(); 140 | } catch (err) { 141 | error(`Failed to show analysis for URI: ${err.message}`); 142 | this.handleFetchFailure(); 143 | } 144 | } 145 | 146 | async fetchAudioAnalysisWithRetry(trackUri) { 147 | debug(`Fetching audio analysis for ${trackUri}`); 148 | while (this.retryAttempts < this.maxRetryAttempts) { 149 | try { 150 | await this.fetchAudioAnalysis(trackUri); 151 | cancelAnimationFrame(this.loadingAnimationFrame); // Stop the loading animation 152 | debug("Audio analysis fetched successfully"); 153 | return; 154 | } catch (err) { 155 | error(`Failed to fetch audio analysis: ${err.message}`); 156 | this.retryAttempts++; 157 | if (this.retryAttempts < this.maxRetryAttempts) { 158 | debug(`Retrying in ${this.retryDelay}ms (attempt ${this.retryAttempts}/${this.maxRetryAttempts})`); 159 | await new Promise(resolve => setTimeout(resolve, this.retryDelay)); 160 | } 161 | } 162 | } 163 | error("Max retry attempts reached. Using original seekbar."); 164 | throw new Error("Failed to fetch audio analysis after max retries"); 165 | } 166 | 167 | async fetchAudioAnalysis(trackUri) { 168 | debug(`Fetching audio analysis from Spotify API for ${trackUri}`); 169 | if (!trackUri) { 170 | throw new Error("Invalid track URI"); 171 | } 172 | 173 | if (SIMULATE_API_ERROR) { 174 | debug("Simulating API error"); 175 | throw new Error("Simulated API error: 404 Not Found"); 176 | } 177 | 178 | const trackId = trackUri.split(':').pop(); 179 | const response = await Spicetify.CosmosAsync.get(`https://api.spotify.com/v1/audio-analysis/${trackId}`); 180 | debug("Audio analysis data received, processing..."); 181 | this.waveformData = this.processAudioAnalysis(response); 182 | } 183 | 184 | processAudioAnalysis(analysisData) { 185 | debug("Processing audio analysis data"); 186 | if (!analysisData || !analysisData.segments || !analysisData.track) { 187 | throw new Error("Invalid audio analysis data"); 188 | } 189 | 190 | const segments = analysisData.segments; 191 | const duration = analysisData.track.duration; 192 | 193 | const dataPoints = 1000; 194 | const segmentDuration = duration / dataPoints; 195 | 196 | let processedData = new Array(dataPoints).fill(0); 197 | 198 | segments.forEach(segment => { 199 | const startIndex = Math.floor(segment.start / segmentDuration); 200 | const endIndex = Math.min(Math.floor((segment.start + segment.duration) / segmentDuration), dataPoints - 1); 201 | 202 | // Normalize loudness to a value between 0 and 1 203 | const normalizedLoudness = 1 - (Math.min(Math.max(segment.loudness_max, -40), 0) / -40); 204 | 205 | // Apply contrast adjustment 206 | const adjustedLoudness = Math.pow(normalizedLoudness, this.contrastFactor); 207 | 208 | for (let i = startIndex; i <= endIndex; i++) { 209 | processedData[i] = Math.max(processedData[i], adjustedLoudness); 210 | } 211 | }); 212 | 213 | debug("Audio analysis processing complete"); 214 | return processedData; 215 | } 216 | 217 | replaceSeekBar() { 218 | debug("Replacing seek bar with custom waveform"); 219 | if (!this.seekBar) { 220 | error("Seek bar element not found"); 221 | return; 222 | } 223 | 224 | // Store the original seekbar 225 | this.originalSeekBar = this.seekBar; 226 | this.originalSeekBarParent = this.seekBar.parentNode; 227 | this.originalSeekBarNextSibling = this.seekBar.nextSibling; 228 | 229 | // Create our custom seekbar 230 | this.customSeekBar = document.createElement('div'); 231 | this.customSeekBar.style.width = '100%'; 232 | this.customSeekBar.style.height = '30px'; 233 | this.customSeekBar.style.position = 'relative'; 234 | 235 | this.canvas = document.createElement('canvas'); 236 | this.canvas.style.width = 'calc(100% - 80px)'; // Add 40px padding on each side 237 | this.canvas.style.height = '100%'; 238 | this.canvas.style.position = 'absolute'; 239 | this.canvas.style.left = '40px'; 240 | this.canvas.style.top = '0'; 241 | this.customSeekBar.appendChild(this.canvas); 242 | 243 | // Create containers for timestamps 244 | this.currentTimeLabel = document.createElement('div'); 245 | this.totalTimeLabel = document.createElement('div'); 246 | 247 | // Style the containers 248 | const timeStyle = ` 249 | position: absolute; 250 | top: 50%; 251 | transform: translateY(-50%); 252 | font-family: var(--font-family,CircularSp,CircularSp-Arab,CircularSp-Hebr,CircularSp-Cyrl,CircularSp-Grek,CircularSp-Deva,var(--fallback-fonts,sans-serif)); 253 | font-weight: var(--font-weight-normal, 400); 254 | font-size: var(--font-size-x-small, 11px); 255 | color: var(--text-subdued,#6a6a6a); 256 | letter-spacing: 0.1em; 257 | padding: 2px 5px; 258 | `; 259 | this.currentTimeLabel.style.cssText = timeStyle + 'left: 0;'; 260 | this.totalTimeLabel.style.cssText = timeStyle + 'right: 0;'; 261 | 262 | // Add the containers to the custom seekbar 263 | this.customSeekBar.appendChild(this.currentTimeLabel); 264 | this.customSeekBar.appendChild(this.totalTimeLabel); 265 | 266 | // Create seekhead marker 267 | this.seekheadMarker = document.createElement('div'); 268 | this.seekheadMarker.style.cssText = ` 269 | position: absolute; 270 | top: 0; 271 | width: 2px; 272 | height: 100%; 273 | background-color: var(--spice-subtext); 274 | opacity: 0; 275 | pointer-events: none; 276 | transition: opacity 0.1s ease; 277 | z-index: 10; 278 | `; 279 | this.customSeekBar.appendChild(this.seekheadMarker); 280 | 281 | // Create a separate element for the time display 282 | this.seekheadTime = document.createElement('div'); 283 | this.seekheadTime.style.cssText = ` 284 | position: absolute; 285 | top: -20px; 286 | transform: translateX(-50%); 287 | background-color: rgba(var(--spice-rgb-main), 0.7); 288 | color: var(--spice-subtext); 289 | padding: 2px 4px; 290 | border-radius: 3px; 291 | font-size: 10px; 292 | opacity: 0; 293 | pointer-events: none; 294 | transition: opacity 0.1s ease; 295 | z-index: 11; 296 | `; 297 | this.customSeekBar.appendChild(this.seekheadTime); 298 | 299 | // Replace the original seekbar with our custom one 300 | this.originalSeekBarParent.insertBefore(this.customSeekBar, this.originalSeekBar); 301 | this.originalSeekBarParent.removeChild(this.originalSeekBar); 302 | 303 | // Set canvas dimensions 304 | this.canvas.width = this.canvas.offsetWidth; 305 | this.canvas.height = this.canvas.offsetHeight; 306 | 307 | this.customSeekBar.addEventListener('click', this.onWaveformClick.bind(this)); 308 | this.customSeekBar.addEventListener('mousemove', this.onMouseMove.bind(this)); 309 | this.customSeekBar.addEventListener('mouseenter', this.onMouseEnter.bind(this)); 310 | this.customSeekBar.addEventListener('mouseleave', this.onMouseLeave.bind(this)); 311 | 312 | if (!this.canvas) { 313 | error("Failed to create canvas"); 314 | } else { 315 | debug("Canvas created successfully"); 316 | } 317 | 318 | this.usingCustomSeekBar = true; 319 | debug("Custom seekbar is now active"); 320 | } 321 | 322 | drawWaveform() { 323 | debug("Drawing waveform"); 324 | if (!this.canvas) { 325 | error("Canvas not available"); 326 | return; 327 | } 328 | if (!this.waveformData) { 329 | error("Waveform data not available"); 330 | return; 331 | } 332 | 333 | const ctx = this.canvas.getContext('2d'); 334 | const width = this.canvas.width; 335 | const height = this.canvas.height; 336 | 337 | ctx.clearRect(0, 0, width, height); 338 | 339 | const backgroundColor = getComputedStyle(document.documentElement) 340 | .getPropertyValue('--spice-button-disabled').trim() || '#b3b3b3'; 341 | 342 | const barWidth = width / this.waveformData.length; 343 | 344 | this.waveformData.forEach((loudness, index) => { 345 | const x = index * barWidth; 346 | const barHeight = loudness * height * 0.8; 347 | const y = (height - barHeight) / 2; 348 | ctx.fillStyle = backgroundColor; 349 | ctx.fillRect(x, y, barWidth - 1, barHeight); 350 | }); 351 | 352 | this.waveformDrawn = true; 353 | this.updatePlaybackPosition(); 354 | this.resetSeekheadVisibility(); 355 | debug("Waveform drawn successfully"); 356 | } 357 | 358 | updatePlaybackPosition() { 359 | if (!this.usingCustomSeekBar) { 360 | return; // Skip update if we're not using the custom seekbar 361 | } 362 | 363 | if (!this.canvas || !this.waveformDrawn) { 364 | debug("Skipping playback position update: Canvas or waveform not ready"); 365 | return; 366 | } 367 | 368 | const duration = Spicetify.Player.getDuration(); 369 | const currentTime = Spicetify.Player.getProgress(); 370 | const position = currentTime / duration; 371 | 372 | const width = this.canvas.width; 373 | const height = this.canvas.height; 374 | 375 | const ctx = this.canvas.getContext('2d'); 376 | 377 | const backgroundColor = getComputedStyle(document.documentElement) 378 | .getPropertyValue('--spice-button-disabled').trim() || '#b3b3b3'; 379 | 380 | const progressColor = getComputedStyle(document.documentElement) 381 | .getPropertyValue('--spice-button').trim() || '#1DB954'; 382 | 383 | const barWidth = width / this.waveformData.length; 384 | 385 | this.waveformData.forEach((loudness, index) => { 386 | const x = index * barWidth; 387 | const barHeight = loudness * height * 0.8; 388 | const y = (height - barHeight) / 2; 389 | 390 | if (x <= position * width) { 391 | ctx.fillStyle = progressColor; 392 | } else { 393 | ctx.fillStyle = backgroundColor; 394 | } 395 | 396 | ctx.fillRect(x, y, barWidth - 1, barHeight); 397 | }); 398 | 399 | // Update time labels 400 | this.currentTimeLabel.textContent = this.formatTime(currentTime); 401 | this.totalTimeLabel.textContent = this.formatTime(duration); 402 | } 403 | 404 | drawLoadingAnimation() { 405 | if (!this.canvas) { 406 | debug("Skipping loading animation: Canvas not available"); 407 | return; 408 | } 409 | 410 | const ctx = this.canvas.getContext('2d'); 411 | const width = this.canvas.width; 412 | const height = this.canvas.height; 413 | 414 | ctx.clearRect(0, 0, width, height); 415 | 416 | const barCount = 250; 417 | const barWidth = width / (barCount * 2); // Leave space between bars 418 | const maxBarHeight = height * 2; 419 | 420 | const animationSpeed = 0.002; 421 | const time = Date.now() * animationSpeed; 422 | 423 | ctx.fillStyle = getComputedStyle(document.documentElement) 424 | .getPropertyValue('--spice-button-disabled').trim() || '#1DB954'; 425 | 426 | for (let i = 0; i < barCount; i++) { 427 | const x = (i * 2 + 0.5) * barWidth; // Center each bar in its space 428 | 429 | // Create a wave-like pattern 430 | const waveFrequency = 0.15; 431 | const waveAmplitude = 0.5; 432 | const baseHeight = 0.1; 433 | 434 | const wave1 = Math.sin(time + i * waveFrequency) * waveAmplitude; 435 | const wave2 = Math.sin(time * 1.5 + i * waveFrequency * 0.5) * (waveAmplitude * 0.5); 436 | const wave3 = Math.sin(time * 0.5 + i * waveFrequency * 0.25) * (waveAmplitude * 0.25); 437 | 438 | const combinedWave = (wave1 + wave2 + wave3) / 3 + baseHeight; 439 | 440 | const barHeight = combinedWave * maxBarHeight; 441 | 442 | const y = (height - barHeight) / 2; 443 | ctx.fillRect(x, y, barWidth * 0.8, barHeight); // Slightly narrow bars for visual separation 444 | } 445 | 446 | this.loadingAnimationFrame = requestAnimationFrame(() => this.drawLoadingAnimation()); 447 | } 448 | 449 | onWaveformClick(event) { 450 | debug("Waveform clicked"); 451 | const rect = this.canvas.getBoundingClientRect(); 452 | const x = event.clientX - rect.left; 453 | const percentage = x / rect.width; 454 | 455 | const seekTime = percentage * Spicetify.Player.getDuration(); 456 | debug(`Seeking to ${seekTime}ms`); 457 | Spicetify.Player.seek(seekTime); 458 | 459 | setTimeout(() => { 460 | this.updatePlaybackPosition(); 461 | }, 0); 462 | } 463 | 464 | onMouseMove(event) { 465 | const rect = this.customSeekBar.getBoundingClientRect(); 466 | const canvasRect = this.canvas.getBoundingClientRect(); 467 | 468 | // Calculate the offset of the canvas within the custom seek bar 469 | const canvasOffset = canvasRect.left - rect.left; 470 | 471 | // Adjust x to account for the canvas offset 472 | const x = event.clientX - rect.left - canvasOffset; 473 | 474 | // Ensure x is within the bounds of the canvas 475 | const boundedX = Math.max(0, Math.min(x, canvasRect.width)); 476 | 477 | // Update seekhead marker position, adding the canvas offset back 478 | const markerPosition = boundedX + canvasOffset; 479 | this.seekheadMarker.style.left = `${markerPosition}px`; 480 | 481 | // Calculate and display time at cursor position 482 | const percentage = boundedX / canvasRect.width; 483 | const timeAtCursor = percentage * Spicetify.Player.getDuration(); 484 | this.seekheadTime.textContent = this.formatTime(timeAtCursor); 485 | 486 | // Position the time display 487 | this.seekheadTime.style.left = `${markerPosition}px`; 488 | 489 | // Ensure the seekhead marker is visible 490 | this.seekheadMarker.style.opacity = '1'; 491 | this.seekheadTime.style.opacity = '1'; 492 | } 493 | 494 | onMouseEnter() { 495 | this.seekheadMarker.style.opacity = '1'; 496 | this.seekheadTime.style.opacity = '1'; 497 | } 498 | 499 | onMouseLeave() { 500 | this.seekheadMarker.style.opacity = '0'; 501 | this.seekheadTime.style.opacity = '0'; 502 | } 503 | 504 | updateColors() { 505 | debug("Updating colors"); 506 | if (this.canvas && this.waveformData) { 507 | this.drawWaveform(); 508 | } else { 509 | debug("Skipping color update: Canvas or waveform data not ready"); 510 | } 511 | } 512 | 513 | restoreOriginalSeekBar() { 514 | debug("Restoring original seek bar"); 515 | if (this.originalSeekBar && this.customSeekBar && this.originalSeekBarParent) { 516 | // Remove our custom seekbar 517 | this.originalSeekBarParent.removeChild(this.customSeekBar); 518 | 519 | // Restore the original seekbar 520 | if (this.originalSeekBarNextSibling) { 521 | this.originalSeekBarParent.insertBefore(this.originalSeekBar, this.originalSeekBarNextSibling); 522 | } else { 523 | this.originalSeekBarParent.appendChild(this.originalSeekBar); 524 | } 525 | 526 | // Clear our references 527 | this.customSeekBar = null; 528 | this.canvas = null; 529 | 530 | // Remove event listeners 531 | if (this.customSeekBar) { 532 | this.customSeekBar.removeEventListener('mousemove', this.onMouseMove); 533 | this.customSeekBar.removeEventListener('mouseenter', this.onMouseEnter); 534 | this.customSeekBar.removeEventListener('mouseleave', this.onMouseLeave); 535 | } 536 | 537 | this.usingCustomSeekBar = false; 538 | debug("Reverted to original seekbar"); 539 | } else { 540 | error("Failed to restore original seek bar: Some elements are missing"); 541 | } 542 | } 543 | 544 | formatTime(milliseconds) { 545 | const totalSeconds = Math.floor(milliseconds / 1000); 546 | const minutes = Math.floor(totalSeconds / 60); 547 | const seconds = totalSeconds % 60; 548 | if (minutes < 60) { 549 | return `${minutes}:${seconds.toString().padStart(2, '0')}`; 550 | } else { 551 | const hours = Math.floor(minutes / 60); 552 | const remainingMinutes = minutes % 60; 553 | return `${hours}:${remainingMinutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`; 554 | } 555 | } 556 | 557 | resetSeekheadVisibility() { 558 | if (this.seekheadMarker) { 559 | this.seekheadMarker.style.opacity = '0'; 560 | } 561 | if (this.seekheadTime) { 562 | this.seekheadTime.style.opacity = '0'; 563 | } 564 | } 565 | 566 | handleFetchFailure() { 567 | cancelAnimationFrame(this.loadingAnimationFrame); // Stop the loading animation 568 | this.restoreOriginalSeekBar(); 569 | debug("Reverted to original seekbar due to fetch failure"); 570 | } 571 | } 572 | 573 | new WaveformSeekbar(); 574 | } 575 | 576 | // Start the initialization process 577 | debug("Starting Waveform extension"); 578 | waitForSpicetify(); 579 | })(); 580 | --------------------------------------------------------------------------------