fuzzyworld

hawk / commit

Added a drop-down for the target player.

2c1835af15bb81af1dc5df7f85f8859d1af277ae · Pavel Safronov · 2023-10-19 16:33:08 -0700 · parents 7e06c2fcb323

Added a drop-down for the target player.
Added link styles to match the existing black/white theme, except the hover color is now "Spotify green".
 public/index.html       |  15 ++++--
 public/js/api.js        |  28 ++++++++++++
 public/js/controls.js   | 119 ++++++++++++++++++++++++++++++++++++++++++++++--
 public/js/main.js       |   5 +-
 public/styles/links.css |  21 +++++++++
 public/styles/main.css  |  29 ++++++++++--
 6 files changed, 204 insertions(+), 13 deletions(-)

diff --git a/public/index.html b/public/index.html
index 05d4562..d40e625 100644
--- a/public/index.html
+++ b/public/index.html
@@ -7,6 +7,7 @@
     <link rel="icon" type="image/png" href="images/favicon.png">
     <link rel="stylesheet" href="styles/collapsible.css">
     <link rel="stylesheet" href="styles/song.css">
+    <link rel="stylesheet" href="styles/links.css">
     <link rel="stylesheet" href="styles/main.css">
     <link rel="stylesheet"
         href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200" />
@@ -15,8 +16,8 @@
 <body>
     <!-- HEADER -->
     <div>
-        <div id="header">
-            <span class="right">
+        <div id="header" class="flex">
+            <span class="left">
                 <h1 id="logo-and-text">
                     <a href="https://pavelsafronov.gitlab.io/hawk/index.html">
                         <img id="logo" src="images/hawk.png" />
@@ -24,7 +25,7 @@
                     <span>Hawk</span>
                 </h1>
             </span>
-            <span class="left">
+            <span class="right">
                 <a href="https://spotify.com/" target="_blank">
                     <img id="spotify-logo" src="images/Spotify_Logo_RGB_Black.png" />
                 </a>
@@ -81,6 +82,10 @@
             </button>
         </div>
         <div class="signedin">
+            <div class="flex">
+                <h3 class="left">Target player:</h3>
+                <select id="devices" class="right" required></select>
+            </div>
             <div>
                 <h3>Search for a song by duration:</h3>
             </div>
@@ -96,8 +101,8 @@
                 <button id="search" onclick="searchForMatch()">Search</button>
             </div>
 
-            <div>
-                <span id="status"></span>
+            <div class="flex">
+                <div id="status"></div>
             </div>
 
             <div>
diff --git a/public/js/api.js b/public/js/api.js
index 0bff57e..7970bd7 100644
--- a/public/js/api.js
+++ b/public/js/api.js
@@ -182,3 +182,31 @@ async function pause() {
         }
     });
 }
+
+async function getAvailableDevices() {
+    const accessToken = await getAccessToken();
+    const response = await fetch("https://api.spotify.com/v1/me/player/devices", {
+        headers: {
+            Authorization: 'Bearer ' + accessToken
+        }
+    });
+    const data = await response.json();
+    return data.devices;
+}
+
+async function transferPlayback(deviceId) {
+    const accessToken = await getAccessToken();
+    const response = await fetch("https://api.spotify.com/v1/me/player", {
+        method: "PUT",
+        headers: {
+            Authorization: 'Bearer ' + accessToken
+        },
+        body: JSON.stringify({
+            device_ids: [deviceId]
+        })
+    });
+    if (response.status != 204) {
+        const json = await response.json();
+        throw new Error(`Failed to transfer playback to ${deviceId}: ${response.status}\n${JSON.stringify(json)}`);
+    }
+}
\ No newline at end of file
diff --git a/public/js/controls.js b/public/js/controls.js
index 96acb07..3d14665 100644
--- a/public/js/controls.js
+++ b/public/js/controls.js
@@ -1,11 +1,20 @@
-let statusText = '';
+let pageStatus = {
+    text: '',
+    isError: false
+};
 setInterval(() => {
     const statusDiv = document.getElementById('status');
-    statusDiv.innerText = statusText;
+    statusDiv.innerText = pageStatus.text;
+    if (pageStatus.isError) {
+        statusDiv.classList.add('error');
+    } else {
+        statusDiv.classList.remove('error');
+    }
 }, 100);
 
-function reportStatus(text) {
-    statusText = text;
+function reportStatus(text, isError = false) {
+    pageStatus.text = text;
+    pageStatus.isError = isError;
 }
 
 async function pauseOrPlay() {
@@ -19,6 +28,17 @@ async function pauseOrPlay() {
 }
 
 async function playChosenSong(trackName, trackId) {
+    // clear status
+    reportStatus('')
+
+    // get select element
+    const devicesList = document.getElementById('devices');
+    const deviceId = devicesList.value;
+    if (!deviceId) {
+        reportStatus('Cannot play track, no device selected', true);
+        return;
+    }
+
     try {
         await playTrack(trackId);
     } catch (e) {
@@ -165,3 +185,94 @@ async function setCurrentPlayingSong() {
     }
     currentSongSpan.innerHTML = html;
 }
+
+async function configureDevicesList() {
+    // get select element
+    const devicesList = document.getElementById('devices');
+
+    await updateDevicesList(devicesList);
+    setInterval(async () => {
+        await updateDevicesList(devicesList);
+    }, 1000 * 5);
+    devicesList.addEventListener('change', async () => {
+        const deviceId = devicesList.value;
+        saveDeviceId(deviceId);
+        if (deviceId) {
+            console.info(`Setting device to ${deviceId}`);
+            await transferPlayback(deviceId);
+        }
+    });
+}
+
+async function updateDevicesList(devicesList) {
+    // get available devices
+    const devices = await getAvailableDevices();
+
+    // add all devices to the select as options
+    let activeDeviceId = "";
+    for (let i = 0; i < devices.length; i++) {
+        const device = devices[i];
+        const ignoreDevice = device.is_restricted || device.is_private_session;
+
+        // if the device is active, save its id
+        if (device.is_active) {
+            activeDeviceId = device.id;
+        }
+
+        // check if the device is already in the list
+        const existingOption = devicesList.querySelector(`option[value="${device.id}"]`);
+        if (existingOption) {
+            if (ignoreDevice) {
+                // remove the device from the list
+                devicesList.removeChild(existingOption);
+            } else {
+                // update the name of the device, if it has changed
+                if (existingOption.text != device.name) {
+                    existingOption.text = device.name;
+                }
+                // enable the option, if it was disabled
+                if (existingOption.hasAttribute('disabled')) {
+                    existingOption.removeAttribute('disabled');
+                }
+            }
+        } else if (!ignoreDevice) {
+            // add the device to the list
+            const option = document.createElement('option');
+            option.value = device.id;
+            option.text = device.name;
+            devicesList.appendChild(option);
+        }
+    }
+
+    // disable options that are no longer available
+    const options = devicesList.querySelectorAll('option');
+    for (let i = 0; i < options.length; i++) {
+        const option = options[i];
+        const existingDevice = devices.find(device => device.id == option.value);
+        if (!existingDevice) {
+            option.setAttribute('disabled', 'disabled');
+            if (option.value == devicesList.value) {
+                devicesList.value = "";
+                devicesList.dispatchEvent(new Event('change'));
+            }
+        }
+    }
+
+    // if there is an active device, select it and save the id
+    if (activeDeviceId) {
+        devicesList.value = activeDeviceId;
+        saveDeviceId(activeDeviceId);
+    } else {
+        const savedDeviceId = getSavedDeviceId();
+        if (savedDeviceId) {
+            devicesList.value = savedDeviceId;
+        }
+    }
+}
+
+function saveDeviceId(deviceId) {
+    localStorage.setItem('active_device_id', deviceId);
+}
+function getSavedDeviceId() {
+    return localStorage.getItem('active_device_id');
+}
diff --git a/public/js/main.js b/public/js/main.js
index 064102b..4dcfb13 100644
--- a/public/js/main.js
+++ b/public/js/main.js
@@ -38,4 +38,7 @@ if (userIsSignedIn) {
     hideOrShow('signedout', false);
     setCurrentPlayingSong();
     setInterval(setCurrentPlayingSong, 5 * 1000);
-}
\ No newline at end of file
+}
+
+// periodically lookup the active device
+configureDevicesList();
\ No newline at end of file
diff --git a/public/styles/links.css b/public/styles/links.css
new file mode 100644
index 0000000..3f3ab8c
--- /dev/null
+++ b/public/styles/links.css
@@ -0,0 +1,21 @@
+a:link {
+    color: #333;
+    /* dark gray */
+    text-decoration: none;
+}
+
+a:visited {
+    color: #666;
+    /* medium gray */
+    text-decoration: none;
+}
+
+a:hover {
+    color: #1DB954;
+    text-decoration: underline;
+}
+
+a:active {
+    color: #000;
+    text-decoration: underline;
+}
\ No newline at end of file
diff --git a/public/styles/main.css b/public/styles/main.css
index 687aa04..81ce4fd 100644
--- a/public/styles/main.css
+++ b/public/styles/main.css
@@ -24,18 +24,18 @@ button {
     align-items: center;
 }
 
-#header {
+.flex {
     text-align: center;
     display: flex;
     align-items: center;
 }
 
 .left {
-    margin-left: auto;
+    margin-right: auto;
 }
 
 .right {
-    margin-right: auto;
+    margin-left: auto;
 }
 
 #logo {
@@ -100,4 +100,27 @@ button {
     width: 1em;
     height: 1em;
     margin-right: 1em;
+}
+
+#devices {
+    margin-left: 2rem;
+    border: 2px solid black;
+    font-size: 1em;
+}
+
+#devices:invalid {
+    /* Styles for when no option is selected */
+    border-color: red;
+}
+
+#status {
+    /* margin: 1em; */
+    /* padding: 1em; */
+    border: 0 solid black;
+    font-size: 1em;
+    width: 100%;
+}
+
+#status.error {
+    background-color: pink;
 }
\ No newline at end of file