miniradio_server 0.0.3 → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +14 -0
- data/README.md +215 -101
- data/exe/miniradio_server +5 -0
- data/lib/miniradio_server/app.rb +129 -78
- data/lib/miniradio_server/cli.rb +55 -0
- data/lib/miniradio_server/templ/index.html.slim +53 -42
- data/lib/miniradio_server/version.rb +1 -1
- data/lib/miniradio_server.rb +6 -72
- data/lib/public/player.js +312 -0
- data/lib/public/style/main.css +55 -21
- metadata +76 -11
- data/Rakefile +0 -8
- data/hls_cache/.gitkeep +0 -0
- data/mp3_files/.gitkeep +0 -0
- data/sig/miniradio_server.rbs +0 -4
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
/* One audio element; streams and artwork are loaded only when selected. */
|
|
2
|
+
(() => {
|
|
3
|
+
const tracks = JSON.parse(document.getElementById('tracks').textContent);
|
|
4
|
+
const player = document.getElementById('player');
|
|
5
|
+
const tableBody = document.querySelector('#playlistTable tbody');
|
|
6
|
+
const ids = ['currentTrack', 'currentArtist', 'currentAlbum', 'currentArtwork', 'artworkPlaceholder',
|
|
7
|
+
'playbackState', 'playerStatus', 'elapsedTime', 'durationTime', 'seekControl', 'volumeControl',
|
|
8
|
+
'volumeControls', 'volumeHint', 'muteButton', 'customControls', 'startButton', 'playPauseButton',
|
|
9
|
+
'prevButton', 'nextButton', 'loopButton', 'shuffleButton'];
|
|
10
|
+
const ui = Object.fromEntries(ids.map(id => [id, document.getElementById(id)]));
|
|
11
|
+
const labels = { idle: 'No track selected', loading: 'Loading', playing: 'Playing', paused: 'Paused',
|
|
12
|
+
buffering: 'Buffering', blocked: 'Press Play', error: 'Playback error', finished: 'Finished', unsupported: 'Unsupported browser' };
|
|
13
|
+
const rowStates = [];
|
|
14
|
+
let currentIndex = -1;
|
|
15
|
+
let isLooping = false;
|
|
16
|
+
let isShuffling = false;
|
|
17
|
+
let hls;
|
|
18
|
+
let selection = 0;
|
|
19
|
+
let session;
|
|
20
|
+
let state = 'idle';
|
|
21
|
+
let seekSelection;
|
|
22
|
+
let removeMediaListeners = () => {};
|
|
23
|
+
|
|
24
|
+
// Retain the hls.js / ManagedMediaSource policy used by this application.
|
|
25
|
+
player.disableRemotePlayback = true;
|
|
26
|
+
|
|
27
|
+
function formatTime(seconds) {
|
|
28
|
+
if (!Number.isFinite(seconds) || seconds < 0) return '--:--';
|
|
29
|
+
const total = Math.floor(seconds);
|
|
30
|
+
const minutes = Math.floor(total / 60);
|
|
31
|
+
const remainder = String(total % 60).padStart(2, '0');
|
|
32
|
+
return minutes >= 60 ? `${Math.floor(minutes / 60)}:${String(minutes % 60).padStart(2, '0')}:${remainder}` : `${minutes}:${remainder}`;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function activeMedia() {
|
|
36
|
+
return session?.ready && session.id === selection && session.source === (player.currentSrc || player.src);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function renderProgress() {
|
|
40
|
+
const ready = activeMedia() && !['error', 'unsupported', 'loading'].includes(state);
|
|
41
|
+
const duration = ready ? player.duration : NaN;
|
|
42
|
+
const valid = Number.isFinite(duration) && duration > 0;
|
|
43
|
+
ui.durationTime.textContent = formatTime(valid ? duration : NaN);
|
|
44
|
+
ui.seekControl.disabled = !valid || player.seekable.length === 0;
|
|
45
|
+
ui.seekControl.max = valid ? duration : 0;
|
|
46
|
+
if (seekSelection !== selection) {
|
|
47
|
+
const elapsed = ready && Number.isFinite(player.currentTime) ? player.currentTime : 0;
|
|
48
|
+
ui.elapsedTime.textContent = formatTime(elapsed);
|
|
49
|
+
ui.seekControl.value = valid ? Math.min(duration, Math.max(0, elapsed)) : 0;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function renderPlaybackState() {
|
|
54
|
+
const selected = currentIndex >= 0;
|
|
55
|
+
ui.playbackState.textContent = labels[state];
|
|
56
|
+
ui.playPauseButton.textContent = state === 'error' ? 'Retry' : ['playing', 'buffering'].includes(state) ? 'Pause' : 'Play';
|
|
57
|
+
ui.playPauseButton.disabled = !selected || ['loading', 'unsupported'].includes(state);
|
|
58
|
+
ui.prevButton.disabled = !selected;
|
|
59
|
+
ui.nextButton.disabled = !selected || (!(isShuffling && tracks.length > 1) && currentIndex === tracks.length - 1);
|
|
60
|
+
ui.startButton.disabled = ui.loopButton.disabled = ui.shuffleButton.disabled = tracks.length === 0;
|
|
61
|
+
[...tableBody.children].forEach((row, i) => {
|
|
62
|
+
const active = i === currentIndex;
|
|
63
|
+
row.classList.toggle('active', active);
|
|
64
|
+
if (active) row.setAttribute('aria-current', 'true');
|
|
65
|
+
else row.removeAttribute('aria-current');
|
|
66
|
+
rowStates[i].textContent = active ? labels[state] : '';
|
|
67
|
+
});
|
|
68
|
+
renderProgress();
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function setState(next, message = '') {
|
|
72
|
+
state = next;
|
|
73
|
+
ui.playerStatus.textContent = message;
|
|
74
|
+
renderPlaybackState();
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function streamError() {
|
|
78
|
+
if (session) {
|
|
79
|
+
session.ready = false;
|
|
80
|
+
session.wantsPlay = false;
|
|
81
|
+
session.attempt += 1;
|
|
82
|
+
}
|
|
83
|
+
player.pause();
|
|
84
|
+
setState('error', 'Unable to load this stream. Press Retry or select another track.');
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function requestPlay() {
|
|
88
|
+
if (!activeMedia()) return;
|
|
89
|
+
const current = session;
|
|
90
|
+
const attempt = ++current.attempt;
|
|
91
|
+
current.wantsPlay = true;
|
|
92
|
+
player.play().catch(error => {
|
|
93
|
+
if (current !== session || attempt !== current.attempt || !current.wantsPlay) return;
|
|
94
|
+
current.wantsPlay = false;
|
|
95
|
+
if (error.name === 'NotSupportedError') streamError();
|
|
96
|
+
else setState('blocked', 'Playback could not start. Press Play to try again.');
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function renderCurrentTrack(track, selectionId) {
|
|
101
|
+
ui.currentTrack.textContent = track.title || track.file;
|
|
102
|
+
ui.currentArtist.textContent = track.artist || 'Artist information unavailable';
|
|
103
|
+
ui.currentAlbum.textContent = track.album || 'Album information unavailable';
|
|
104
|
+
ui.currentArtwork.hidden = true;
|
|
105
|
+
ui.currentArtwork.removeAttribute('src');
|
|
106
|
+
ui.artworkPlaceholder.hidden = false;
|
|
107
|
+
if (!track.artwork_url) return;
|
|
108
|
+
// Separate requests keep stale load/error events away from the visible image.
|
|
109
|
+
const image = new Image();
|
|
110
|
+
image.addEventListener('load', () => {
|
|
111
|
+
if (selectionId !== selection) return;
|
|
112
|
+
ui.currentArtwork.src = image.src;
|
|
113
|
+
ui.currentArtwork.hidden = false;
|
|
114
|
+
ui.artworkPlaceholder.hidden = true;
|
|
115
|
+
});
|
|
116
|
+
image.addEventListener('error', () => {
|
|
117
|
+
if (selectionId !== selection) return;
|
|
118
|
+
ui.currentArtwork.hidden = true;
|
|
119
|
+
ui.artworkPlaceholder.hidden = false;
|
|
120
|
+
});
|
|
121
|
+
image.src = track.artwork_url;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function bindMedia(current) {
|
|
125
|
+
const listeners = {
|
|
126
|
+
playing: () => {
|
|
127
|
+
if (player.paused || player.ended || !current.wantsPlay) return;
|
|
128
|
+
setState('playing');
|
|
129
|
+
},
|
|
130
|
+
pause: () => {
|
|
131
|
+
if (!player.paused || player.ended || ['error', 'blocked', 'finished'].includes(state)) return;
|
|
132
|
+
current.wantsPlay = false;
|
|
133
|
+
current.attempt += 1;
|
|
134
|
+
setState('paused');
|
|
135
|
+
},
|
|
136
|
+
waiting: () => {
|
|
137
|
+
if (!player.paused && current.wantsPlay) setState('buffering', 'Buffering audio…');
|
|
138
|
+
},
|
|
139
|
+
ended: () => {
|
|
140
|
+
if (!player.ended || state === 'finished') return;
|
|
141
|
+
current.wantsPlay = false;
|
|
142
|
+
if (isLooping) playTrack(currentIndex);
|
|
143
|
+
else if ((isShuffling && tracks.length > 1) || currentIndex < tracks.length - 1) nextTrack();
|
|
144
|
+
else setState('finished', 'Playlist finished. Press Play all to restart.');
|
|
145
|
+
},
|
|
146
|
+
error: () => { if (player.error) streamError(); },
|
|
147
|
+
loadedmetadata: renderProgress,
|
|
148
|
+
durationchange: renderProgress,
|
|
149
|
+
progress: renderProgress,
|
|
150
|
+
timeupdate: renderProgress
|
|
151
|
+
};
|
|
152
|
+
const bound = Object.entries(listeners).map(([event, listener]) => {
|
|
153
|
+
const guarded = () => {
|
|
154
|
+
if (current === session && activeMedia()) listener();
|
|
155
|
+
};
|
|
156
|
+
player.addEventListener(event, guarded);
|
|
157
|
+
return [event, guarded];
|
|
158
|
+
});
|
|
159
|
+
removeMediaListeners = () => bound.forEach(([event, listener]) => player.removeEventListener(event, listener));
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function playTrack(index) {
|
|
163
|
+
const track = tracks[index];
|
|
164
|
+
if (!track) return;
|
|
165
|
+
selection += 1;
|
|
166
|
+
removeMediaListeners();
|
|
167
|
+
session = undefined;
|
|
168
|
+
player.pause();
|
|
169
|
+
if (hls) hls.destroy();
|
|
170
|
+
hls = undefined;
|
|
171
|
+
player.removeAttribute('src');
|
|
172
|
+
player.load();
|
|
173
|
+
currentIndex = index;
|
|
174
|
+
seekSelection = undefined;
|
|
175
|
+
renderCurrentTrack(track, selection);
|
|
176
|
+
const current = { id: selection, ready: false, source: undefined, wantsPlay: true, attempt: 0 };
|
|
177
|
+
session = current;
|
|
178
|
+
setState('loading', 'Loading track…');
|
|
179
|
+
bindMedia(current);
|
|
180
|
+
if (typeof Hls === 'undefined' || !Hls.isSupported()) {
|
|
181
|
+
setState('unsupported', 'This browser cannot play HLS streams. Update your browser or reload the page to retry.');
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
hls = new Hls();
|
|
185
|
+
hls.on(Hls.Events.MANIFEST_PARSED, () => {
|
|
186
|
+
if (current !== session || state === 'error') return;
|
|
187
|
+
current.ready = true;
|
|
188
|
+
current.source = player.currentSrc || player.src;
|
|
189
|
+
renderProgress();
|
|
190
|
+
requestPlay();
|
|
191
|
+
});
|
|
192
|
+
hls.on(Hls.Events.ERROR, (_event, data) => {
|
|
193
|
+
if (current === session && data.fatal) streamError();
|
|
194
|
+
});
|
|
195
|
+
hls.loadSource(track.url);
|
|
196
|
+
hls.attachMedia(player);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function nextTrack() {
|
|
200
|
+
if (currentIndex < 0) return;
|
|
201
|
+
if (isShuffling && tracks.length > 1) {
|
|
202
|
+
const offset = 1 + Math.floor(Math.random() * (tracks.length - 1));
|
|
203
|
+
playTrack((currentIndex + offset) % tracks.length);
|
|
204
|
+
} else if (currentIndex + 1 < tracks.length) playTrack(currentIndex + 1);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
ui.startButton.addEventListener('click', () => playTrack(0));
|
|
208
|
+
ui.playPauseButton.addEventListener('click', () => {
|
|
209
|
+
if (ui.playPauseButton.disabled) return;
|
|
210
|
+
if (['error', 'finished'].includes(state)) playTrack(currentIndex);
|
|
211
|
+
else if (player.paused) requestPlay();
|
|
212
|
+
else {
|
|
213
|
+
session.wantsPlay = false;
|
|
214
|
+
session.attempt += 1;
|
|
215
|
+
player.pause();
|
|
216
|
+
}
|
|
217
|
+
});
|
|
218
|
+
ui.nextButton.addEventListener('click', nextTrack);
|
|
219
|
+
ui.prevButton.addEventListener('click', () => {
|
|
220
|
+
if (currentIndex >= 0) playTrack((currentIndex + tracks.length - 1) % tracks.length);
|
|
221
|
+
});
|
|
222
|
+
ui.loopButton.addEventListener('click', () => {
|
|
223
|
+
isLooping = !isLooping;
|
|
224
|
+
ui.loopButton.textContent = `Repeat track: ${isLooping ? 'ON' : 'OFF'}`;
|
|
225
|
+
ui.loopButton.setAttribute('aria-pressed', String(isLooping));
|
|
226
|
+
});
|
|
227
|
+
ui.shuffleButton.addEventListener('click', () => {
|
|
228
|
+
isShuffling = !isShuffling;
|
|
229
|
+
ui.shuffleButton.textContent = `Shuffle: ${isShuffling ? 'ON' : 'OFF'}`;
|
|
230
|
+
ui.shuffleButton.setAttribute('aria-pressed', String(isShuffling));
|
|
231
|
+
renderPlaybackState();
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
ui.seekControl.addEventListener('input', () => {
|
|
235
|
+
if (ui.seekControl.disabled) return;
|
|
236
|
+
seekSelection = selection;
|
|
237
|
+
ui.elapsedTime.textContent = formatTime(Number(ui.seekControl.value));
|
|
238
|
+
});
|
|
239
|
+
ui.seekControl.addEventListener('change', () => {
|
|
240
|
+
if (seekSelection !== selection || ui.seekControl.disabled) {
|
|
241
|
+
seekSelection = undefined;
|
|
242
|
+
renderProgress();
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
const desired = Number(ui.seekControl.value);
|
|
246
|
+
const ranges = player.seekable;
|
|
247
|
+
let nearest;
|
|
248
|
+
for (let i = 0; i < ranges.length; i += 1) {
|
|
249
|
+
const candidate = Math.max(ranges.start(i), Math.min(ranges.end(i), desired));
|
|
250
|
+
if (nearest === undefined || Math.abs(candidate - desired) < Math.abs(nearest - desired)) nearest = candidate;
|
|
251
|
+
}
|
|
252
|
+
seekSelection = undefined;
|
|
253
|
+
if (Number.isFinite(nearest)) player.currentTime = nearest;
|
|
254
|
+
renderProgress();
|
|
255
|
+
});
|
|
256
|
+
ui.seekControl.addEventListener('blur', () => {
|
|
257
|
+
seekSelection = undefined;
|
|
258
|
+
renderProgress();
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
function renderVolume() {
|
|
262
|
+
ui.volumeControl.value = player.volume;
|
|
263
|
+
ui.muteButton.textContent = player.muted ? 'Unmute' : 'Mute';
|
|
264
|
+
ui.muteButton.setAttribute('aria-pressed', String(player.muted));
|
|
265
|
+
}
|
|
266
|
+
// Some devices expose volume but do not allow scripts to change it.
|
|
267
|
+
const originalVolume = player.volume;
|
|
268
|
+
let canChangeVolume = false;
|
|
269
|
+
try {
|
|
270
|
+
const probe = originalVolume > 0.5 ? 0.25 : 0.75;
|
|
271
|
+
player.volume = probe;
|
|
272
|
+
canChangeVolume = player.volume === probe;
|
|
273
|
+
} catch (_) {
|
|
274
|
+
canChangeVolume = false;
|
|
275
|
+
} finally {
|
|
276
|
+
try { player.volume = originalVolume; } catch (_) { /* Device volume is read-only. */ }
|
|
277
|
+
}
|
|
278
|
+
ui.volumeControls.hidden = !canChangeVolume;
|
|
279
|
+
ui.volumeHint.hidden = canChangeVolume;
|
|
280
|
+
ui.volumeControl.addEventListener('input', () => { player.volume = Number(ui.volumeControl.value); });
|
|
281
|
+
ui.muteButton.addEventListener('click', () => { player.muted = !player.muted; });
|
|
282
|
+
player.addEventListener('volumechange', renderVolume);
|
|
283
|
+
renderVolume();
|
|
284
|
+
|
|
285
|
+
tracks.forEach((track, index) => {
|
|
286
|
+
const row = document.createElement('tr');
|
|
287
|
+
const playCell = document.createElement('td');
|
|
288
|
+
const button = document.createElement('button');
|
|
289
|
+
button.textContent = '▶';
|
|
290
|
+
button.setAttribute('aria-label', `Play ${track.title || track.file}`);
|
|
291
|
+
button.addEventListener('click', () => playTrack(index));
|
|
292
|
+
playCell.dataset.label = 'Play';
|
|
293
|
+
playCell.appendChild(button);
|
|
294
|
+
row.appendChild(playCell);
|
|
295
|
+
[[track.title || track.file, 'Title'], [track.artist || '', 'Artist'], [track.album || '', 'Album']].forEach(([text, label]) => {
|
|
296
|
+
const cell = document.createElement('td');
|
|
297
|
+
cell.textContent = text;
|
|
298
|
+
cell.dataset.label = label;
|
|
299
|
+
row.appendChild(cell);
|
|
300
|
+
});
|
|
301
|
+
const rowState = document.createElement('td');
|
|
302
|
+
rowState.dataset.label = 'Status';
|
|
303
|
+
rowState.classList.add('track-status');
|
|
304
|
+
rowStates.push(rowState);
|
|
305
|
+
row.appendChild(rowState);
|
|
306
|
+
tableBody.appendChild(row);
|
|
307
|
+
});
|
|
308
|
+
setState('idle', tracks.length ? '' : 'No MP3 files found.');
|
|
309
|
+
ui.customControls.hidden = false;
|
|
310
|
+
player.controls = false;
|
|
311
|
+
player.hidden = true;
|
|
312
|
+
})();
|
data/lib/public/style/main.css
CHANGED
|
@@ -1,26 +1,60 @@
|
|
|
1
1
|
@charset "utf-8";
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
3
|
+
[hidden] { display: none !important; }
|
|
4
|
+
#player { width: 100%; }
|
|
5
|
+
.now-playing {
|
|
6
|
+
background: var(--pico-card-background-color);
|
|
7
|
+
border: 1px solid var(--pico-muted-border-color);
|
|
8
|
+
border-radius: var(--pico-border-radius);
|
|
9
|
+
padding: 1.25rem;
|
|
7
10
|
}
|
|
11
|
+
.track-details { display: grid; grid-template-columns: 144px minmax(0, 1fr); gap: 1.5rem; align-items: center; }
|
|
12
|
+
.artwork { width: 144px; aspect-ratio: 1; background: var(--pico-background-color); border-radius: var(--pico-border-radius); overflow: hidden; }
|
|
13
|
+
.artwork img { width: 100%; height: 100%; object-fit: contain; }
|
|
14
|
+
#artworkPlaceholder { height: 100%; display: flex; flex-direction: column; align-items: center; justify-content: center; color: var(--pico-muted-color); }
|
|
15
|
+
#artworkPlaceholder span { font-size: 2.5rem; }
|
|
16
|
+
#artworkPlaceholder small { font-size: 0.65rem; }
|
|
17
|
+
.track-metadata { min-width: 0; overflow-wrap: anywhere; }
|
|
18
|
+
.track-metadata h2 { margin: 0.4rem 0; }
|
|
19
|
+
.track-metadata p { margin: 0; }
|
|
20
|
+
#playbackState { color: var(--pico-primary); font-size: 0.8rem; }
|
|
21
|
+
#currentArtist, #currentAlbum { color: var(--pico-muted-color); }
|
|
22
|
+
#currentAlbum { font-size: 0.85rem; }
|
|
23
|
+
.time-labels { display: flex; justify-content: space-between; margin-top: 1rem; color: var(--pico-muted-color); font-size: 0.8rem; font-variant-numeric: tabular-nums; }
|
|
24
|
+
#seekControl { width: 100%; margin: 0; min-height: 44px; }
|
|
25
|
+
.transport, .playback-options, .volume { display: flex; flex-wrap: wrap; gap: 0.5rem; align-items: center; }
|
|
26
|
+
.transport button, .playback-options button, .volume button { width: auto; min-height: 44px; margin: 0; padding: 0.5rem 0.75rem; }
|
|
27
|
+
.transport button:not(#playPauseButton), .playback-options button { --pico-background-color: transparent; --pico-color: var(--pico-primary); }
|
|
28
|
+
#playPauseButton { min-width: 7rem; }
|
|
29
|
+
.volume { margin-left: auto; font-size: 0.8rem; }
|
|
30
|
+
.volume label { margin: 0; }
|
|
31
|
+
#volumeControl { width: 90px; margin: 0; min-height: 44px; }
|
|
32
|
+
.playback-options { border-top: 1px solid var(--pico-muted-border-color); padding-top: 1rem; margin-top: 1rem; font-size: 0.8rem; }
|
|
33
|
+
.playback-options button[aria-pressed="true"] { background: var(--pico-primary); color: var(--pico-primary-inverse); }
|
|
34
|
+
#playerStatus { margin: 1rem 0 0; font-size: 0.85rem; overflow-wrap: anywhere; }
|
|
35
|
+
#playerStatus:empty { margin: 0; }
|
|
36
|
+
.playlist-heading { margin-top: 1.5rem; }
|
|
37
|
+
tr.active { outline: 2px solid var(--pico-primary); outline-offset: -2px; }
|
|
38
|
+
.track-status { color: var(--pico-primary); }
|
|
39
|
+
table.compact td, table.compact th { padding: 0.4rem 0.5rem; font-size: 0.9rem; line-height: 1.2; overflow-wrap: anywhere; }
|
|
40
|
+
table.compact { table-layout: fixed; }
|
|
41
|
+
table.compact th:first-child { width: 4rem; }
|
|
42
|
+
table.compact button { margin: 0; min-width: 44px; min-height: 44px; padding: 0.4rem; }
|
|
8
43
|
@media screen and (max-width: 600px) {
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
}
|
|
44
|
+
.now-playing { padding: 0.8rem; }
|
|
45
|
+
.track-details { grid-template-columns: 80px minmax(0, 1fr); gap: 0.8rem; }
|
|
46
|
+
.artwork { width: 80px; }
|
|
47
|
+
#artworkPlaceholder small { display: none; }
|
|
48
|
+
.track-metadata h2 { font-size: 1.2rem; }
|
|
49
|
+
.transport { gap: 0.4rem; }
|
|
50
|
+
.transport > button { flex: 1; padding: 0.4rem; font-size: 0.8rem; }
|
|
51
|
+
#playPauseButton { min-width: 0; }
|
|
52
|
+
.volume { margin-left: 0; width: 100%; }
|
|
53
|
+
#volumeControl { flex: 1; min-width: 0; }
|
|
54
|
+
table thead { display: none; }
|
|
55
|
+
table tr { display: grid; grid-template-columns: 44px minmax(0, 1fr); column-gap: 0.75rem; margin-bottom: 0.8rem; padding: 0.6rem; }
|
|
56
|
+
table td { display: block; grid-column: 2; padding: 0.15rem 0; border: none; }
|
|
57
|
+
table td:first-child { grid-column: 1; grid-row: 1 / span 5; align-self: center; }
|
|
58
|
+
table td[data-label="Artist"], table td[data-label="Album"] { color: var(--pico-muted-color); font-size: 0.8rem; }
|
|
59
|
+
table td:empty { display: none; }
|
|
26
60
|
}
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: miniradio_server
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.0
|
|
4
|
+
version: 0.1.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Koichiro Ohba
|
|
@@ -52,19 +52,81 @@ dependencies:
|
|
|
52
52
|
- !ruby/object:Gem::Version
|
|
53
53
|
version: '5.16'
|
|
54
54
|
- !ruby/object:Gem::Dependency
|
|
55
|
-
name:
|
|
55
|
+
name: standard
|
|
56
|
+
requirement: !ruby/object:Gem::Requirement
|
|
57
|
+
requirements:
|
|
58
|
+
- - "~>"
|
|
59
|
+
- !ruby/object:Gem::Version
|
|
60
|
+
version: '1.0'
|
|
61
|
+
type: :development
|
|
62
|
+
prerelease: false
|
|
63
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
64
|
+
requirements:
|
|
65
|
+
- - "~>"
|
|
66
|
+
- !ruby/object:Gem::Version
|
|
67
|
+
version: '1.0'
|
|
68
|
+
- !ruby/object:Gem::Dependency
|
|
69
|
+
name: simplecov
|
|
70
|
+
requirement: !ruby/object:Gem::Requirement
|
|
71
|
+
requirements:
|
|
72
|
+
- - "~>"
|
|
73
|
+
- !ruby/object:Gem::Version
|
|
74
|
+
version: '1.3'
|
|
75
|
+
type: :development
|
|
76
|
+
prerelease: false
|
|
77
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
78
|
+
requirements:
|
|
79
|
+
- - "~>"
|
|
80
|
+
- !ruby/object:Gem::Version
|
|
81
|
+
version: '1.3'
|
|
82
|
+
- !ruby/object:Gem::Dependency
|
|
83
|
+
name: bundler-audit
|
|
84
|
+
requirement: !ruby/object:Gem::Requirement
|
|
85
|
+
requirements:
|
|
86
|
+
- - "~>"
|
|
87
|
+
- !ruby/object:Gem::Version
|
|
88
|
+
version: '0.9'
|
|
89
|
+
type: :development
|
|
90
|
+
prerelease: false
|
|
91
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
92
|
+
requirements:
|
|
93
|
+
- - "~>"
|
|
94
|
+
- !ruby/object:Gem::Version
|
|
95
|
+
version: '0.9'
|
|
96
|
+
- !ruby/object:Gem::Dependency
|
|
97
|
+
name: rack
|
|
56
98
|
requirement: !ruby/object:Gem::Requirement
|
|
57
99
|
requirements:
|
|
58
100
|
- - ">="
|
|
59
101
|
- !ruby/object:Gem::Version
|
|
60
|
-
version: '
|
|
102
|
+
version: '3.1'
|
|
103
|
+
- - "<"
|
|
104
|
+
- !ruby/object:Gem::Version
|
|
105
|
+
version: '4'
|
|
61
106
|
type: :runtime
|
|
62
107
|
prerelease: false
|
|
63
108
|
version_requirements: !ruby/object:Gem::Requirement
|
|
64
109
|
requirements:
|
|
65
110
|
- - ">="
|
|
66
111
|
- !ruby/object:Gem::Version
|
|
67
|
-
version: '
|
|
112
|
+
version: '3.1'
|
|
113
|
+
- - "<"
|
|
114
|
+
- !ruby/object:Gem::Version
|
|
115
|
+
version: '4'
|
|
116
|
+
- !ruby/object:Gem::Dependency
|
|
117
|
+
name: rackup
|
|
118
|
+
requirement: !ruby/object:Gem::Requirement
|
|
119
|
+
requirements:
|
|
120
|
+
- - "~>"
|
|
121
|
+
- !ruby/object:Gem::Version
|
|
122
|
+
version: '2.2'
|
|
123
|
+
type: :runtime
|
|
124
|
+
prerelease: false
|
|
125
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
126
|
+
requirements:
|
|
127
|
+
- - "~>"
|
|
128
|
+
- !ruby/object:Gem::Version
|
|
129
|
+
version: '2.2'
|
|
68
130
|
- !ruby/object:Gem::Dependency
|
|
69
131
|
name: webrick
|
|
70
132
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -154,27 +216,30 @@ description: |2
|
|
|
154
216
|
This server is designed for simplicity and primarily targets Video on Demand (VOD) scenarios where you want to stream existing MP3 files via HLS without pre-converting them.
|
|
155
217
|
email:
|
|
156
218
|
- koichiro.ohba@gmail.com
|
|
157
|
-
executables:
|
|
219
|
+
executables:
|
|
220
|
+
- miniradio_server
|
|
158
221
|
extensions: []
|
|
159
222
|
extra_rdoc_files: []
|
|
160
223
|
files:
|
|
224
|
+
- CHANGELOG.md
|
|
161
225
|
- LICENSE.txt
|
|
162
226
|
- README.md
|
|
163
|
-
-
|
|
164
|
-
- hls_cache/.gitkeep
|
|
227
|
+
- exe/miniradio_server
|
|
165
228
|
- lib/miniradio_server.rb
|
|
166
229
|
- lib/miniradio_server/app.rb
|
|
230
|
+
- lib/miniradio_server/cli.rb
|
|
167
231
|
- lib/miniradio_server/templ/index.html.slim
|
|
168
232
|
- lib/miniradio_server/version.rb
|
|
233
|
+
- lib/public/player.js
|
|
169
234
|
- lib/public/style/main.css
|
|
170
|
-
- mp3_files/.gitkeep
|
|
171
|
-
- sig/miniradio_server.rbs
|
|
172
235
|
homepage: https://github.com/koichiro/miniradio_server
|
|
173
236
|
licenses:
|
|
174
237
|
- MIT
|
|
175
238
|
metadata:
|
|
239
|
+
allowed_push_host: https://rubygems.org
|
|
176
240
|
homepage_uri: https://github.com/koichiro/miniradio_server
|
|
177
241
|
source_code_uri: https://github.com/koichiro/miniradio_server.git
|
|
242
|
+
changelog_uri: https://github.com/koichiro/miniradio_server/blob/main/CHANGELOG.md
|
|
178
243
|
rdoc_options: []
|
|
179
244
|
require_paths:
|
|
180
245
|
- lib
|
|
@@ -182,14 +247,14 @@ required_ruby_version: !ruby/object:Gem::Requirement
|
|
|
182
247
|
requirements:
|
|
183
248
|
- - ">="
|
|
184
249
|
- !ruby/object:Gem::Version
|
|
185
|
-
version: 3.
|
|
250
|
+
version: 3.4.0
|
|
186
251
|
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
187
252
|
requirements:
|
|
188
253
|
- - ">="
|
|
189
254
|
- !ruby/object:Gem::Version
|
|
190
255
|
version: '0'
|
|
191
256
|
requirements: []
|
|
192
|
-
rubygems_version:
|
|
257
|
+
rubygems_version: 4.0.20
|
|
193
258
|
specification_version: 4
|
|
194
259
|
summary: Miniradio Server is Simple Ruby HLS Server for MP3s.
|
|
195
260
|
test_files: []
|
data/Rakefile
DELETED
data/hls_cache/.gitkeep
DELETED
|
File without changes
|
data/mp3_files/.gitkeep
DELETED
|
File without changes
|
data/sig/miniradio_server.rbs
DELETED