Created
May 11, 2025 15:49
-
-
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 { 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> |
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 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