Skip to content

Instantly share code, notes, and snippets.

@DerGoogler
Created May 11, 2025 15:49
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 { wrapToReadableStream } from "./wrapToReadableStream.mjs";
const fileInterface = window[Object.keys(window).find(key => key.match(/^\$(\w{2})File$/m))];
const fileInputInterface = window[Object.keys(window).find(key => key.match(/^\$(\w{2})FileInputStream$/m))];
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 = fileInterface.list("/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 inputStream = fileInputInterface.open(path);
const stream = await wrapToReadableStream(inputStream, { chunkSize: 64 * 1024 });
const reader = stream.getReader();
const chunks = [];
while (true) {
const { done, value } = await reader.read();
if (done) break;
chunks.push(value);
}
const merged = new Uint8Array(chunks.reduce((a, b) => a + b.length, 0));
let offset = 0;
for (const chunk of chunks) {
merged.set(chunk, offset);
offset += chunk.length;
}
audioContext = new (window.AudioContext || window.webkitAudioContext)();
audioBuffer = await audioContext.decodeAudioData(merged.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 const defaultStreamOptions = {
chunkSize: 1024 * 1024,
signal: null,
};
export async function wrapToReadableStream(inputStream, options = {}) {
const mergedOptions = { ...defaultStreamOptions, ...options };
return new Promise((resolve, reject) => {
let input;
try {
input = inputStream;
if (!input) {
throw new Error("Failed to open file input stream");
}
} catch (error) {
reject(error);
return;
}
const abortHandler = () => {
try {
input?.close();
} catch (error) {
console.error("Error during abort cleanup:", error);
}
reject(new DOMException("The operation was aborted.", "AbortError"));
};
if (mergedOptions.signal) {
if (mergedOptions.signal.aborted) {
abortHandler();
return;
}
mergedOptions.signal.addEventListener("abort", abortHandler);
}
const stream = new ReadableStream({
async pull(controller) {
try {
const chunkData = input.readChunk(mergedOptions.chunkSize);
if (!chunkData) {
controller.close();
cleanup();
return;
}
const chunk = JSON.parse(chunkData);
if (chunk && chunk.length > 0) {
controller.enqueue(new Uint8Array(chunk));
} else {
controller.close();
cleanup();
}
} catch (error) {
cleanup();
controller.error(error);
reject(new Error("Error reading file chunk: " + error.message));
}
},
cancel() {
cleanup();
},
});
function cleanup() {
try {
if (mergedOptions.signal) {
mergedOptions.signal.removeEventListener("abort", abortHandler);
}
input?.close();
} catch (error) {
console.error("Error during cleanup:", error);
}
}
resolve(stream);
});
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment