Skip to content

Commit c996960

Browse files
committed
playlist-maker
1 parent 8a43dea commit c996960

File tree

6 files changed

+583
-0
lines changed

6 files changed

+583
-0
lines changed

client/playlist-maker.css

+172
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
@charset "UTF-8";
2+
body {
3+
font-family: Frutiger, "Frutiger Linotype", Univers, Calibri, "Gill Sans", "Gill Sans MT", "Myriad Pro", Myriad, "DejaVu Sans Condensed", "Liberation Sans", "Nimbus Sans L", Tahoma, Geneva, "Helvetica Neue", Helvetica, Arial, sans-serif;
4+
background: #1e1f26;
5+
color: #fff; }
6+
7+
.loading {
8+
position: fixed;
9+
top: 0;
10+
left: 0;
11+
width: 100%;
12+
height: 100%;
13+
background: inherit;
14+
font-size: 60px;
15+
display: flex;
16+
align-items: center;
17+
justify-content: center;
18+
transition: opacity .2s, visibility .2s; }
19+
20+
.loaded .loading {
21+
opacity: 0;
22+
visibility: hidden; }
23+
24+
fieldset {
25+
border-radius: 4px;
26+
border: 1px solid #fff;
27+
margin: 16px 0; }
28+
29+
legend {
30+
padding: 0 8px; }
31+
32+
.filters > div {
33+
margin: 12px 0; }
34+
35+
.difficulties label {
36+
margin-right: 16px;
37+
cursor: pointer; }
38+
39+
.difficulties span {
40+
transition: color .1s; }
41+
.difficulties span::before {
42+
content: '✓';
43+
opacity: 0;
44+
margin-right: 4px;
45+
transition: opacity .1s; }
46+
47+
.difficulties input {
48+
opacity: 0;
49+
position: absolute;
50+
pointer-events: none;
51+
top: -9999px;
52+
left: -9999px; }
53+
.difficulties input:checked ~ span {
54+
color: #89d1a7; }
55+
.difficulties input:checked ~ span::before {
56+
opacity: 1; }
57+
.difficulties input:focus ~ span {
58+
border-bottom: 1px solid; }
59+
60+
.mouse .difficulties input ~ span {
61+
border-bottom: none; }
62+
63+
.desc {
64+
display: inline-block;
65+
min-width: 80px;
66+
text-align: right;
67+
margin-right: 6px; }
68+
69+
.from, .to {
70+
display: inline-block;
71+
border-radius: 4px 0 0 4px;
72+
background: #fff;
73+
color: #1e1f26;
74+
line-height: 24px;
75+
vertical-align: middle; }
76+
.from::before, .to::before {
77+
content: 'from';
78+
display: inline-block;
79+
padding: 0 6px;
80+
background: #eee;
81+
box-shadow: 0 0 3px rgba(0, 0, 0, 0.3);
82+
border-radius: 4px 0 0 4px; }
83+
.from input, .to input {
84+
background: none;
85+
border: none;
86+
font: inherit;
87+
padding: 0 6px;
88+
width: 60px; }
89+
90+
.to {
91+
border-radius: 0 4px 4px 0; }
92+
.to::before {
93+
content: 'to';
94+
border-radius: 0; }
95+
96+
.preview .list {
97+
background: #fff;
98+
color: #000;
99+
height: 200px;
100+
max-width: 600px;
101+
overflow-y: auto;
102+
margin-top: 4px;
103+
border-radius: 4px;
104+
padding: 8px;
105+
white-space: nowrap;
106+
font-size: 14px;
107+
line-height: 20px; }
108+
109+
.details > div, .details > label {
110+
display: block;
111+
margin: 12px 0; }
112+
113+
.details input, .details select {
114+
border: none;
115+
background: #fff;
116+
font: inherit;
117+
margin: 0;
118+
padding: 4px 6px;
119+
border-radius: 4px; }
120+
121+
.details option {
122+
vertical-align: middle; }
123+
124+
.details .desc {
125+
min-width: 50px; }
126+
127+
.details select {
128+
margin: 0; }
129+
130+
.image-options {
131+
display: inline-block; }
132+
.image-options label {
133+
position: relative;
134+
display: inline-block;
135+
margin-right: 16px;
136+
cursor: pointer;
137+
font-size: 0; }
138+
.image-options img {
139+
height: 32px;
140+
border-radius: 4px;
141+
color: rgba(137, 209, 167, 0);
142+
border: 2px solid;
143+
background: currentColor; }
144+
.image-options .transparent::after {
145+
content: 'none';
146+
position: absolute;
147+
top: 50%;
148+
left: 50%;
149+
font-size: 12px;
150+
color: #666;
151+
transform: translate(-50%, -50%); }
152+
.image-options input {
153+
opacity: 0;
154+
position: absolute;
155+
pointer-events: none;
156+
top: -9999px;
157+
left: -9999px; }
158+
.image-options input:checked ~ img {
159+
color: #89d1a7; }
160+
.image-options input:focus ~ img {
161+
box-shadow: 0 0 5px 1px #78c8ff; }
162+
163+
.mouse .image-options input:focus ~ img {
164+
box-shadow: none; }
165+
166+
.download {
167+
background: #fff;
168+
color: #16a085;
169+
font-weight: bold;
170+
text-decoration: none;
171+
padding: 6px 12px;
172+
border-radius: 4px; }

client/playlist-maker.js

+201
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,201 @@
1+
document.body.addEventListener('mousedown', () => {
2+
document.body.classList.add('mouse');
3+
});
4+
document.body.addEventListener('keydown', () => {
5+
document.body.classList.remove('mouse');
6+
});
7+
8+
const images = {
9+
transparent: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=',
10+
scoresaber: '/client/scoresaber.png',
11+
peepoUK: '/client/icon.png',
12+
};
13+
14+
(async () => {
15+
let data = await fetch('/ranked').then(r => r.json());
16+
let ranked = data.list;
17+
let difficulties = [
18+
{ name: 'Easy' },
19+
{ name: 'Normal' },
20+
{ name: 'Hard' },
21+
{ name: 'Expert' },
22+
{ name: 'ExpertPlus', label: 'Expert+' },
23+
];
24+
let $ = document.querySelector.bind(document);
25+
let fields = {
26+
difficulty: {},
27+
stars: {
28+
from: $('.stars .from input'),
29+
to: $('.stars .to input'),
30+
},
31+
rating: {
32+
from: $('.rating .from input'),
33+
to: $('.rating .to input'),
34+
},
35+
duration: {
36+
from: $('.duration .from input'),
37+
to: $('.duration .to input'),
38+
},
39+
name: $('.name'),
40+
author: $('.author'),
41+
count: $('.count'),
42+
sort: $('.sort'),
43+
};
44+
let preview = {
45+
count: $('.result-count'),
46+
list: $('.preview .list'),
47+
};
48+
let diffContainer = $('.difficulties');
49+
difficulties.forEach(difficulty => {
50+
let label = document.createElement('label');
51+
let input = document.createElement('input');
52+
let span = document.createElement('span');
53+
input.type = 'checkbox';
54+
input.checked = 'true';
55+
input.addEventListener('change', updateSongs);
56+
label.appendChild(input);
57+
span.appendChild(document.createTextNode(difficulty.label || difficulty.name));
58+
label.appendChild(span);
59+
fields.difficulty[difficulty.name] = input;
60+
diffContainer.appendChild(label);
61+
});
62+
63+
let imagesContainer = $('.image-options');
64+
Object.keys(images).forEach(async (image, i) => {
65+
let value = images[image];
66+
let label = document.createElement('label');
67+
let input = document.createElement('input');
68+
let preview = document.createElement('img');
69+
label.className = image;
70+
input.type = 'radio';
71+
input.name = 'image';
72+
input.checked = !i;
73+
74+
preview.crossOrigin = 'anonymous';
75+
await new Promise((res, rej) => {
76+
preview.onload = res;
77+
preview.onerror = rej;
78+
preview.src = value;
79+
});
80+
81+
let canvas = document.createElement('canvas');
82+
let ctx = canvas.getContext('2d');
83+
canvas.height = preview.naturalHeight;
84+
canvas.width = preview.naturalWidth;
85+
ctx.drawImage(preview, 0, 0);
86+
input.value = canvas.toDataURL('image/png');
87+
88+
input.addEventListener('change', updateLink);
89+
label.appendChild(input);
90+
label.appendChild(preview);
91+
imagesContainer.appendChild(label);
92+
});
93+
94+
let activeSongs = [];
95+
function between(n, m, M) {
96+
// n undefined returns true (assume valid when unknown)
97+
return !(n < m || n > M);
98+
}
99+
function parseDuration(val, def) {
100+
if (!val) {
101+
return def;
102+
}
103+
if (+val) {
104+
return +val;
105+
}
106+
let match = val.match(/^\s*(\d+)\s*:\s*(\d+)\s*$/);
107+
if (match) {
108+
return +match[2] + match[1] * 60;
109+
}
110+
return def;
111+
}
112+
function updateSongs() {
113+
let stars = [
114+
+fields.stars.from.value || 0,
115+
+fields.stars.to.value || Infinity
116+
].sort((a, b) => a - b);
117+
let rating = [
118+
+fields.rating.from.value || 0,
119+
+fields.rating.to.value || 100
120+
].sort((a, b) => a - b);
121+
let duration = [
122+
parseDuration(fields.duration.from.value, 0),
123+
parseDuration(fields.duration.to.value, Infinity)
124+
].sort((a, b) => a - b);
125+
let leaderboards = ranked.filter(ldb => {
126+
if (!fields.difficulty[ldb.diff].checked) {
127+
return false;
128+
}
129+
let dur = 60 * ldb.duration / ldb.bpm;
130+
return (
131+
between(ldb.stars, stars[0], stars[1])
132+
&& between(ldb.rating, rating[0], rating[1])
133+
&& between(dur, duration[0], duration[1])
134+
);
135+
});
136+
let [sortProp, sortDir] = fields.sort.value.split('-');
137+
sortDir = sortDir === 'desc' ? -1 : 1;
138+
leaderboards.sort((a, b) => a[sortProp] < b[sortProp] ? -sortDir : a[sortProp] > b[sortProp] ? sortDir : 0);
139+
let perSong = new Map();
140+
leaderboards.forEach(ldb => {
141+
let current = perSong.get(ldb.id);
142+
if (!current) {
143+
current = [];
144+
perSong.set(ldb.id, current);
145+
}
146+
current.push(ldb);
147+
});
148+
while (preview.list.firstChild) preview.list.removeChild(preview.list.firstChild);
149+
activeSongs = [...perSong.values()].map((list, i) => {
150+
let data = list[0];
151+
let previewElem = document.createElement('div');
152+
let content = (i + 1) + '. ' + data.artist + ' - ' + data.name + ' | ' + data.mapper;
153+
let details = [
154+
data.rating && ('👍 ' + data.rating.toFixed(2) + '%'),
155+
list.map(e => e.diff.replace('ExpertPlus', 'Expert+')).join(', ')
156+
].filter(e => e).join(' - ');
157+
if (details) {
158+
content += ' (' + details + ')';
159+
}
160+
previewElem.appendChild(document.createTextNode(content));
161+
preview.list.appendChild(previewElem);
162+
return { hash: data.id, songName: data.name };
163+
});
164+
let ldbCount = leaderboards.length;
165+
let songsCount = activeSongs.length;
166+
preview.count.textContent = ldbCount + ' leaderboard' + (ldbCount === 1 ? '' : 's') + ' from ' + songsCount + ' map' + (songsCount === 1 ? '' : 's');
167+
updateLink();
168+
}
169+
function updateLink() {
170+
let image = images.transparent;
171+
let selectedImage = $('.image-options input:checked');
172+
if (selectedImage) {
173+
image = selectedImage.value;
174+
}
175+
let bplist = {
176+
playlistTitle: fields.name.value || fields.name.placeholder,
177+
playlistAuthor: fields.author.value || fields.author.placeholder,
178+
image,
179+
songs: activeSongs.slice(0, +fields.count.value || Infinity)
180+
};
181+
$('.download').href = 'data:application/json;charset=utf-8,' + encodeURIComponent(JSON.stringify(bplist));
182+
}
183+
184+
[
185+
fields.stars.from,
186+
fields.stars.to,
187+
fields.rating.from,
188+
fields.rating.to,
189+
fields.duration.from,
190+
fields.duration.to,
191+
fields.sort,
192+
].forEach(input => input.addEventListener('input', updateSongs));
193+
[
194+
fields.name,
195+
fields.author,
196+
fields.count,
197+
].forEach(input => input.addEventListener('input', updateLink));
198+
199+
updateSongs();
200+
document.body.classList.add('loaded');
201+
})();

client/playlist-maker.min.js

+1
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

client/scoresaber.png

1.54 KB
Loading

index.js

+2
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ const paths = {
3737
index: 'client/index.html',
3838
peepee: 'client/peepee.html',
3939
overlay: 'client/overlay.html',
40+
playlistMaker: 'client/playlist-maker.html',
4041
};
4142
Object.keys(paths).forEach(key => paths[key] = path.resolve(paths[key]));
4243

@@ -50,6 +51,7 @@ app.get(['/favicon.ico', '/robots.txt'], serveStaticFiles);
5051
app.get('/', (req, res) => res.sendFile(paths.index));
5152
app.get('/peepee', (req, res) => res.sendFile(paths.peepee));
5253
app.get('/overlay', (req, res) => res.sendFile(paths.overlay));
54+
app.get('/playlist-maker', (req, res) => res.sendFile(paths.playlistMaker));
5355
app.use('/proxy', (req, res) => req.pipe(request('https://scoresaber.com' + req.url)).pipe(res));
5456

5557
app.get('/ranked', async (req, res) => {

0 commit comments

Comments
 (0)