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.

Revisions

  1. DerGoogler revised this gist Aug 30, 2025. 3 changed files with 86 additions and 101 deletions.
    31 changes: 9 additions & 22 deletions index.html
    Original file line number Diff line number Diff line change
    @@ -153,9 +153,11 @@ <h2>Select a Song</h2>
    </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))];
    import { openInputStream } from "./openInputStream.js";

    window.openInputStream = openInputStream

    const fs = global.require("fs")

    const fileListEl = document.getElementById('fileList');
    const playBtn = document.getElementById('playBtn');
    @@ -172,7 +174,7 @@ <h2>Select a Song</h2>
    let startTime = 0;
    let isPlaying = false;

    const files = fileInterface.list("/sdcard/Music/Telegram").split(",");
    const files = fs.listSync("/sdcard/Music/Telegram").split(",");
    files.forEach(file => {
    const el = document.createElement('div');
    el.className = 'file-item';
    @@ -196,26 +198,11 @@ <h2>Select a Song</h2>

    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;
    }
    const stream = await openInputStream(path)
    const buffer = await stream.arrayBuffer()

    audioContext = new (window.AudioContext || window.webkitAudioContext)();
    audioBuffer = await audioContext.decodeAudioData(merged.buffer);
    audioBuffer = await audioContext.decodeAudioData(buffer);

    startPlayback();
    } catch (e) {
    77 changes: 77 additions & 0 deletions openInputStream.mjs
    Original file line number Diff line number Diff line change
    @@ -0,0 +1,77 @@
    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"
    }
    }
    79 changes: 0 additions & 79 deletions wrapToReadableStream.mjs
    Original file line number Diff line number Diff line change
    @@ -1,79 +0,0 @@
    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);
    });
    }
  2. DerGoogler created this gist May 11, 2025.
    281 changes: 281 additions & 0 deletions index.html
    Original file line number Diff line number Diff line change
    @@ -0,0 +1,281 @@
    <!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>
    79 changes: 79 additions & 0 deletions wrapToReadableStream.mjs
    Original file line number Diff line number Diff line change
    @@ -0,0 +1,79 @@
    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);
    });
    }