Skip to content

Instantly share code, notes, and snippets.

@DerGoogler
Last active August 30, 2025 13:33
Show Gist options
  • Save DerGoogler/03be947bb49269dde6489d341710a85d to your computer and use it in GitHub Desktop.
Save DerGoogler/03be947bb49269dde6489d341710a85d to your computer and use it in GitHub Desktop.
WebUI X Audio Player Prototype.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>MMRL Audio Player</title>
<!-- Window Safe Area Insets -->
<link rel="stylesheet" type="text/css" href="https://mui.kernelsu.org/internal/insets.css" />
<!-- App Theme which the user has currently selected -->
<link rel="stylesheet" type="text/css" href="https://mui.kernelsu.org/internal/colors.css" />
<style>
* {
box-sizing: border-box;
}
html,
body {
margin: 0;
padding: 0;
height: 100%;
background: var(--background);
color: var(--onBackground);
font-family: 'Segoe UI', sans-serif;
}
body {
display: flex;
align-items: center;
justify-content: center;
}
.player-card {
/* If it doesn't apply: force the apply */
padding-top: calc(var(--window-inset-top, 0px) + 16px) !important;
padding-bottom: calc(var(--window-inset-bottom, 0px) + 16px) !important;
width: 100%;
height: 100%;
padding-left: 16px;
padding-right: 16px;
display: flex;
flex-direction: column;
align-items: center;
background: var(--surfaceContainer);
}
h2 {
margin: 0 0 1rem 0;
}
.file-list {
flex: 1;
width: 100%;
overflow-y: auto;
background: var(--surface);
border-radius: 8px;
border: 1px solid #3a2a2a;
}
.file-item {
padding: 1rem;
border-bottom: 1px solid #2a1a1a;
cursor: pointer;
}
.file-item:hover {
background: var(--primaryContainer);
}
.file-item.active {
background: var(--primary);
color: var(--onPrimary);
font-weight: bold;
}
.controls {
display: flex;
gap: 1rem;
margin: 1rem 0;
}
button {
padding: 0.75rem 1.5rem;
border: none;
border-radius: 8px;
font-size: 1rem;
cursor: pointer;
background: var(--filledTonalButtonContainerColor);
color: var(--filledTonalButtonContentColor);
}
button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.loader {
border: 4px solid rgba(255, 255, 255, 0.1);
border-top: 4px solid var(--primary);
border-radius: 50%;
width: 32px;
height: 32px;
animation: spin 1s linear infinite;
display: none;
}
.loader.active {
display: block;
}
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
#progressContainer {
width: 100%;
height: 12px;
background: #3a2a2a;
border-radius: 6px;
overflow: hidden;
cursor: pointer;
margin-top: auto;
}
#progressBar {
height: 100%;
width: 0%;
background: var(--primary);
transition: width 0.1s;
}
</style>
</head>
<body>
<div class="player-card">
<h2>Select a Song</h2>
<div class="file-list" id="fileList"></div>
<div class="controls">
<button id="playBtn" disabled>Play</button>
<button id="stopBtn" disabled>Stop</button>
<div class="loader" id="loader"></div>
</div>
<div id="progressContainer">
<div id="progressBar"></div>
</div>
</div>
<script type="module">
import { openInputStream } from "./openInputStream.js";
window.openInputStream = openInputStream
const fs = global.require("fs")
const fileListEl = document.getElementById('fileList');
const playBtn = document.getElementById('playBtn');
const stopBtn = document.getElementById('stopBtn');
const loader = document.getElementById('loader');
const progressContainer = document.getElementById('progressContainer');
const progressBar = document.getElementById('progressBar');
let currentFilename = null;
let audioContext = null;
let sourceNode = null;
let animationFrame = null;
let audioBuffer = null;
let startTime = 0;
let isPlaying = false;
const files = fs.listSync("/sdcard/Music/Telegram").split(",");
files.forEach(file => {
const el = document.createElement('div');
el.className = 'file-item';
el.textContent = file;
el.onclick = () => {
document.querySelectorAll('.file-item').forEach(i => i.classList.remove('active'));
el.classList.add('active');
currentFilename = file;
playBtn.disabled = false;
stopPlayback(); // auto-stop if switching
};
fileListEl.appendChild(el);
});
playBtn.onclick = async () => {
if (isPlaying) return;
playBtn.disabled = true;
stopBtn.disabled = false;
loader.classList.add('active');
try {
const path = `/sdcard/Music/Telegram/${currentFilename}`;
const stream = await openInputStream(path)
const buffer = await stream.arrayBuffer()
audioContext = new (window.AudioContext || window.webkitAudioContext)();
audioBuffer = await audioContext.decodeAudioData(buffer);
startPlayback();
} catch (e) {
console.error(e);
} finally {
loader.classList.remove('active');
}
};
stopBtn.onclick = () => {
stopPlayback();
playBtn.disabled = false;
};
function startPlayback(offset = 0) {
if (!audioBuffer) return;
sourceNode = audioContext.createBufferSource();
sourceNode.buffer = audioBuffer;
sourceNode.connect(audioContext.destination);
sourceNode.start(0, offset);
startTime = audioContext.currentTime - offset;
isPlaying = true;
animationFrame = requestAnimationFrame(updateProgress);
sourceNode.onended = stopPlayback;
}
function stopPlayback() {
if (sourceNode) {
sourceNode.stop();
sourceNode.disconnect();
}
if (audioContext) audioContext.close();
if (animationFrame) cancelAnimationFrame(animationFrame);
sourceNode = null;
audioContext = null;
audioBuffer = null;
isPlaying = false;
stopBtn.disabled = true;
progressBar.style.width = '0%';
}
function updateProgress() {
if (!audioContext || !audioBuffer) return;
const elapsed = audioContext.currentTime - startTime;
const percent = (elapsed / audioBuffer.duration) * 100;
progressBar.style.width = `${Math.min(100, percent)}%`;
if (isPlaying) animationFrame = requestAnimationFrame(updateProgress);
}
progressContainer.onclick = (e) => {
if (!audioBuffer || !audioContext) return;
const rect = progressContainer.getBoundingClientRect();
const clickX = e.clientX - rect.left;
const ratio = clickX / rect.width;
const seekTime = ratio * audioBuffer.duration;
stopPlayback();
audioContext = new (window.AudioContext || window.webkitAudioContext)();
startPlayback(seekTime);
};
</script>
</body>
</html>
export async function openInputStream(path, init = {}) {
const mergedInit = {
...readableStreamInit,
...init
}
return new Promise((resolve, reject) => {
const chunks = []
let aborted = false
const onAbort = () => {
aborted = true
cleanup()
reject(new DOMException("The operation was aborted.", "AbortError"))
}
if (mergedInit.signal) {
if (mergedInit.signal.aborted) {
onAbort()
return
}
mergedInit.signal.addEventListener("abort", onAbort)
}
function cleanup() {
if (mergedInit.signal) {
mergedInit.signal.removeEventListener("abort", onAbort)
}
window.FsInputStream.onmessage = null
}
window.FsInputStream.addEventListener("message", (event) => {
if (aborted) return
const msg = event.data
if (msg instanceof ArrayBuffer) {
chunks.push(new Uint8Array(msg))
} else if (typeof msg === "string") {
cleanup()
reject(new Error(msg))
return
}
// Once we have the full file (or a single chunk for now), create a stream
const stream = new ReadableStream({
start(controller) {
try {
for (const chunk of chunks) {
controller.enqueue(chunk)
}
controller.close()
cleanup()
} catch (e) {
cleanup()
controller.error(e)
}
},
cancel(reason) {
console.warn("Stream canceled:", reason)
cleanup()
}
})
resolve(new Response(stream, mergedInit))
})
// Send the request to Android
window.FsInputStream.postMessage(path)
})
}
export const readableStreamInit = {
headers: {
"Content-Type": "application/octet-stream"
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment