├── .gitignore ├── CNAME ├── LICENSE.md ├── README.md ├── img ├── discordIcon.svg ├── folder.svg ├── githubIcon.svg └── logo-512.png ├── index.html ├── js ├── app.js ├── audio.js ├── data.js ├── interface.js ├── keyboardControls.js ├── lightColors.js ├── loop.js └── maxVolume.js ├── main.css ├── manifest.json └── service-worker.js /.gitignore: -------------------------------------------------------------------------------- 1 | notes.txt 2 | /songs 3 | -------------------------------------------------------------------------------- /CNAME: -------------------------------------------------------------------------------- 1 | stemplayeronline.com -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | This is free and unencumbered software released into the public domain. 2 | 3 | Anyone is free to copy, modify, publish, use, compile, sell, or 4 | distribute this software, either in source code form or as a compiled 5 | binary, for any purpose, commercial or non-commercial, and by any 6 | means. 7 | 8 | In jurisdictions that recognize copyright laws, the author or authors 9 | of this software dedicate any and all copyright interest in the 10 | software to the public domain. We make this dedication for the benefit 11 | of the public at large and to the detriment of our heirs and 12 | successors. We intend this dedication to be an overt act of 13 | relinquishment in perpetuity of all present and future rights to this 14 | software under copyright law. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 19 | IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR 20 | OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, 21 | ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 22 | OTHER DEALINGS IN THE SOFTWARE. 23 | 24 | For more information, please refer to 25 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Frame 2](https://user-images.githubusercontent.com/47042841/182762011-817883f9-6f74-45f8-acc6-961867960ce5.png)](https://stemplayeronline.com) 2 | 3 | # Stem Player Online 4 | An online stem player. Inspired by but not affiliated with YEEZY TECH X KANO Stem Player. 5 | 6 | See it live: https://stemplayeronline.com 7 | 8 | Join the Stem Player Online community: https://discord.gg/jfnFhggJGZ 9 | 10 | ## Usage 11 | This site aims to function as closely to the original stem player device as possible. All usage instructions unique to the site are listed below. 12 | 13 | ### Loading local files 14 | If you have local stem files that you would like to load into the stem player, you can do so by clicking on the folder icon in the top left corner of the page and selecting the folder. Folders should contain 4 audio files, ideally named 1-4 with a typical audio format type such as .mp3, .flac, .wav. If files are not named by numbers 1-4, they will still play fine, but their positions on the player may be unpredictable. 15 | 16 | 17 | ### Keyboard shortcuts 18 | Keyboard shortcuts have been included to help better the experience of using the stem player on a pc. They simply allow an alternative way of interacting with the stem player other than touching with the mouse. 19 | 20 | * `Spacebar` - Toggles playback, pausing and playing the entire track 21 | * `` - Each arrow key controls the volume of it's respective slider. Pressing the arrow key will increase the volume and holding control while pressing the arrow key will decrease the volume. 22 | -------------------------------------------------------------------------------- /img/discordIcon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /img/folder.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /img/githubIcon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /img/logo-512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lukew3/stemPlayerOnline/90b8fcfd601c8534d31ed1b15c24ab5c6568b9c6/img/logo-512.png -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Stem Player Online 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 21 | 22 | 23 | 24 | 31 | 32 | 33 |
34 |
35 |
36 | 37 | Folder Select 38 |

Select folder with stems

39 |
40 |
41 | 42 |
43 | 44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 |
54 |
55 |
56 |
57 |
58 |
59 |
60 |
61 |
62 |
63 |
64 |
65 |
66 |
67 |
68 |
69 |
70 |
71 |
72 |
73 |
74 |
75 |
76 |
77 |
78 |
79 |
80 |
81 |
82 | 83 |
84 |
85 |
86 |
87 |
88 |
89 |
90 |
91 |
92 |
93 |
94 |
95 |
96 |
97 |
98 |
99 |
100 |
101 |
102 |
Hurricane
103 |
Jail
104 |
Jail pt 2
105 |
Jesus Lord
106 |
Jesus Lord pt 2
107 |
108 |
109 |
110 | 114 |
Test the beta version
115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | -------------------------------------------------------------------------------- /js/app.js: -------------------------------------------------------------------------------- 1 | let isolating = false; 2 | let controlPressed = false; 3 | let pointerdown = false; 4 | let maxVolume = 1; 5 | let wholeMaxVolume = 8; // Max volume in non-decimal 6 | let lightNum; 7 | let levels = [4, 4, 4, 4]; 8 | let sliderNames = ["right", "top", "left", "bottom"]; 9 | let hideLightsTimeout; 10 | 11 | const loadPlaylistViewer = () => { 12 | const pv = $("playlistViewer"); 13 | pv.innerHTML = ""; 14 | playlist.forEach((song, i) => { 15 | let songDiv = document.createElement('div'); 16 | songDiv.classList.add("playlistViewerItem"); 17 | songDiv.innerHTML = i+1 + ") " + song.title; 18 | songDiv.addEventListener("click", () => { 19 | songIndex = i; 20 | loadSong(); 21 | }) 22 | pv.append(songDiv); 23 | }) 24 | } 25 | loadPlaylistViewer(); 26 | 27 | const levelToVolume = (level) => { 28 | return (level-1)/3*maxVolume; 29 | } 30 | 31 | const setLightBrightness = (light, brightness) => { 32 | if (brightness == 0) { 33 | light.classList.add("lightOff"); 34 | light.classList.remove("lightBright"); 35 | } else if (brightness == 1) { 36 | light.classList.remove("lightOff"); 37 | light.classList.remove("lightBright"); 38 | } else if (brightness == 2) { 39 | light.classList.remove("lightOff"); 40 | light.classList.add("lightBright"); 41 | } 42 | } 43 | 44 | const allLightsOff = () => { 45 | Array.from(document.getElementsByClassName('light')).forEach((light) => { 46 | setLightBrightness(light, 0); 47 | }); 48 | } 49 | 50 | const showStemLights = () => { 51 | sliderNames.forEach((sliderName, index) => { 52 | Array.from(document.getElementsByClassName(sliderName + 'Light')).forEach((light) => { 53 | setLightColor(light, levels[index]); 54 | }); 55 | }); 56 | } 57 | 58 | const setLightColor = (light, lightIndex) => { 59 | (light.id.split("_")[1] > lightIndex) ? 60 | setLightBrightness(light, 0) : 61 | setLightBrightness(light, 1); 62 | } 63 | 64 | const isolateStem = (sliderName) => { 65 | if (isolating) return; 66 | isolating = true; 67 | tracks.forEach((track) => {track.muted = true;}); 68 | allLightsOff(); 69 | 70 | key[sliderName].muted = false; 71 | Array.from(document.getElementsByClassName(sliderName + 'Light')).forEach((light) => { 72 | setLightBrightness(light, 1); 73 | }); 74 | const resetVolume = () => { 75 | tracks.forEach((track, i) => {track.muted = false}); 76 | sliderNames.forEach((sliderName, index) => { 77 | Array.from(document.getElementsByClassName(sliderName + 'Light')).forEach((light) => { 78 | setLightColor(light, levels[index]); 79 | }); 80 | }); 81 | isolating = false; 82 | // remove the event listener after it is used once 83 | document.removeEventListener('pointerup', resetVolume); 84 | } 85 | document.addEventListener('pointerup', resetVolume) 86 | } 87 | 88 | /* Folder Select */ 89 | $("folderSelectGroup").addEventListener("click", () => { 90 | $("folderSelectField").click(); 91 | }); 92 | 93 | $("folderSelectField").addEventListener("change", () => { 94 | // load the first 4 mp3 files in the directory as stems 95 | // todo: ensure that 4 audio files are used as stems 96 | let files = $("folderSelectField").files; 97 | nowPlaying = false; 98 | // this will automatically place tracks in the right position if they are numbered 99 | tracks.forEach((track, i) => {track.src = URL.createObjectURL(files[i]);}); 100 | // set label to folder name 101 | $("folderSelectLabel").innerHTML = files[0].webkitRelativePath.split("/")[0]; 102 | }); 103 | 104 | -------------------------------------------------------------------------------- /js/audio.js: -------------------------------------------------------------------------------- 1 | let songIndex = 1; 2 | let nowPlaying = false; 3 | let tracks = []; 4 | let tracksReady = [false, false, false, false]; 5 | 6 | // Load starting stems 7 | for (var i=0; i<4; i++) { 8 | tracks[i] = new Audio(playlist[songIndex].tracks[i]); 9 | tracks[i].type = "audio/wav"; 10 | } 11 | tracks[0].onended = () => { 12 | if (songIndex < playlist.length - 1) { 13 | songIndex++; 14 | loadSong(); 15 | } else { 16 | nowPlaying = false; 17 | } 18 | } 19 | const loadSong = () => { 20 | tracksReady = [false, false, false, false]; 21 | let song = playlist[songIndex].tracks; 22 | for (var i=0; i<4; i++) { 23 | tracks[i].src = song[i]; 24 | } 25 | setTimeout(playAudio, 500); 26 | if (bpm) { 27 | bpm = playlist[songIndex].bpm || 120; 28 | beatDuration = 60/bpm*1000; 29 | } 30 | } 31 | 32 | const key = { 33 | "right": tracks[0], 34 | "top": tracks[1], 35 | "left": tracks[2], 36 | "bottom": tracks[3] 37 | } 38 | 39 | tracks.forEach((track, i) => { 40 | track.addEventListener("canplaythrough", (e) => { 41 | tracksReady[i] = true; 42 | }) 43 | }) 44 | function playAudio() { 45 | $("loading").style.display = "block"; 46 | setTimeout(() => { 47 | if (tracksReady.indexOf(false) === -1) { 48 | try { 49 | tracks.forEach((track) => {track.play()}); 50 | nowPlaying = true; 51 | } catch (err) { 52 | console.log('Failed to play...' + err); 53 | } 54 | $("loading").style.display = "none"; 55 | } else { 56 | playAudio(); 57 | } 58 | }, 100) 59 | } 60 | 61 | const pauseAudio = () => { 62 | tracks.forEach((track) => {track.pause();}); 63 | nowPlaying = false; 64 | } 65 | 66 | const togglePlayback = () => { 67 | if (nowPlaying) { 68 | pauseAudio(); 69 | } else { 70 | playAudio(); 71 | } 72 | } 73 | 74 | -------------------------------------------------------------------------------- /js/data.js: -------------------------------------------------------------------------------- 1 | let playlist = [ 2 | { 3 | title: "Donda Chant", 4 | bpm: 103, 5 | tracks: [ 6 | "/songs/Donda-Chant/1.flac", 7 | "/songs/Donda-Chant/2.flac", 8 | "/songs/Donda-Chant/3.flac", 9 | "/songs/Donda-Chant/4.flac", 10 | ], 11 | }, 12 | { 13 | title: "Hurricane", 14 | bpm: 80, 15 | tracks: [ 16 | "/songs/Hurricane/1.flac", 17 | "/songs/Hurricane/2.flac", 18 | "/songs/Hurricane/3.flac", 19 | "/songs/Hurricane/4.flac", 20 | ], 21 | }, 22 | { 23 | title: "Moon", 24 | bpm: 162, //66? 25 | tracks: [ 26 | "/songs/Moon/1.flac", 27 | "/songs/Moon/2.flac", 28 | "/songs/Moon/3.flac", 29 | "/songs/Moon/4.flac", 30 | ], 31 | }, 32 | { 33 | title: "Life of the Party", 34 | bpm: 79, 35 | tracks: [ 36 | "/songs/Life-Of-The-Party/1.flac", 37 | "/songs/Life-Of-The-Party/2.flac", 38 | "/songs/Life-Of-The-Party/3.flac", 39 | "/songs/Life-Of-The-Party/4.flac", 40 | ], 41 | }, 42 | { 43 | title: "Off the Grid", 44 | bpm: 138, 45 | tracks: [ 46 | "/songs/Off-The-Grid/1.flac", 47 | "/songs/Off-The-Grid/2.flac", 48 | "/songs/Off-The-Grid/3.flac", 49 | "/songs/Off-The-Grid/4.flac", 50 | ], 51 | }, 52 | { 53 | title: "Jail", 54 | bpm: 110, 55 | tracks: [ 56 | "/songs/Jail/1.flac", 57 | "/songs/Jail/2.flac", 58 | "/songs/Jail/3.flac", 59 | "/songs/Jail/4.flac", 60 | ], 61 | }, 62 | { 63 | title: "Praise God", 64 | bpm: 118, 65 | tracks: [ 66 | "/songs/Praise-God/1.flac", 67 | "/songs/Praise-God/2.flac", 68 | "/songs/Praise-God/3.flac", 69 | "/songs/Praise-God/4.flac", 70 | ], 71 | }, 72 | { 73 | title: "Come To Life", 74 | bpm: 127, 75 | tracks: [ 76 | "/songs/Come-To-Life/1.flac", 77 | "/songs/Come-To-Life/2.flac", 78 | "/songs/Come-To-Life/3.flac", 79 | "/songs/Come-To-Life/4.flac", 80 | ], 81 | }, 82 | { 83 | title: "Believe What I Say", 84 | bpm: 100, 85 | tracks: [ 86 | "/songs/Believe-What-I-Say/1.flac", 87 | "/songs/Believe-What-I-Say/2.flac", 88 | "/songs/Believe-What-I-Say/3.flac", 89 | "/songs/Believe-What-I-Say/4.flac", 90 | ], 91 | }, 92 | { 93 | title: "No Child Left Behind", 94 | bpm: 135, 95 | tracks: [ 96 | "/songs/No-Child-Left-Behind/1.flac", 97 | "/songs/No-Child-Left-Behind/2.flac", 98 | "/songs/No-Child-Left-Behind/3.flac", 99 | "/songs/No-Child-Left-Behind/4.flac", 100 | ], 101 | }, 102 | { 103 | title: "Up From The Ashes", 104 | bpm: 143, 105 | tracks: [ 106 | "/songs/Up-From-The-Ashes/1.flac", 107 | "/songs/Up-From-The-Ashes/2.flac", 108 | "/songs/Up-From-The-Ashes/3.flac", 109 | "/songs/Up-From-The-Ashes/4.flac", 110 | ], 111 | }, 112 | { 113 | title: "God Breathed", 114 | bpm: 93, 115 | tracks: [ 116 | "/songs/God-Breathed/1.flac", 117 | "/songs/God-Breathed/2.flac", 118 | "/songs/God-Breathed/3.flac", 119 | "/songs/God-Breathed/4.flac", 120 | ], 121 | }, 122 | { 123 | title: "Lord I Need You", 124 | bpm: 83, 125 | tracks: [ 126 | "/songs/Lord-I-Need-You/1.flac", 127 | "/songs/Lord-I-Need-You/2.flac", 128 | "/songs/Lord-I-Need-You/3.flac", 129 | "/songs/Lord-I-Need-You/4.flac", 130 | ], 131 | }, 132 | { 133 | title: "24", 134 | bpm: 110, 135 | tracks: [ 136 | "/songs/24/1.flac", 137 | "/songs/24/2.flac", 138 | "/songs/24/3.flac", 139 | "/songs/24/4.flac", 140 | ] 141 | }, 142 | { 143 | title: "Junya", 144 | bpm: 148, 145 | tracks: [ 146 | "/songs/Junya/1.flac", 147 | "/songs/Junya/2.flac", 148 | "/songs/Junya/3.flac", 149 | "/songs/Junya/4.flac", 150 | ], 151 | }, 152 | { 153 | title: "Never Abandon Your Family", 154 | bpm: 90, 155 | tracks: [ 156 | "/songs/Never-Abandon-Your-Family/1.flac", 157 | "/songs/Never-Abandon-Your-Family/2.flac", 158 | "/songs/Never-Abandon-Your-Family/3.flac", 159 | "/songs/Never-Abandon-Your-Family/4.flac", 160 | ], 161 | }, 162 | { 163 | title: "Donda", 164 | bpm: 106, 165 | tracks: [ 166 | "/songs/Donda/1.flac", 167 | "/songs/Donda/2.flac", 168 | "/songs/Donda/3.flac", 169 | "/songs/Donda/4.flac", 170 | ], 171 | }, 172 | { 173 | title: "Keep My Spirit Alive", 174 | bpm: 166, 175 | tracks: [ 176 | "/songs/Keep-My-Spirit-Alive/1.flac", 177 | "/songs/Keep-My-Spirit-Alive/2.flac", 178 | "/songs/Keep-My-Spirit-Alive/3.flac", 179 | "/songs/Keep-My-Spirit-Alive/4.flac", 180 | ], 181 | }, 182 | { 183 | title: "Jesus Lord pt. 2", 184 | bpm: 107, 185 | tracks: [ 186 | "/songs/Jesus-Lord-2/1.flac", 187 | "/songs/Jesus-Lord-2/2.flac", 188 | "/songs/Jesus-Lord-2/3.flac", 189 | "/songs/Jesus-Lord-2/4.flac", 190 | ], 191 | }, 192 | { 193 | title: "Heaven and Hell", 194 | bpm: 83, 195 | tracks: [ 196 | "/songs/Heaven-And-Hell/1.flac", 197 | "/songs/Heaven-And-Hell/2.flac", 198 | "/songs/Heaven-And-Hell/3.flac", 199 | "/songs/Heaven-And-Hell/4.flac", 200 | ], 201 | }, 202 | { 203 | title: "Remote Control", 204 | bpm: 110, 205 | tracks: [ 206 | "/songs/Remote-Control/1.flac", 207 | "/songs/Remote-Control/2.flac", 208 | "/songs/Remote-Control/3.flac", 209 | "/songs/Remote-Control/4.flac", 210 | ], 211 | }, 212 | { 213 | title: "Tell The Vision", 214 | bpm: 144, 215 | tracks: [ 216 | "/songs/Tell-The-Vision/1.flac", 217 | "/songs/Tell-The-Vision/2.flac", 218 | "/songs/Tell-The-Vision/3.flac", 219 | "/songs/Tell-The-Vision/4.flac", 220 | ], 221 | }, 222 | { 223 | title: "Jonah", 224 | bpm: 177, 225 | tracks: [ 226 | "/songs/Jonah/1.flac", 227 | "/songs/Jonah/2.flac", 228 | "/songs/Jonah/3.flac", 229 | "/songs/Jonah/4.flac", 230 | ], 231 | }, 232 | { 233 | title: "Pure Souls", 234 | bpm: 97, 235 | tracks: [ 236 | "/songs/Pure-Souls/1.flac", 237 | "/songs/Pure-Souls/2.flac", 238 | "/songs/Pure-Souls/3.flac", 239 | "/songs/Pure-Souls/4.flac", 240 | ], 241 | }, 242 | { 243 | title: "Ok Ok", 244 | bpm: 108, 245 | tracks: [ 246 | "/songs/Ok-Ok/1.flac", 247 | "/songs/Ok-Ok/2.flac", 248 | "/songs/Ok-Ok/3.flac", 249 | "/songs/Ok-Ok/4.flac", 250 | ], 251 | }, 252 | { 253 | title: "New Again", 254 | bpm: 91, 255 | tracks: [ 256 | "/songs/New-Again/1.flac", 257 | "/songs/New-Again/2.flac", 258 | "/songs/New-Again/3.flac", 259 | "/songs/New-Again/4.flac", 260 | ], 261 | }, 262 | { 263 | title: "Jesus Lord", 264 | bpm: 106, 265 | tracks: [ 266 | "/songs/Jesus-Lord/1.flac", 267 | "/songs/Jesus-Lord/2.flac", 268 | "/songs/Jesus-Lord/3.flac", 269 | "/songs/Jesus-Lord/4.flac", 270 | ], 271 | }, 272 | { 273 | title: "Ok Ok pt 2", 274 | bpm: 108, 275 | tracks: [ 276 | "/songs/Ok-Ok-2/1.flac", 277 | "/songs/Ok-Ok-2/2.flac", 278 | "/songs/Ok-Ok-2/3.flac", 279 | "/songs/Ok-Ok-2/4.flac", 280 | ], 281 | }, 282 | { 283 | title: "Junya pt. 2", 284 | bpm: 148, 285 | tracks: [ 286 | "/songs/Junya-2/1.flac", 287 | "/songs/Junya-2/2.flac", 288 | "/songs/Junya-2/3.flac", 289 | "/songs/Junya-2/4.flac", 290 | ], 291 | }, 292 | { 293 | title: "Jail pt. 2", 294 | bpm: 110, 295 | tracks: [ 296 | "/songs/Jail-2/1.flac", 297 | "/songs/Jail-2/2.flac", 298 | "/songs/Jail-2/3.flac", 299 | "/songs/Jail-2/4.flac", 300 | ], 301 | }, 302 | ] 303 | 304 | -------------------------------------------------------------------------------- /js/interface.js: -------------------------------------------------------------------------------- 1 | // include getsliders, handleLighttap, etc 2 | 3 | const handleLightTap = (sliderName, lightIndex) => { 4 | if (levels[sliderNames.indexOf(sliderName)] == parseInt(lightIndex)) return; //Dont update volume or lights if same light as active light is selected 5 | key[sliderName].volume = levelToVolume(lightIndex); 6 | levels[sliderNames.indexOf(sliderName)] = parseInt(lightIndex); 7 | Array.from(document.getElementsByClassName(sliderName + 'Light')).forEach((light) => { 8 | setLightColor(light, lightIndex); 9 | }); 10 | } 11 | 12 | /* Detect slider click */ 13 | document.addEventListener('pointerdown', (e) => { 14 | pointerdown = true; 15 | handlePointerDown(e); 16 | }) 17 | document.addEventListener('pointerup', (e) => { 18 | pointerdown = false; 19 | }) 20 | 21 | let sliderBounds = []; 22 | const getSliders = () => { 23 | sliderBounds = [ 24 | $("rightSlider").getBoundingClientRect(), 25 | $("topSlider").getBoundingClientRect(), 26 | $("leftSlider").getBoundingClientRect(), 27 | $("bottomSlider").getBoundingClientRect() 28 | ]; 29 | } 30 | getSliders(); 31 | window.addEventListener('resize', (e) => { 32 | getSliders(); 33 | }) 34 | 35 | document.addEventListener("pointermove", (e) => { 36 | if (pointerdown) handlePointerDown(e); 37 | }) 38 | const handlePointerDown = (e) => { 39 | sliderBounds.forEach((bound, index) => { 40 | if (e.clientX >= bound.left && 41 | e.clientX <= bound.right && 42 | e.clientY >= bound.top && 43 | e.clientY <= bound.bottom 44 | ) { 45 | lightNum = getLightClicked(e, index); 46 | if (inLoopMode) { 47 | loopHandleLightTap(sliderNames[index], lightNum) 48 | } else { 49 | handleLightTap(sliderNames[index], lightNum); 50 | // listen for hold if light 4 is touched 51 | if (lightNum == "4" && !isolating) setTimeout(() => { 52 | if (pointerdown && lightNum == "4") { 53 | //lightNum is global so that you can check new value after timeout 54 | isolateStem(sliderNames[index]); 55 | } 56 | }, 200) 57 | } 58 | } 59 | }) 60 | } 61 | const getLightClicked = (clickEvent, boundIndex) => { 62 | let segLen, i; 63 | let bound = sliderBounds[boundIndex]; 64 | let y = clickEvent.clientY; 65 | let x = clickEvent.clientX; 66 | const inBounds = [ 67 | () => { return x <= bound.left + i * segLen}, 68 | () => { return y >= bound.bottom - i * segLen}, 69 | () => { return x >= bound.right - i * segLen}, 70 | () => { return y <= bound.top + i * segLen} 71 | ] 72 | if ([1, 3].includes(boundIndex)) segLen = bound.height/4; 73 | else if ([2, 0].includes(boundIndex)) segLen = bound.width/4; 74 | for (i=1; i<=4; i++) 75 | if (inBounds[boundIndex]()) return i.toString(); 76 | return "1"; // catch error 77 | } 78 | let centerButtonPressed = false; 79 | $("centerButton").addEventListener("pointerdown", () => { 80 | $("centerButton").style.backgroundColor = "#82664b"; 81 | centerButtonPressed = true; 82 | }); 83 | $("centerButton").addEventListener("pointerup", () => { 84 | if (centerButtonPressed) { 85 | if (!inLoopMode) { 86 | togglePlayback(); 87 | } else { 88 | if (loopDuration == 7 && speedDotIndex == 5) { 89 | exitLoopMode(); 90 | } else { 91 | loopHandleLightTap("top", "4"); 92 | setSpeed("right", "2"); // Should speed be getting reset here? 93 | } 94 | } 95 | $("centerButton").style.backgroundColor = "var(--player)"; 96 | centerButtonPressed = false; 97 | } 98 | }); 99 | 100 | $("minusButton").addEventListener("click", () => { 101 | if (!inLoopMode) { 102 | if (wholeMaxVolume != 0) { 103 | wholeMaxVolume--; 104 | maxVolume = wholeMaxVolume/8; 105 | updateVolumes(); 106 | } 107 | displayVolume(); 108 | } 109 | }); 110 | $("plusButton").addEventListener("click", () => { 111 | if (!inLoopMode) { 112 | if (wholeMaxVolume != 8) { 113 | wholeMaxVolume++; 114 | maxVolume = wholeMaxVolume/8; 115 | updateVolumes(); 116 | } 117 | displayVolume(); 118 | } 119 | }); 120 | 121 | $("leftDotButton").addEventListener("click", () => { 122 | if (!inLoopMode) { 123 | if (songIndex != 0) { 124 | songIndex--; 125 | loadSong(); 126 | } 127 | } else if (loopStart*1000 >= beatDuration) { 128 | loopStart -= beatDuration/1000; 129 | } 130 | }); 131 | 132 | $("rightDotButton").addEventListener("click", () => { 133 | if (!inLoopMode) { 134 | if (songIndex + 1 != playlist.length) { 135 | songIndex++; 136 | loadSong(); 137 | } 138 | } else { 139 | loopStart += beatDuration/1000; 140 | } 141 | }); 142 | 143 | 144 | -------------------------------------------------------------------------------- /js/keyboardControls.js: -------------------------------------------------------------------------------- 1 | document.addEventListener("keydown", (e) => { 2 | if (e.key == " ") { 3 | togglePlayback(); 4 | } else if (e.key == "Control") { 5 | controlPressed = true; 6 | } else if (e.key.substring(0,5) == "Arrow") { 7 | let dir; 8 | if (e.key == "ArrowRight") dir = "right"; 9 | else if (e.key == "ArrowUp") dir = "top"; 10 | else if (e.key == "ArrowLeft") dir = "left"; 11 | else if (e.key == "ArrowDown") dir = "bottom"; 12 | dirLevel = levels[sliderNames.indexOf(dir)]; 13 | if (controlPressed && dirLevel != 1) 14 | handleLightTap(dir, (dirLevel-1).toString()); 15 | else if (!controlPressed && dirLevel != 4) 16 | handleLightTap(dir, (dirLevel+1).toString()); 17 | } 18 | //todo: enable holding arrow key to isolate track (or shift+arrow maybe) 19 | }) 20 | 21 | document.addEventListener("keyup", (e) => { 22 | if (e.key == "Control") controlPressed = false; 23 | }); 24 | -------------------------------------------------------------------------------- /js/lightColors.js: -------------------------------------------------------------------------------- 1 | let storedColors = window.localStorage.colors ? JSON.parse(window.localStorage.colors) : [$('color4Input').value, $('color1Input').value]; 2 | $('color4Input').value = storedColors[0]; 3 | $('color1Input').value = storedColors[1]; 4 | 5 | const colorHex2Dec = (color) => { 6 | return [parseInt(color.substring(1,3), 16), parseInt(color.substring(3,5), 16), parseInt(color.substring(5,7), 16)] 7 | } 8 | const colorDec2Hex = (colorDec) => { 9 | const add0 = (val) => {return (val<17) ? "0" : ""}; 10 | let r = add0(colorDec[0]) + colorDec[0].toString(16); 11 | let g = add0(colorDec[1]) + colorDec[1].toString(16); 12 | let b = add0(colorDec[2]) + colorDec[2].toString(16); 13 | return "#" + r + g + b; 14 | } 15 | const generateGradient = () => { 16 | let color4 = $('color4Input').value; 17 | let color1 = $('color1Input').value; 18 | window.localStorage.setItem("colors", JSON.stringify([color4, color1])); 19 | let color4Dec = colorHex2Dec(color4); 20 | let color1Dec = colorHex2Dec(color1); 21 | let diff = [ 22 | color4Dec[0] - color1Dec[0], 23 | color4Dec[1] - color1Dec[1], 24 | color4Dec[2] - color1Dec[2], 25 | ] 26 | let color2 = colorDec2Hex([ 27 | color1Dec[0] + Math.trunc(diff[0] / 3), 28 | color1Dec[1] + Math.trunc(diff[1] / 3), 29 | color1Dec[2] + Math.trunc(diff[2] / 3) 30 | ]); 31 | let color3 = colorDec2Hex([ 32 | color1Dec[0] + 2 * Math.trunc(diff[0] / 3), 33 | color1Dec[1] + 2 * Math.trunc(diff[1] / 3), 34 | color1Dec[2] + 2 * Math.trunc(diff[2] / 3) 35 | ]); 36 | let r = document.querySelector(':root'); 37 | r.style.setProperty('--light-1', color1); 38 | r.style.setProperty('--light-2', color2); 39 | r.style.setProperty('--light-3', color3); 40 | r.style.setProperty('--light-4', color4); 41 | } 42 | $("color4Icon").addEventListener("click", () => { 43 | $("color4Input").click(); 44 | }) 45 | $("color1Icon").addEventListener("click", () => { 46 | $("color1Input").click(); 47 | }) 48 | $("color4Input").addEventListener("change", () => { 49 | generateGradient(); 50 | }) 51 | $("color1Input").addEventListener("change", () => { 52 | generateGradient(); 53 | }) 54 | generateGradient(); 55 | -------------------------------------------------------------------------------- /js/loop.js: -------------------------------------------------------------------------------- 1 | let inLoopMode = false; 2 | let bpm = playlist[songIndex].bpm || 120; 3 | let beatDuration = 60/bpm*1000;// Milliseconds per beat 4 | // Index of the location of the dot moving horizontally 5 | const horizArray = ['left_4', 'left_3', 'left_2', 'left_1', 'right_1', 'right_2', 'right_3', 'right_4']; 6 | let horizLoopTracker = 0; 7 | let horizLoopTimeout; 8 | const moveHorizDot = () => { 9 | let nextLight = $(horizArray[horizLoopTracker]); 10 | nextLight.classList.add("loopLight", "lightBright"); 11 | horizLoopTimeout = setTimeout(() => { 12 | if (inLoopMode && nowPlaying) { 13 | let lastLight = $(horizArray[horizLoopTracker]); 14 | if (horizLoopTracker !== speedDotIndex) { 15 | lastLight.classList.remove("loopLight"); 16 | } 17 | lastLight.classList.remove("lightBright"); 18 | if (horizLoopTracker < 7) { 19 | horizLoopTracker++; 20 | } else { 21 | horizLoopTracker = 0; 22 | } 23 | } 24 | moveHorizDot(); 25 | }, beatDuration) 26 | } 27 | 28 | let vertLoopIndex; 29 | let loopDuration = 7; 30 | let loopStart = 0; // Time in song where loop starts (should this be rounded to the nearest beat?) 31 | let inLoop = false; 32 | let vertLoopTimeout; 33 | const vertArray = ['bottom_4', 'bottom_3', 'bottom_2', 'bottom_1', 'top_1', 'top_2', 'top_3', 'top_4']; 34 | const verticalLoop = () => { 35 | if (loopDuration < 7) { 36 | let nextLight = $(vertArray[vertLoopIndex]); 37 | nextLight.classList.add("loopLight", "lightBright"); 38 | vertLoopTimeout = setTimeout(() => { 39 | $(vertArray[vertLoopIndex]).classList.remove("lightBright"); 40 | if (vertLoopIndex < loopDuration) { 41 | vertLoopIndex++; 42 | } else { 43 | vertLoopIndex = 0; 44 | tracksReady = [false, false, false, false]; 45 | tracks.forEach((track) => { 46 | track.pause(); 47 | try { 48 | track.fastSeek(loopStart); 49 | } catch { 50 | track.currentTime = loopStart; 51 | } 52 | }) 53 | playAudio(); 54 | } 55 | verticalLoop(); 56 | }, beatDuration) 57 | } else { 58 | vertLoopIndex = 0; 59 | } 60 | } 61 | 62 | const enterLoopMode = () => { 63 | allLightsOff(); 64 | inLoopMode = true; 65 | bpm = playlist[songIndex].bpm || 120; 66 | beatDuration = 60/bpm*1000;// Milliseconds per beat 67 | // Init loop mode 68 | ["top", "bottom"].forEach((dir) => { 69 | for(let i=1; i<5; i++) { 70 | $(`${dir}_${i}`).classList.add("loopLight"); 71 | } 72 | }) 73 | moveHorizDot(); 74 | loopDuration = 7; 75 | vertLoopIndex = 0; 76 | verticalLoop(); 77 | $(horizArray[speedDotIndex]).classList.add("loopLight"); 78 | } 79 | const exitLoopMode = () => { 80 | showStemLights(); 81 | inLoopMode = false; 82 | // Clear manually set background colors 83 | // Could just apply a class and remove it instead 84 | ["top", "bottom", "left", "right"].forEach((dir) => { 85 | for(let i=1; i<5; i++) { 86 | $(`${dir}_${i}`).classList.remove("loopLight", "lightBright"); 87 | } 88 | }) 89 | clearTimeout(horizLoopTimeout); 90 | clearTimeout(vertLoopTimeout); 91 | } 92 | $("menuButton").addEventListener("click", () => { 93 | if (!inLoopMode) { 94 | enterLoopMode(); 95 | } else { 96 | exitLoopMode(); 97 | } 98 | }) 99 | 100 | let speedDotIndex = 5; 101 | const setSpeed = (sliderName, lightIndex) => { 102 | lightIndex = parseInt(lightIndex); 103 | if (sliderName == "right") { 104 | let pbRate = 1; 105 | if (lightIndex == 1) pbRate = 0.5; 106 | else if (lightIndex == 2) pbRate = 1; 107 | else if (lightIndex == 3) pbRate = 1.5; 108 | else if (lightIndex == 4) pbRate = 2; 109 | beatDuration = 60/bpm*1000/pbRate; 110 | tracks.forEach((track) => { 111 | track.playbackRate = pbRate; 112 | }) 113 | $(horizArray[speedDotIndex]).classList.remove("loopLight"); 114 | speedDotIndex = 3 + lightIndex; 115 | $(horizArray[speedDotIndex]).classList.add("loopLight"); 116 | } 117 | } 118 | 119 | const loopHandleLightTap = (sliderName, lightIndex) => { 120 | let nextLight; 121 | if (loopDuration === 7) { 122 | loopStart = tracks[0].currentTime; 123 | } 124 | if (["top","bottom"].includes(sliderName)) { 125 | let maxFound = false; 126 | let lightId = `${sliderName}_${lightIndex}`; 127 | for(let i=0; i i) { 135 | nextLight = $(vertArray[vertLoopIndex]); 136 | nextLight.classList.remove("loopLight", "lightBright"); 137 | vertLoopIndex = 0; 138 | loopStart = tracks[0].currentTime; 139 | } 140 | } else { 141 | $(vertArray[i]).classList.add("loopLight"); 142 | } 143 | } 144 | if (loopDuration == 7) { 145 | nextLight = $(vertArray[vertLoopIndex]); 146 | nextLight.classList.add("loopLight"); 147 | nextLight.classList.remove("lightBright"); 148 | } 149 | clearTimeout(vertLoopTimeout); 150 | verticalLoop(); 151 | } else if (["left", "right"].includes(sliderName)) { 152 | setSpeed(sliderName, lightIndex); 153 | } 154 | } 155 | 156 | -------------------------------------------------------------------------------- /js/maxVolume.js: -------------------------------------------------------------------------------- 1 | /* Max Volume Control */ 2 | const updateVolumes = (prevWholeMaxVol) => { 3 | tracks.forEach((track, index) => { 4 | track.volume = ((levels[index]-1)/3)*(wholeMaxVolume/8); 5 | }); 6 | } 7 | 8 | const volumeLights = ["bottom_4", "bottom_3", "bottom_2", "bottom_1", "top_1", "top_2", "top_3", "top_4"]; 9 | const displayVolume = () => { 10 | allLightsOff(); 11 | for (let i=0; i<8; i++) { 12 | $(volumeLights[i]).style.backgroundColor = null; 13 | } 14 | for (let i=0; i { 19 | for (let i=0; i<8; i++) { 20 | $(volumeLights[i]).style.backgroundColor = null; 21 | } 22 | if (!inLoopMode) showStemLights(); 23 | }, 800); 24 | } 25 | -------------------------------------------------------------------------------- /main.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --bg: #2c2f33; 3 | --player: #9c836a; 4 | --player-border: #856849; 5 | --slider: #997b5c; 6 | --light-on: #c6c9d2; 7 | --light-off: #8a7159; 8 | /* 9 | --light-1: #f5440a; 10 | --light-2: #b24156; 11 | --light-3: #743c9b; 12 | --light-4: #4339e3; 13 | */ 14 | --light-1: #515151; 15 | --light-2: #515151; 16 | --light-3: #515151; 17 | --light-4: #515151; 18 | --maxVolColor: #303FDB; 19 | --loopColor: orange; 20 | } 21 | 22 | 23 | html, body { 24 | background-color: var(--bg); 25 | overflow-x: hidden; 26 | overflow-y: hidden; 27 | margin: 0; 28 | } 29 | * { 30 | font-family: 'Roboto', sans-serif; 31 | color: #E2E2E2; 32 | -webkit-user-select: none; /* Chrome/Safari */ 33 | -moz-user-select: none; /* Firefox */ 34 | -ms-user-select: none; /* IE10+ */ 35 | user-select: none; 36 | } 37 | nav { 38 | display: flex; 39 | align-items: center; 40 | } 41 | nav * { 42 | margin-left: 10px; 43 | text-decoration: none; 44 | } 45 | #pageTitle { 46 | font-size: 14pt; 47 | } 48 | #topBar { 49 | display: flex; 50 | justify-content: space-between; 51 | margin: 5px; 52 | } 53 | #folderSelectGroup { 54 | display: flex; 55 | align-items: center; 56 | cursor: pointer; 57 | margin-left: 10px; 58 | } 59 | #folderSelectField { 60 | display: none; 61 | } 62 | #folderSelectIcon { 63 | height: 25px; 64 | width: 35px; 65 | padding: 3px; 66 | margin-right: 5px; 67 | } 68 | #playerContainer { 69 | display: flex; 70 | align-items: center; 71 | flex-direction: column; 72 | margin: auto; 73 | } 74 | #content { 75 | height: calc(85vh - 50px); 76 | display: flex; 77 | } 78 | #player { 79 | height: 95vw; 80 | width: 95vw; 81 | max-height: 500px; 82 | max-width: 500px; 83 | margin-bottom: 15px; 84 | border-radius: 50%; 85 | background-color: var(--player); 86 | border: 1px solid var(--player-border); 87 | box-shadow: inset 5px 5px 15px 15px rgba(0,0,0,0.2); 88 | } 89 | 90 | .playerSection { 91 | display: flex; 92 | width: 100%; 93 | height: 33.3%; 94 | align-items: center; 95 | } 96 | 97 | #upperPlayer, #lowerPlayer { 98 | justify-content: center; 99 | } 100 | #middlePlayer { 101 | justify-content: space-between; 102 | } 103 | 104 | .slider, .light { 105 | touch-action: none; 106 | } 107 | 108 | .slider { 109 | display: flex; 110 | background-color: var(--slider); 111 | width: 34%; 112 | height: 15vmin; /* 80px */ 113 | max-height: 80px; 114 | border-radius: 40px; 115 | justify-content: center; 116 | align-items: center; 117 | box-shadow: inset 3px 3px 3px rgba(0,0,0,0.2), inset -3px -3px 3px rgba(0,0,0,0.2); 118 | cursor: pointer; 119 | } 120 | 121 | 122 | #topSlider { 123 | transform: rotate(-90deg); 124 | margin-top: 6%; 125 | } 126 | #leftSlider { 127 | transform: rotate(180deg); 128 | margin-left: 5%; 129 | } 130 | #centerButton { 131 | width: 12%; 132 | height: 36%; /* 40px */ 133 | border-radius: 50%; 134 | background-color: var(--player); 135 | border: 1px solid var(--player-border); 136 | box-shadow: inset 1px 1px 2px 2px rgba(0,0,0,0.2); 137 | cursor: pointer; 138 | } 139 | #rightSlider { 140 | margin-right: 5%; 141 | } 142 | #bottomSlider { 143 | transform: rotate(90deg); 144 | margin-bottom: 6%; 145 | } 146 | 147 | .light { 148 | width: 16%; 149 | max-width: 24px; 150 | height: 34%; 151 | max-height: 24px; 152 | border: 2px solid var(--light-on); 153 | border: none; 154 | margin: 5px; 155 | border-radius: 50%; 156 | } 157 | .light1 { 158 | background-color: var(--light-1); 159 | } 160 | .light1.lightBright { 161 | box-shadow: 0px 0px 3px 3px var(--light-1); 162 | } 163 | .light2 { 164 | background-color: var(--light-2); 165 | } 166 | .light2.lightBright { 167 | box-shadow: 0px 0px 3px 3px var(--light-2); 168 | } 169 | .light3 { 170 | background-color: var(--light-3); 171 | } 172 | .light3.lightBright { 173 | box-shadow: 0px 0px 3px 3px var(--light-3); 174 | } 175 | .light4 { 176 | background-color: var(--light-4); 177 | } 178 | .light4.lightBright { 179 | box-shadow: 0px 0px 3px 3px var(--light-4); 180 | } 181 | .loopLight { 182 | background-color: var(--loopColor) !important; 183 | } 184 | .loopLight.lightBright { 185 | box-shadow: 0px 0px 3px 3px var(--loopColor); 186 | } 187 | .lightOff { 188 | background-color: var(--light-off); 189 | box-shadow: none; 190 | } 191 | #playerButtons { 192 | width: 100%; 193 | display: flex; 194 | justify-content: center; 195 | } 196 | .playerButton { 197 | height: 25px; 198 | width: 50px; 199 | background-color: var(--player); 200 | margin: 0px 10px; 201 | border-radius: 12.5px; 202 | border: 1px solid var(--player-border); 203 | cursor: pointer; 204 | } 205 | #volumeButtons, #selectButtons { 206 | width: 100px; 207 | display: flex; 208 | justify-content: space-between; 209 | } 210 | #plusIconVertical { 211 | position: absolute; 212 | height: 2px; 213 | width: 10px; 214 | margin: 11px 10px; 215 | background-color: #68533c; 216 | transform: rotate(90deg); 217 | } 218 | #plusIconHorizontal { 219 | position: absolute; 220 | height: 2px; 221 | width: 10px; 222 | margin: 11px 10px; 223 | background-color: #68533c; 224 | } 225 | #minusIcon { 226 | height: 2px; 227 | width: 10px; 228 | margin: 10.5px 10px; 229 | background-color: #68533c; 230 | } 231 | #minusIcon, #rightDotIcon { 232 | float: right; 233 | } 234 | .dotIcon { 235 | height: 10px; 236 | width: 10px; 237 | margin: 7.5px; 238 | background-color: #68533c; 239 | border-radius: 50%; 240 | } 241 | .halfButton { 242 | width: 50px; 243 | } 244 | .colorInput { 245 | display: none; 246 | } 247 | #colorIcons { 248 | display: flex; 249 | margin-top: 3px; 250 | margin-right: 5px; 251 | } 252 | .colorIcon { 253 | width: 30px; 254 | height: 30px; 255 | border-radius: 50%; 256 | cursor: pointer; 257 | margin: 8px; 258 | border: 1px solid black; 259 | } 260 | #color4Icon { 261 | background-color: var(--light-4); 262 | } 263 | #color1Icon { 264 | background-color: var(--light-1); 265 | } 266 | #externalLinks { 267 | position: absolute; 268 | bottom: 10px; 269 | left: 15px; 270 | display: flex; 271 | } 272 | #externalLinks img { 273 | margin-right: 10px; 274 | width: 25px; 275 | height: 25px; 276 | padding: 5px; 277 | } 278 | @keyframes loadingAnimation { 279 | 0% { margin-left:-70%;} 280 | 100% { margin-left: 120%;} 281 | } 282 | #loading { 283 | position: absolute; 284 | width: 50%; 285 | height: 2px; 286 | background-color: white; 287 | margin: 0; 288 | margin-left: -70%; 289 | animation-name: loadingAnimation; 290 | animation-duration: 2s; 291 | animation-iteration-count: infinite; 292 | display: none; 293 | } 294 | #playlistViewer { 295 | position: absolute; 296 | display: none; 297 | bottom: 10px; 298 | right: 15px; 299 | overflow: auto; 300 | height: 120px; 301 | width: 250px; 302 | border-radius: 3px; 303 | } 304 | .playlistViewerItem { 305 | padding: 6px; 306 | background-color: #222; 307 | border-bottom: 1px solid #333; 308 | cursor: pointer; 309 | } 310 | -------------------------------------------------------------------------------- /manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "dir": "ltr", 3 | "lang": "en", 4 | "name": "Stem Player Online", 5 | "short_name": "Stem Player", 6 | "description": "Play stems online", 7 | "icons": [ 8 | { 9 | "src": "img/logo-512.png", 10 | "sizes": "512x512", 11 | "type": "image/png", 12 | "purpose": "any maskable" 13 | } 14 | ], 15 | "display": "standalone", 16 | "orientation": "portrait", 17 | "start_url": "/", 18 | "theme_color": "#2c2f33", 19 | "background_color": "#2c2f33" 20 | } 21 | -------------------------------------------------------------------------------- /service-worker.js: -------------------------------------------------------------------------------- 1 | // Service worker adapted from: https://developers.google.com/web/fundamentals/primers/service-workers 2 | var CACHE_NAME = 'stemplayeronline-cache'; 3 | var urlsToCache = [ 4 | //'/', 5 | ]; 6 | 7 | self.addEventListener('install', function(event) { 8 | // Perform install steps 9 | event.waitUntil( 10 | caches.open(CACHE_NAME) 11 | .then(function(cache) { 12 | return cache.addAll(urlsToCache); 13 | }) 14 | ); 15 | }); 16 | 17 | self.addEventListener('activate', (event) => { 18 | event.waitUntil((async () => { 19 | if ('navigationPreload' in self.registration) { 20 | await self.registration.navigationPreload.enable(); 21 | } 22 | })()); 23 | 24 | self.clients.claim(); 25 | }); 26 | 27 | self.addEventListener('fetch', function(event) { 28 | event.respondWith( 29 | caches.match(event.request) 30 | .then(function(response) { 31 | // Cache hit - return response 32 | if (response) { 33 | return response; 34 | } 35 | return fetch(event.request); 36 | } 37 | ) 38 | ); 39 | }); 40 | 41 | --------------------------------------------------------------------------------