Skip to content

Commit c074286

Browse files
committed
Refactor components + better seeking/volume drag behaviour
1 parent 629faa6 commit c074286

15 files changed

Lines changed: 385 additions & 238 deletions

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "@stronk-tech/react-librespot-controller",
33
"description": "`go-librespot` squeezebox-alike web frontend for small touchscreens",
4-
"version": "0.0.24",
4+
"version": "0.0.25",
55
"main": "dist/index.cjs.js",
66
"module": "dist/index.esm.js",
77
"files": [

src/components/Album/AlbumCard.js

Lines changed: 17 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,23 @@
1+
// Component to render a nice album cover.
12
import React, { useEffect, useState, useRef } from 'react';
23
import PlaceholderAlbum from "./PlaceHolderAlbum";
34
import './AlbumCard.css';
45

5-
// NOTE: should we make the gradient spin faster? Maybe a full revolution over the course of a song?
6+
const rotationUpdateInterval = 300; // Milliseconds between each gradient rotation update
7+
const rotationDenominator = 200; // How quickly the gradient rotates - Higher == slower, lower == quicker.
8+
const colorThreshold = 50; // Granularity for album color retrieval - Higher == more performance, lower == more dynamic range.
9+
10+
// TODO: add more comments, IE for props
11+
// TODO: should we make the gradient spin faster? Maybe a full revolution over the course of a song?
12+
// TODO: should we make it look more like a record player?
613
const AlbumCard = ({ title, subtitle, image, isStopped }) => {
7-
const [loaded, setLoaded] = useState(false);
8-
const [gradient, setGradient] = useState('');
9-
const canvasRef = useRef();
14+
const [loaded, setLoaded] = useState(false); //< Whether the album image is loaded - fades in the image once loaded
15+
const [gradient, setGradient] = useState(''); //< Holds the gradient CSS style (excluding rotation)
16+
const canvasRef = useRef(); //< Canvas ref used to extract dominant colors from the album image
17+
// Set rotation of gradient based on browser time
1018
const [rotationDegree, setRotationDegree] = useState(() => {
1119
const currentTime = new Date().getTime();
12-
return (currentTime / 200) % 360;
20+
return (currentTime / rotationDenominator) % 360;
1321
});
1422

1523
// Extract dominant colors using Canvas API
@@ -28,7 +36,6 @@ const AlbumCard = ({ title, subtitle, image, isStopped }) => {
2836
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
2937
const pixels = imageData.data;
3038
const colorCounts = {};
31-
const colorThreshold = 50; // Reduce granularity for performance
3239

3340
// Process pixels to count colors
3441
for (let i = 0; i < pixels.length; i += 4) {
@@ -50,6 +57,7 @@ const AlbumCard = ({ title, subtitle, image, isStopped }) => {
5057
: `linear-gradient(to right, ${dominantColors[0]}, ${dominantColors[0]})`;
5158
};
5259

60+
// Retrieve dominant colors every time the album image updates
5361
useEffect(() => {
5462
if (image) {
5563
const img = new Image();
@@ -70,16 +78,13 @@ const AlbumCard = ({ title, subtitle, image, isStopped }) => {
7078
}
7179
}, [image]);
7280

73-
// Effect to rotate the gradient continuously at a slower pace
81+
// On inital render set an interval to rotate the image
7482
useEffect(() => {
75-
const interval = setInterval(() => {
76-
const currentTime = new Date().getTime();
77-
setRotationDegree((currentTime / 200) % 360);
78-
}, 300);
79-
83+
const interval = setInterval(setRotationDegree, rotationUpdateInterval);
8084
return () => clearInterval(interval);
8185
}, []);
8286

87+
// Fill in the rotation on render
8388
const rotatingGradient = `${gradient.replace('to right', `${rotationDegree}deg`)}`;
8489

8590
return (

src/components/Album/PlaceHolderAlbum.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
1+
// Placeholder image for when the player is stopped or disconnected.
12
import React from "react";
23
import "./PlaceHolderAlbum.css";
34

4-
// TODO: we could do something fun here, like a bouncing DVD logo!
5+
// TODO: we could do something fun here like a bouncing DVD logo.
56
const PlaceholderAlbum = () => {
67
return (
78
<div className="spotify-player-placeholder-album">

src/components/Controls/MediaButtons.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
1+
// Media control buttons - play, pause, forward, backward, shuffle
12
import React from "react";
23
import { FaPlay, FaPause, FaStepForward, FaStepBackward, FaRandom } from "react-icons/fa";
34
import './MediaButtons.css';
45

6+
// TODO: add more comments, IE for props
57
const MediaButtons = ({
68
isPlaying,
79
handlePlayPause,

src/components/Controls/SeekControls.js

Lines changed: 48 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
1+
// Seek controls - displays the current playback position, duration and a draggable input
12
import React, { useState, useRef, useEffect } from "react";
23
import './SeekControls.css';
34

4-
const throttleDelay = 600;
5+
const throttleDelay = 500; //< Min amount of time between API calls
56

67
const formatTime = (milliseconds) => {
78
const totalSeconds = Math.floor(milliseconds / 1000);
@@ -10,25 +11,30 @@ const formatTime = (milliseconds) => {
1011
return `${minutes}:${seconds < 10 ? "0" : ""}${seconds}`;
1112
};
1213

14+
// TODO: add more comments, IE for props
1315
const SeekControls = ({
1416
duration,
15-
currentPosition,
17+
remotePosition,
1618
handleSeek,
1719
isStopped,
18-
isConnected
20+
isConnected,
21+
isPlaying
1922
}) => {
20-
const [localPosition, setLocalPosition] = useState(currentPosition);
21-
const timeoutRef = useRef(null);
22-
const lastCallTimeRef = useRef(0);
23+
const [localPosition, setLocalPosition] = useState(remotePosition); //< Local position for responsiveness while throttling outgoing API calls
24+
const intervalRef = useRef(null); //< Used to update the remotePosition on a timeout
25+
const timeoutRef = useRef(null); //< Used to space out API calls if we're quicker than the rate limit
26+
const lastCallTimeRef = useRef(0); //< Last time we sent out an API call to seek
27+
const isDraggingRef = useRef(false); //< Tracks whether the user is dragging
2328

29+
// Throttled seeking - instantly applies changes locally, but prevents spamming the API while dragging
2430
const onSeekChange = (e) => {
2531
const value = (e.target.value / 100) * duration;
2632
setLocalPosition(value);
33+
isDraggingRef.current = true;
2734

28-
// Throttled API call
2935
const now = Date.now();
3036
if (now - lastCallTimeRef.current > throttleDelay) {
31-
handleSeek(e); // Call the API
37+
handleSeek(e);
3238
lastCallTimeRef.current = now;
3339
} else {
3440
// Clear previous timeout if one exists
@@ -42,11 +48,40 @@ const SeekControls = ({
4248
}
4349
};
4450

51+
// Make the final API call when dragging ends
52+
const onSeekEnd = (e) => {
53+
if (timeoutRef.current) {
54+
clearTimeout(timeoutRef.current);
55+
}
56+
handleSeek(e);
57+
isDraggingRef.current = false;
58+
};
59+
60+
// Update the local position when the remote position updates - but only if we're not currently seeking
4561
useEffect(() => {
46-
if (!lastCallTimeRef.current || Date.now() - lastCallTimeRef.current > throttleDelay) {
47-
setLocalPosition(currentPosition);
62+
if (!isDraggingRef.current) {
63+
setLocalPosition(remotePosition);
64+
}
65+
}, [remotePosition]);
66+
67+
// Emulate playback for as long as we don't receive any updates on the remote position or are seeking
68+
useEffect(() => {
69+
if (isPlaying && duration) {
70+
intervalRef.current = setInterval(() => {
71+
// Skip if we are currently seeking
72+
if (isDraggingRef.current) {
73+
return;
74+
}
75+
setLocalPosition((prev) => {
76+
const nextPosition = prev + 1000;
77+
return nextPosition < duration ? nextPosition : duration;
78+
});
79+
}, 1000);
80+
} else {
81+
clearInterval(intervalRef.current);
4882
}
49-
}, [currentPosition]);
83+
return () => clearInterval(intervalRef.current);
84+
}, [isPlaying, duration]);
5085

5186
return (
5287
<div className="spotify-player-seek-container">
@@ -57,6 +92,8 @@ const SeekControls = ({
5792
max="100"
5893
value={(localPosition / duration) * 100}
5994
onChange={onSeekChange}
95+
onMouseUp={onSeekEnd}
96+
onTouchEnd={onSeekEnd}
6097
className="spotify-player-seek-bar"
6198
disabled={isStopped || !isConnected}
6299
/>

src/components/Controls/VolumeControls.js

Lines changed: 26 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,32 @@
1+
// Volume controls - displays the current volume and draggable input
12
import React, { useState, useRef, useEffect } from "react";
23
import { FaVolumeDown, FaVolumeUp } from "react-icons/fa";
34
import './VolumeControls.css';
45

5-
const throttleDelay = 500;
6+
const throttleDelay = 300; //< Min amount of time between API calls
67

8+
// TODO: add more comments, IE for props
79
const VolumeControls = ({
810
handleVolumeChange,
9-
volume,
11+
remoteVolume,
1012
maxVolume,
1113
isStopped,
1214
isConnected
1315
}) => {
14-
const [localVolume, setLocalVolume] = useState(volume);
15-
const timeoutRef = useRef(null);
16-
const lastCallTimeRef = useRef(0);
16+
const [localVolume, setLocalVolume] = useState(remoteVolume); //< Local volume for responsiveness while throttling outgoing API calls
17+
const timeoutRef = useRef(null); //< Used to space out API calls if we're quicker than the rate limit
18+
const lastCallTimeRef = useRef(0); //< Last time we sent out an API call to seek
19+
const isDraggingRef = useRef(false); //< Tracks whether the user is dragging
1720

21+
// Throttled volume change - instantly applies changes locally, but prevents spamming the API while dragging
1822
const onVolumeChange = (e) => {
1923
const value = (e.target.value / 100) * maxVolume;
2024
setLocalVolume(value);
25+
isDraggingRef.current = true;
2126

22-
// Throttled API call
2327
const now = Date.now();
24-
2528
if (now - lastCallTimeRef.current > throttleDelay) {
26-
handleVolumeChange(e); // Call the API
29+
handleVolumeChange(e);
2730
lastCallTimeRef.current = now;
2831
} else {
2932
// Clear previous timeout if one exists
@@ -37,11 +40,21 @@ const VolumeControls = ({
3740
}
3841
};
3942

43+
// Make the final API call when dragging ends
44+
const onDragEnd = (e) => {
45+
if (timeoutRef.current) {
46+
clearTimeout(timeoutRef.current);
47+
}
48+
handleVolumeChange(e);
49+
isDraggingRef.current = false;
50+
};
51+
52+
// Update the local volume when the remote volume updates - but only if we're not currently seeking
4053
useEffect(() => {
41-
if (!lastCallTimeRef.current || Date.now() - lastCallTimeRef.current > throttleDelay) {
42-
setLocalVolume(volume);
54+
if (!isDraggingRef.current) {
55+
setLocalVolume(remoteVolume);
4356
}
44-
}, [volume]);
57+
}, [remoteVolume]);
4558

4659
return (
4760
<div className="spotify-player-volume-control">
@@ -54,6 +67,8 @@ const VolumeControls = ({
5467
max="100"
5568
value={(localVolume / maxVolume) * 100}
5669
onChange={onVolumeChange}
70+
onMouseUp={onDragEnd}
71+
onTouchEnd={onDragEnd}
5772
className="spotify-player-volume-slider"
5873
disabled={isStopped || !isConnected}
5974
/>
Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
// Displays a header with the client device name and an icon depending on device type
12
import React from "react";
23
import {
34
GiCompactDisc,
@@ -11,8 +12,9 @@ import {
1112
} from "react-icons/gi";
1213
import { FaTabletAlt, FaCar, FaMusic, FaQuestionCircle, FaChromecast, FaExclamationCircle } from "react-icons/fa";
1314
import { MdWatch } from "react-icons/md";
14-
import './DeviceTitle.css';
15+
import './Header.css';
1516

17+
// Mapping from device type to which Icon we should render
1618
const deviceIcons = {
1719
computer: GiLaptop,
1820
tablet: FaTabletAlt,
@@ -33,10 +35,11 @@ const deviceIcons = {
3335
home_thing: GiRadioTower,
3436
};
3537

36-
const DeviceTitle = ({ isConnected, deviceName, isPlaying, deviceType, isStopped }) => {
37-
const name = deviceType?.toLowerCase();
38-
const Icon = isStopped ? GiNightSleep : deviceIcons[name] || FaQuestionCircle;
39-
38+
// TODO: expand with settings button
39+
// TODO: expand with playlists/albums/explore button once we can get albums/playlists from the API
40+
// TODO: add more comments, IE for props
41+
const Header = ({ isConnected, deviceName, isPlaying, deviceType, isStopped }) => {
42+
const Icon = isStopped ? GiNightSleep : deviceIcons[deviceType?.toLowerCase()] || FaQuestionCircle;
4043
return (
4144
<div className="spotify-player-device-title">
4245
{isConnected ? (
@@ -49,4 +52,4 @@ const DeviceTitle = ({ isConnected, deviceName, isPlaying, deviceType, isStopped
4952
);
5053
};
5154

52-
export default DeviceTitle;
55+
export default Header;
Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,34 @@
1+
// Renders a text box with info - like a table with playback info or an error message.
12
import React from "react";
2-
import './TrackDetails.css';
3+
import './TextInfo.css';
34

45
// TODO: should display the error somewhere at some point
5-
const TrackDetails = ({ track, formatReleaseDate, isStopped, isConnected, error }) => {
6+
// TODO: add more comments, IE for props
7+
const TextInfo = ({ track, isStopped, isConnected, error }) => {
68
if (isStopped || !isConnected) {
79
return (
810
<div className="spotify-player-track-details spotify-player-message">
911
{isConnected ? "The device is currently stopped. Please load a playlist or album." : "Not Connected."}
1012
</div>
1113
)
1214
}
15+
16+
// Tries to format the release date to YYYY-MM-DD
17+
const formatReleaseDate = (releaseDate) => {
18+
if (!releaseDate || !releaseDate.length){
19+
return "Unknown";
20+
}
21+
// Match the format with optional spaces: "year:YYYY month:MM day:DD"
22+
const match = releaseDate.match(/year:\s*(\d+)\s*month:\s*(\d+)\s*day:\s*(\d+)/);
23+
if (match) {
24+
const year = match[1];
25+
const month = match[2].padStart(2, "0"); // Ensure two-digit month
26+
const day = match[3].padStart(2, "0"); // Ensure two-digit day
27+
return `${year}-${month}-${day}`;
28+
}
29+
return releaseDate; // Return as-is for invalid formats
30+
};
31+
1332
return (
1433
<div className="spotify-player-track-details">
1534
<h4>{track?.name || "N/A"}</h4>
@@ -33,4 +52,4 @@ const TrackDetails = ({ track, formatReleaseDate, isStopped, isConnected, error
3352
)
3453
}
3554

36-
export default TrackDetails;
55+
export default TextInfo;

0 commit comments

Comments
 (0)