file/playlist download to local file system, design update
Mwebclient/index.html
@@ -15,7 +15,6 @@
span {
padding: 1rem;
background-color: rgba(0, 0, 0, 0.8);
border-radius: 4px;
}
body {
@@ -43,10 +42,9 @@
height: 100vh;
background: #3f4d4e;
div {
span {
padding: 1rem;
background-color: rgba(0, 0, 0, 0.8);
border-radius: 4px;
}
}
</style>
@@ -56,9 +54,9 @@
<noscript>Pico Pixel Player requires javascript to be enabled.</noscript>
<div id="root">
<div id="inital-loading-screen">
<div>
<span>
Loading required assets to start...
</div>
</span>
</div>
</div>
<script src="./src/index.tsx" type="module"></script>
Mwebclient/src/App.tsx
@@ -10,10 +10,10 @@ import Playlist from "./components/Playlist";
import { getSrc } from "./offline";
//TODO: download single file, playlist
//TODO: play directly from filebrowser
//TODO: when downloading dirs, maybe put all subfiles in a "downloading" state to not accidentally trigger them twice
//TODO: make sure all button have title texts
//TODO: better touch controls?
//---
//TODO: setting export/import
//TODO: playlist management
//TODO: Add documentation to functions
@@ -270,6 +270,7 @@ const App: Component = () => {
/>
<Playlist
currentDir={currentDir}
playlist={playlist}
setPlaylist={setPlaylist}
playerState={playerState}
Mwebclient/src/components/FileBrowser.css
@@ -68,7 +68,6 @@
padding: 0.5rem;
margin-bottom: 0.25rem;
background-color: var(--color-c);
border-radius: 4px;
display: flex;
align-items: center;
&:hover {
Mwebclient/src/components/OptionsModal.css
@@ -4,7 +4,6 @@ dialog {
left: 50%;
transform: translate(-50%, -50%);
border: none;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.35);
width: 400px;
max-width: 90%;
Mwebclient/src/components/PlayerControls.css
@@ -39,7 +39,6 @@
flex: 1;
height: 8px;
background-color: #ddd;
border-radius: 4px;
cursor: pointer;
}
@@ -49,7 +48,6 @@
top: 0;
left: 0;
height: 100%;
border-radius: 4px;
cursor: pointer;
background-color: #0cc069;
}
Mwebclient/src/components/Playlist.css
@@ -7,7 +7,7 @@
.playlist-header {
display: flex;
justify-content: space-between;
gap: 0.5rem;
align-items: center;
padding: 0.5rem;
font-weight: bold;
@@ -37,21 +37,14 @@
gap: 0.25rem;
}
.playlist-remove {
background: none;
color: var(--text-color);
margin-left: 0.5rem;
}
.playlist-item {
padding: 0.5rem;
background-color: var(--color-c);
border-radius: 4px;
border-width: 0;
border-color: var(--color-d);
display: flex;
justify-content: space-between;
align-items: center;
gap: 0.5rem;
div {
display: flex;
Mwebclient/src/components/Playlist.tsx
@@ -1,10 +1,13 @@
import { type Accessor, For, type Setter, createSignal, onMount, onCleanup, createEffect } from "solid-js";
import "./Playlist.css";
import type { PlayerState, PlaylistItem } from "../types";
import { formatTime } from "../utils";
import { ClearSVG, DragSVG } from "./SVG";
import { formatTime, joinPath, toast } from "../utils";
import { ClearSVG, DownloadSVG, DragSVG } from "./SVG";
import { createTar } from "../tar";
import Toastify from "toastify-js";
interface PlaylistProps {
currentDir: Accessor<string>;
playlist: Accessor<PlaylistItem[]>;
setPlaylist: Setter<PlaylistItem[]>;
playerState: Accessor<PlayerState>;
@@ -141,6 +144,54 @@ export default function Playlist(props: PlaylistProps) {
scheduleUpdate(e.touches[0].clientY);
}
function browserDownload(name: string, path: string) {
const a = document.createElement("a");
a.href = `/download/${path}`;
a.download = name;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
}
async function browserDownloadAll() {
const playlist = props.playlist();
const containerElement = document.createElement("div");
containerElement.style = "display:flex;flex-direction:column;";
containerElement.textContent = "Download playlist...";
const progressElement = document.createElement("progress");
progressElement.value = 0;
progressElement.max = playlist.length;
containerElement.appendChild(progressElement);
const progressToast = Toastify({
node: containerElement,
duration: -1,
close: false,
});
progressToast.showToast();
try {
const contentMap = new Map<string, Uint8Array>();
for (const item of playlist) {
const response = await fetch(`/download/${item.path}`);
if (!response.ok) throw new Error(`Download of ${item.name} was not successful`);
contentMap.set(item.name, new Uint8Array(await response.arrayBuffer()));
progressElement.value += 1;
}
const tarFile = createTar(contentMap);
const a = document.createElement("a");
a.href = URL.createObjectURL(new Blob([tarFile]));
a.download = "playlist.tar";
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
} catch (e) {
toast("Playlist items download failed", "error", e);
} finally {
progressToast.hideToast();
}
}
onMount(() => {
const cancelDrag = () => endDrag(false);
const doDrag = () => endDrag(true);
@@ -160,6 +211,13 @@ export default function Playlist(props: PlaylistProps) {
<div class="playlist-container">
<div class="playlist-header">
<span>Playlist ({props.playlist().length} tracks)</span>
<div class="spacer" />
<button
title="Download all files in the playlist in their original format as a tar file"
onClick={browserDownloadAll}
>
<DownloadSVG />
</button>
<button title="Clear Playlist" onClick={() => props.setPlaylist([])}>
<ClearSVG />
</button>
@@ -191,19 +249,26 @@ export default function Playlist(props: PlaylistProps) {
<DragSVG />
</div>
<span>{item.name}</span>
<div>
{formatTime(item.duration)}
<button
class="playlist-remove"
title={`Remove ${item.name}`}
onClick={(e) => {
e.stopPropagation();
removeFromPlaylist(item.id);
}}
>
<ClearSVG />
</button>
</div>
<div class="spacer" />
{formatTime(item.duration)}
<button
title={"Download this file in its original format to your file system"}
onClick={(e) => {
e.stopPropagation();
browserDownload(item.name, item.path);
}}
>
<DownloadSVG />
</button>
<button
title={`Remove ${item.name}`}
onClick={(e) => {
e.stopPropagation();
removeFromPlaylist(item.id);
}}
>
<ClearSVG />
</button>
</div>
);
}}
Mwebclient/src/style.css
@@ -34,7 +34,6 @@ select {
place-items: center;
padding: 0.25rem 0.5rem;
border: none;
border-radius: 4px;
background-color: var(--color-d);
color: oklch(0.26 0 0);
@@ -107,7 +106,7 @@ html {
overflow: hidden;
}
@media (min-width: 600px) {
@media (min-width: 601px) {
.filebrowser-toggle {
display: none;
}
Awebclient/src/tar.ts
@@ -0,0 +1,160 @@
function pad(value: string, length: number, padWith = "\0"): string {
if (value.length > length) return value.slice(0, length);
return value + padWith.repeat(length - value.length);
}
function truncateFilename(filename: string, maxBytes = 100): string {
const encoder = new TextEncoder();
// Identify extension (last '.' not at position 0)
const dotIdx = filename.lastIndexOf(".");
let base: string;
let ext: string;
if (dotIdx > 0 && dotIdx < filename.length - 1) {
base = filename.slice(0, dotIdx);
ext = filename.slice(dotIdx); // includes dot
} else {
base = filename;
ext = "";
}
// Whole filename fits? Done.
if (encoder.encode(filename).length <= maxBytes) {
return filename;
}
const ellipsis = "...";
const tail = ellipsis + ext;
const tailBytes = encoder.encode(tail).length;
// Figure out how many bytes are left for the base
const baseMaxBytes = maxBytes - tailBytes;
if (baseMaxBytes <= 0) {
// Only enough space for truncated tail
let res = "";
let count = 0;
for (const ch of tail) {
const chBytes = encoder.encode(ch).length;
if (count + chBytes > maxBytes) break;
res += ch;
count += chBytes;
}
return res;
}
// Take base from left, as much as fits in baseMaxBytes
let outBase = "";
let totalBytes = 0;
for (const ch of Array.from(base)) {
const chBytes = encoder.encode(ch).length;
if (totalBytes + chBytes > baseMaxBytes) {
break;
}
outBase += ch;
totalBytes += chBytes;
}
return outBase + ellipsis + ext;
}
function octalString(n: number, length: number): string {
// Reserve 1 for NUL (tar standard)
const fieldLen = length - 1;
let s = n.toString(8);
if (s.length > fieldLen) s = "0".repeat(fieldLen);
s = s.padStart(fieldLen, "0");
return `${s}\0`;
}
export function createTar(files: Map<string, Uint8Array>): Uint8Array {
const blocks: Uint8Array[] = [];
files.forEach((content, filename) => {
const truncatedName = truncateFilename(filename);
// --- HEADER ---
const header = new Uint8Array(512);
let idx = 0;
// name (100)
const nameBytes = new TextEncoder().encode(pad(truncatedName, 100));
header.set(nameBytes, idx);
idx += 100;
// mode (8)
header.set(new TextEncoder().encode(octalString(0o644, 8)), idx);
idx += 8;
// uid (8)
header.set(new TextEncoder().encode(octalString(0, 8)), idx);
idx += 8;
// gid (8)
header.set(new TextEncoder().encode(octalString(0, 8)), idx);
idx += 8;
// size (12)
header.set(new TextEncoder().encode(octalString(content.length, 12)), idx);
idx += 12;
// mtime (12)
header.set(new TextEncoder().encode(octalString(Math.floor(Date.now() / 1000), 12)), idx);
idx += 12;
// chksum (8) - space-filled for now
header.set(new TextEncoder().encode(" "), idx);
idx += 8;
// typeflag (1) - '0' for regular file
header[idx++] = "0".charCodeAt(0);
// linkname (100)
idx += 100;
// magic (6)
header.set(new TextEncoder().encode("ustar\0"), idx);
idx += 6;
// version (2)
header.set(new TextEncoder().encode("00"), idx);
idx += 2;
// uname (32)
idx += 32;
// gname (32)
idx += 32;
// devmajor (8)
idx += 8;
// devminor (8)
idx += 8;
// prefix (155)
idx += 155;
// padding (12)
idx += 12;
// Compute checksum: set chksum field to spaces, then sum all bytes of header
let chksum = 0;
for (let i = 0; i < 512; i++) {
// chksum is at 148..155, treated as spaces (0x20)
if (i >= 148 && i < 156) {
chksum += 0x20;
} else {
chksum += header[i];
}
}
const chksumStr = pad(octalString(chksum, 7), 8, " "); // 6 digits, null, space
header.set(new TextEncoder().encode(chksumStr), 148);
blocks.push(header);
// --- CONTENT ---
blocks.push(content);
// pad to 512 bytes
if (content.length % 512 !== 0) {
blocks.push(new Uint8Array(512 - (content.length % 512)));
}
});
// Tar ends with two 512-byte zero blocks
blocks.push(new Uint8Array(512));
blocks.push(new Uint8Array(512));
// Flatten
const total = blocks.reduce((a, b) => a + b.length, 0);
const out = new Uint8Array(total);
let offset = 0;
for (const b of blocks) {
out.set(b, offset);
offset += b.length;
}
return out;
}
Mwebclient/src/utils.ts
@@ -44,6 +44,7 @@ export function toast(text: string, type: "info" | "error" | "sucess", e?: unkno
duration: 20000,
close: true,
style: {
"border-radius": "0",
display: "flex",
"align-items": "center",
background: