├── 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 |
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 |
--------------------------------------------------------------------------------