fuzzyworld

hawk / commit

Multiple changes:

8edcc8872c3d7e06ac0493ba5c38a8a7480d3c17 · Pavel Safronov · 2023-10-13 02:19:17 -0700 · parents 14ae6bbde671

Multiple changes:
- added favicon
- removed profile code
- cleaned up scope
- templated the found songs
- fixed sign-in logic to work locally and on the server
 public/api.js      |  71 +++++++++++++++++++++++++++++++++++++++++++++++++++--
 public/favicon.ico | Bin 0 -> 1150 bytes
 public/favicon.png | Bin 0 -> 872 bytes
 public/favicon.svg |  19 ++++++++++++++
 public/index.html  |  26 +++++++++++++++-----
 public/main.js     |  46 +++++++++++++++++++++++++++-------
 public/signin.js   |  19 +++++++++++---
 public/template.js |  19 ++++++++++++++
 8 files changed, 179 insertions(+), 21 deletions(-)

diff --git a/public/api.js b/public/api.js
index f25cc8a..615efe6 100644
--- a/public/api.js
+++ b/public/api.js
@@ -1,12 +1,28 @@
+async function getAlbums() {
+    let accessToken = localStorage.getItem('access_token');
+    if (!accessToken) {
+        console.error('No access token found');
+        return;
+    }
+
+    const response = await fetch('https://api.spotify.com/v1/me/albums', {
+        headers: {
+            Authorization: 'Bearer ' + accessToken
+        }
+    });
 
-async function getProfile() {
+    const data = await response.json();
+    return data;
+}
+
+async function getTracks(albumId) {
     let accessToken = localStorage.getItem('access_token');
     if (!accessToken) {
         console.error('No access token found');
         return;
     }
 
-    const response = await fetch('https://api.spotify.com/v1/me', {
+    const response = await fetch(`https://api.spotify.com/v1/albums/${albumId}/tracks`, {
         headers: {
             Authorization: 'Bearer ' + accessToken
         }
@@ -14,4 +30,55 @@ async function getProfile() {
 
     const data = await response.json();
     return data;
+}
+
+async function playTrack(trackId) {
+    let accessToken = localStorage.getItem('access_token');
+    if (!accessToken) {
+        console.error('No access token found');
+        return;
+    }
+
+    console.info(`Playing track ${trackId}`);
+    await fetch(`https://api.spotify.com/v1/me/player/play`, {
+        method: 'PUT',
+        headers: {
+            Authorization: 'Bearer ' + accessToken
+        },
+        body: JSON.stringify({
+            uris: [`spotify:track:${trackId}`]
+        })
+    });
+}
+
+async function search(minutes, seconds) {
+    let accessToken = localStorage.getItem('access_token');
+    if (!accessToken) {
+        console.error('No access token found');
+        return;
+    }
+
+    const albums = await getAlbums();
+    // console.log(albums);
+
+    const foundTracks = [];
+    for (let i = 0; i < albums.items.length; i++) {
+        const album = albums.items[i];
+        const tracks = await getTracks(album.album.id);
+        // console.log(tracks);
+
+        for (let j = 0; j < tracks.items.length; j++) {
+            const track = tracks.items[j];
+            const duration = track.duration_ms;
+            const durationInSeconds = Math.floor(duration / 1000);
+            const durationInMinutes = Math.floor(durationInSeconds / 60);
+            const durationInRemainingSeconds = durationInSeconds % 60;
+
+            if (durationInMinutes == minutes && durationInRemainingSeconds == seconds) {
+                console.log(track);
+                foundTracks.push(track);
+            }
+        }
+    }
+    return foundTracks;
 }
\ No newline at end of file
diff --git a/public/favicon.ico b/public/favicon.ico
new file mode 100644
index 0000000..1eeafc2
Binary files /dev/null and b/public/favicon.ico differ
diff --git a/public/favicon.png b/public/favicon.png
new file mode 100644
index 0000000..35b6215
Binary files /dev/null and b/public/favicon.png differ
diff --git a/public/favicon.svg b/public/favicon.svg
new file mode 100644
index 0000000..5ae5ac0
--- /dev/null
+++ b/public/favicon.svg
@@ -0,0 +1,19 @@
+<svg xmlns="http://www.w3.org/2000/svg" version="1.1" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:svgjs="http://svgjs.com/svgjs" width="96.000000pt" height="96.000000pt"><svg version="1.0" xmlns="http://www.w3.org/2000/svg" width="96.000000pt" height="96.000000pt" viewBox="0 0 96.000000 96.000000" preserveAspectRatio="xMidYMid meet">
+
+<g transform="translate(0.000000,96.000000) scale(0.100000,-0.100000)" fill="#000000" stroke="none">
+<path d="M400 771 c-76 -25 -173 -109 -218 -190 l-23 -41 31 0 31 0 -36 -52
+c-20 -30 -35 -65 -35 -82 0 -25 3 -28 23 -22 12 4 36 9 53 12 l31 6 -10 -55
+c-13 -72 -4 -107 28 -107 13 0 27 5 31 11 3 6 22 17 40 25 34 14 35 14 65 -21
+36 -41 74 -69 107 -79 21 -7 22 -4 22 38 0 101 37 206 86 245 22 17 41 21 101
+21 41 0 84 -5 95 -11 20 -11 20 -9 16 47 -4 50 -9 61 -39 86 -19 16 -38 34
+-44 40 -60 65 -92 89 -142 108 -62 24 -172 35 -213 21z m215 -90 c35 -18 66
+-40 70 -50 10 -31 -22 -90 -58 -104 -53 -23 -96 -75 -117 -145 -11 -35 -20
+-75 -20 -88 0 -13 -4 -24 -9 -24 -10 0 -48 43 -67 76 -10 17 -15 18 -45 6 -69
+-28 -70 -28 -63 9 4 19 15 56 26 83 l19 48 -26 -6 c-14 -3 -37 -9 -52 -13
+l-28 -6 29 39 c16 21 40 51 54 66 l24 27 -37 3 -38 3 27 32 c14 17 50 43 79
+58 68 35 141 30 232 -14z"></path>
+<path d="M510 621 c0 -19 39 -51 63 -51 13 0 32 6 42 14 18 13 16 15 -25 26
+-25 6 -53 13 -62 16 -10 3 -18 1 -18 -5z"></path>
+</g>
+</svg><style>@media (prefers-color-scheme: light) { :root { filter: none; } }
+</style></svg>
\ No newline at end of file
diff --git a/public/index.html b/public/index.html
index 0f8ce37..e866593 100644
--- a/public/index.html
+++ b/public/index.html
@@ -4,12 +4,14 @@
 <head>
     <meta charset="utf-8">
     <title>Hawk</title>
-    <favicon href="h.png" />
+    <link rel="icon" type="image/png" href="favicon.png">
     <style>
         body {
             font-family: sans-serif;
             margin: 0;
             padding: 0;
+            max-width: 600px;
+            margin: 0 auto;
         }
 
         #main,
@@ -51,23 +53,34 @@
 </head>
 
 <body>
+    <template id="song-item">
+        <div class="song">
+            <div>
+                <span>${name}</span>
+                <span>${duration}</span>
+                <button id="play-${trackId}" onclick="playTrack('${trackId}')">Play</button>
+                <button id="open-${trackId}" onclick="window.open('${trackHref}')">Open</button>
+            </div>
+        </div>
+    </template>
     <div id="header">
 
         <h1>Hawk</h1>
         <div>
-            You can specify a duration ("5 and a half minutes"), and Hawk will find a song in your library with that
+            You can specify a duration ("5 and a half minutes"), and Hawk will find a song in your Spotify library with
+            that
             length,
             or as close to it as possible.
         </div>
         <div>
-            Name comes from <a href="https://www.imdb.com/title/tt0102070/">Hudson Hawk</a>, where Bruce Willis does
-            this for
-            heists.
+            The name 'Hawk' comes from the movie <a href="https://www.imdb.com/title/tt0102070/" target="_blank">Hudson
+                Hawk</a>, where Bruce Willis picks and sings songs of a particular lengths to time things. Yeah, it's
+            weird, but it's also a fun movie, so check it out.
         </div>
     </div>
     <div id="main">
         <div id="sign-in-div">
-            <button id="signin">Sign In</button>
+            <button id="signin">Sign In to Spotify</button>
         </div>
         <div id="controls">
             <div id="greeting"></div>
@@ -88,6 +101,7 @@
     </div>
     <script src="signin.js"></script>
     <script src="api.js"></script>
+    <script src="template.js"></script>
     <script src="main.js"></script>
 
 </body>
diff --git a/public/main.js b/public/main.js
index 79c14e1..99ee492 100644
--- a/public/main.js
+++ b/public/main.js
@@ -14,10 +14,8 @@ if (code) {
 
 const accessToken = localStorage.getItem('access_token');
 const showSignInButton = !accessToken
-const showProfile = !!accessToken
 
 const signInButton = document.getElementById('signin');
-console.log(signInButton);
 const controlsDiv = document.getElementById('controls');
 if (showSignInButton) {
     signInButton.setAttribute('style', 'display: block');
@@ -31,10 +29,40 @@ if (showSignInButton) {
 const signOutButton = document.getElementById('signout');
 signOutButton.addEventListener('click', signOut);
 
-if (showProfile) {
-    getProfile().then(profile => {
-        console.log(profile);
-        const greetingDiv = document.getElementById('greeting');
-        greetingDiv.innerHTML = `Hello, ${profile.display_name}! Let's find you a song!`;
-    });
-}
\ No newline at end of file
+const searchButton = document.getElementById('search');
+searchButton.addEventListener('click', async () => {
+    searchButton.setAttribute('disabled', 'disabled');
+    try {
+        const minutes = document.getElementById('minutes-input').value;
+        const seconds = document.getElementById('seconds-input').value;
+        const tracks = await search(minutes, seconds);
+        const resultsDiv = document.getElementById('results');
+        tracks.forEach(track => {
+            const songItem = document.querySelector('#song-item');
+            const html = interpolate(songItem.innerHTML, {
+                name: track.name,
+                duration: formatDuration(track.duration_ms),
+                trackId: track.id,
+                trackHref: track.external_urls.spotify
+            });
+            resultsDiv.innerHTML += html;
+        });
+    } finally {
+        searchButton.removeAttribute('disabled');
+    }
+});
+
+function formatDuration(ms) {
+    // Convert milliseconds to total seconds
+    let totalSeconds = Math.floor(ms / 1000);
+
+    // Calculate minutes and seconds
+    let minutes = Math.floor(totalSeconds / 60);
+    let seconds = totalSeconds % 60;
+
+    // Ensure seconds are always 2 digits
+    let formattedSeconds = seconds.toString().padStart(2, '0');
+
+    // Return formatted string
+    return `${minutes}:${formattedSeconds}`;
+}
diff --git a/public/signin.js b/public/signin.js
index f447a95..811d842 100644
--- a/public/signin.js
+++ b/public/signin.js
@@ -1,5 +1,15 @@
 const clientId = '6c4c04a3751b4e409ddad7d71aad9949';
-const redirectUri = 'http://localhost:5500/public/index.html';
+const scope = 'user-library-read user-modify-playback-state';
+function getSpotifyRedirectURL() {
+    // If the current hostname is 'localhost'
+    if (window.location.hostname === 'localhost') {
+        return 'http://localhost:5500/public/index.html';
+    } else if (window.location.hostname === '127.0.0.1') {
+        return 'http://127.0.0.1:5500/public/index.html';
+    } else {
+        return 'https://pavelsafronov.gitlab.io/hawk/index.html';
+    }
+}
 
 async function signOut() {
     localStorage.removeItem('access_token');
@@ -10,15 +20,15 @@ async function signIn() {
     let codeVerifier = generateRandomString(128);
     const codeChallenge = await generateCodeChallenge(codeVerifier)
     let state = generateRandomString(16);
-    let scope = 'user-read-private user-read-email';
 
     localStorage.setItem('code_verifier', codeVerifier);
 
+    const redirectUrl = getSpotifyRedirectURL();
     let args = new URLSearchParams({
         response_type: 'code',
         client_id: clientId,
         scope: scope,
-        redirect_uri: redirectUri,
+        redirect_uri: redirectUrl,
         state: state,
         code_challenge_method: 'S256',
         code_challenge: codeChallenge
@@ -30,10 +40,11 @@ async function signIn() {
 async function getToken(code) {
     let codeVerifier = localStorage.getItem('code_verifier');
 
+    const redirectUrl = getSpotifyRedirectURL();
     let body = new URLSearchParams({
         grant_type: 'authorization_code',
         code: code,
-        redirect_uri: redirectUri,
+        redirect_uri: redirectUrl,
         client_id: clientId,
         code_verifier: codeVerifier
     });
diff --git a/public/template.js b/public/template.js
new file mode 100644
index 0000000..a77573f
--- /dev/null
+++ b/public/template.js
@@ -0,0 +1,19 @@
+/**
+ * Get a template from a string
+ * https://stackoverflow.com/a/41015840
+ * @param  {String} str    The string to interpolate
+ * @param  {Object} params The parameters
+ * @return {String}        The interpolated string
+ */
+function interpolate(str, params) {
+    let names = Object.keys(params);
+    let vals = Object.values(params);
+    return new Function(...names, `return \`${str}\`;`)(...vals);
+}
+
+function htmlToElement(html) {
+    var template = document.createElement('template');
+    html = html.trim(); // Never return a text node of whitespace as the result
+    template.innerHTML = html;
+    return template.content.firstChild;
+}
\ No newline at end of file