Last active
August 30, 2025 13:33
-
-
Save DerGoogler/03be947bb49269dde6489d341710a85d to your computer and use it in GitHub Desktop.
WebUI X Audio Player Prototype.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<!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> |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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