├── CNAME
├── .gitignore
├── img
├── logo-512.png
├── folder.svg
├── githubIcon.svg
└── discordIcon.svg
├── manifest.json
├── js
├── maxVolume.js
├── keyboardControls.js
├── audio.js
├── lightColors.js
├── app.js
├── interface.js
├── loop.js
└── data.js
├── LICENSE.md
├── README.md
├── service-worker.js
├── index.html
└── main.css
/CNAME:
--------------------------------------------------------------------------------
1 | stemplayeronline.com
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | notes.txt
2 | /songs
3 |
--------------------------------------------------------------------------------
/img/logo-512.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/lukew3/stemPlayerOnline/HEAD/img/logo-512.png
--------------------------------------------------------------------------------
/img/folder.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/img/githubIcon.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 | [](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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/img/discordIcon.svg:
--------------------------------------------------------------------------------
1 |
2 |
7 |
--------------------------------------------------------------------------------
/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/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/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/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/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 |
--------------------------------------------------------------------------------
/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 |

38 |
Select folder with stems
39 |
40 |
46 |
47 |
48 |
49 |
81 |
101 |
102 |
Hurricane
103 |
Jail
104 |
Jail pt 2
105 |
Jesus Lord
106 |
Jesus Lord pt 2
107 |
108 |
109 |
110 |
111 |

112 |

113 |
114 |
115 |
116 |
117 |
118 |
119 |
120 |
121 |
122 |
123 |
124 |
125 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------