Skip to content

Commit

Permalink
Add zip exporter
Browse files Browse the repository at this point in the history
  • Loading branch information
okaxaki committed Aug 31, 2024
1 parent e25a848 commit 45531c0
Show file tree
Hide file tree
Showing 9 changed files with 140 additions and 35 deletions.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "m3disp",
"private": true,
"version": "0.13.0",
"version": "0.14.0",
"type": "module",
"scripts": {
"dev": "vite",
Expand Down
2 changes: 1 addition & 1 deletion src/contexts/FileDropContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ export function useFileDrop(playOnDrop: boolean, clearOnDrop: boolean = false) {
const entries = await createEntriesFromFileList(storage, files);
context.reducer.addEntries(entries, insertionIndex);
if (playOnDrop) {
context.reducer.play(insertionIndex);
context.reducer.play(!clearOnDrop ? insertionIndex : 0);
}
};

Expand Down
13 changes: 6 additions & 7 deletions src/utils/loader.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import * as fflate from "fflate";
import { KSS } from "libkss-js";
import { MGSC, TextDecoderEncoding, detectEncoding } from "mgsc-js";
import { PlayListEntry } from "../contexts/PlayerContext";
import { BinaryDataStorage } from "./binary-data-storage";
import { MGSC, TextDecoderEncoding, detectEncoding } from "mgsc-js";
import { parseM3U } from "./m3u-parser";
import * as fflate from "fflate";

/// Convert a given url to a download endpoint that allows CORS access.
export function toDownloadEndpoint(url: string) {
Expand Down Expand Up @@ -82,7 +82,6 @@ export async function loadEntriesFromZip(
const data = unzipped[name];
files.push(new File([data], name));
}
console.log(files);
return createEntriesFromFileList(storage, files, progressCallback);
}

Expand All @@ -104,19 +103,21 @@ export async function loadEntriesFromUrl(
throw new Error(res.statusText);
}

const contentType = res.headers.get("content-type");
const contentType = res.headers.get("content-type")?.replace(/;.*$/, "").trim(); // strip charset section
const ab = await res.arrayBuffer();
const u8a = new Uint8Array(ab);

console.log(contentType);

// zip file
if (isZipfile(u8a)) {
return loadEntriesFromZip(u8a, storage, progressCallback);
}

// playlist
if (contentType == "text/plain") {
const text = loadBufferAsText(ab);
if (!/#opll_mode/.test(text)) {
// play list
const baseUrl = targetUrl.replace(/[^/]*\.(m3u8?|pls)/i, "");
const text = await loadTextFromUrl(targetUrl);
const items = parseM3U(text);
Expand Down Expand Up @@ -359,7 +360,6 @@ export async function loadEntriesFromM3U(
const refNameAlt = refName.replace(/\.[^/]+$/, "") + ".kss";
for (const file of files) {
const name = file.name.toLowerCase();
console.log({ id, name, refName, refNameAlt });
if (refName == name || refNameAlt == name) {
try {
dataMap[id] = await registerFile(storage, file);
Expand All @@ -380,7 +380,6 @@ export async function loadEntriesFromM3U(
}
}

console.log(res);
return res;
}

Expand Down
2 changes: 1 addition & 1 deletion src/utils/m3u-parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,7 @@ export function parseExtendedM3U(lines: string[]): PlayListEntry[] {
// ex. #EXTINF:123 song=<song> loop=<loop> fade=<fade>,Track Title
// - <time>: (required) maximum runtime in seconds.
// - <song>: (optional) sub song number.
// - <loop>: (optional) number of loops (if specified, runtime will be auto detected).
// - <loop>: (optional) number of loops (if not specified, time will be auto detected).
// - <fade>: (optional) fade duraion in seconds.
const infoPattern = /^#EXTINF:([0-9]+)(.*)$/i;
let extInfo = {};
Expand Down
55 changes: 47 additions & 8 deletions src/utils/saver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ function extractExtension(name: string): string {
return "";
}

function createExtendedM3U(entries: PlayListEntry[]) {
function createExtendedM3U(entries: (PlayListEntry & { exportName?: string | null })[]) {
const lines = ["#EXTM3U", "#EXTENC:UTF-8"];
for (const entry of entries) {
const props: { [key: string]: any } = {
Expand All @@ -33,8 +33,12 @@ function createExtendedM3U(entries: PlayListEntry[]) {
info.push(`,${entry.title}`);
}
lines.push(info.join(""));
const ext = extractExtension(entry.filename);
lines.push(`${entry.dataId}${ext}`);
if (entry.exportName != null) {
lines.push(entry.exportName);
} else {
const ext = extractExtension(entry.filename);
lines.push(`${entry.dataId}${ext}`);
}
}
return lines.join("\n");
}
Expand All @@ -47,27 +51,62 @@ export function saveAs(input: Uint8Array | string, filename: string) {
a.click();
}

function insertCounterToBasename(filename: string, count: number) {
const segments = filename.split(".");
if (segments.length >= 2) {
const ext = segments.pop();
return segments.join(".") + ` (${count})` + "." + ext;
}
return `${filename} (${count})`;
}

/** Determine filename for exporting. Duplicated file names are taken into account. */
function withExportName(entries: PlayListEntry[]): (PlayListEntry & { exportName: string })[] {
const res: (PlayListEntry & { exportName: string })[] = [];
const dataIdToExportName: { [key: string]: string } = {};
const counterMap: { [key: string]: number } = {};

for (const entry of entries) {
const { filename, dataId } = entry;
let exportName = dataIdToExportName[dataId];
if (exportName != null) {
res.push({ ...entry, exportName: dataIdToExportName[dataId] });
} else {
// suffix ' ($count)' if the same export name already exists for a different file.
const count = counterMap[filename] != null ? counterMap[filename] + 1 : null;
exportName = count != null ? insertCounterToBasename(filename, count) : filename;
res.push({ ...entry, exportName });
counterMap[filename] = count ?? 1;
dataIdToExportName[dataId] = exportName;
}
}

return res;
}

export async function zipEntries(
entries: PlayListEntry[],
storage: BinaryDataStorage,
progressCallback?: (value: number | null) => void
): Promise<Uint8Array> {
const m3u = createExtendedM3U(entries);
const targets = withExportName(entries);
const m3u = createExtendedM3U(targets);
const data: fflate.Zippable = {};
data["index.m3u"] = new TextEncoder().encode(m3u);
for (const entry of entries) {
const ext = extractExtension(entry.filename);
data[`${entry.dataId}${ext}`] = await storage.get(entry.dataId);
for (const entry of targets) {
data[entry.exportName] = await storage.get(entry.dataId);
}
console.log(data);
const zip = fflate.zipSync(data);
return zip;
}

export async function saveEntriesAsZip(
filename: string,
entries: PlayListEntry[],
storage: BinaryDataStorage,
progressCallback?: (value: number | null) => void
) {
const zip = await zipEntries(entries, storage, progressCallback);
saveAs(zip, "m3disp.zip");
saveAs(zip, filename);
}
2 changes: 2 additions & 0 deletions src/views/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ import { TimeSlider } from "../widgets/TimeSlider";
import { AboutDialog } from "./AboutDialog";
import { OpenUrlDialog } from "./OpenUrlDialog";
import { SampleDialog } from "./SampleDialog";
import { SaveAsZipDialog } from "./SaveAsZipDialog";

const gap = { xs: 0, sm: 1, md: 1.5, lg: 2 };

Expand All @@ -62,6 +63,7 @@ function AppRoot() {
<AboutDialog />
<AppProgressDialog />
<OpenUrlDialog />
<SaveAsZipDialog />
<SampleDialog />
{isXs ? <AppRootMobile /> : <AppRootDesktop />}
</Fragment>
Expand Down
4 changes: 3 additions & 1 deletion src/views/PlayListView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,7 @@ function createSecondaryAction(
}

export function PlayListView(props: { toolbarAlignment?: "top" | "bottom" }) {
const app = useContext(AppContext);
const { fileDropRef, fileDropProps, isDraggingOver, onFileInputChange } = useFileDrop(false);
const border = isDraggingOver ? `2px solid` : null;
const [deleteMode, setDeleteMode] = useState(false);
Expand All @@ -249,12 +250,13 @@ export function PlayListView(props: { toolbarAlignment?: "top" | "bottom" }) {

const fileInputRef = useRef<HTMLInputElement>(null);
const onAddClick = () => {
fileInputRef.current!.value = ""; // clear the last file info
fileInputRef.current!.click();
};

const context = useContext(PlayerContext);
const onExportClick = () => {
saveEntriesAsZip(context.entries, context.storage, (progress) => {});
app.openDialog("save-as-zip-dialog");
};

return (
Expand Down
48 changes: 48 additions & 0 deletions src/views/SaveAsZipDialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { Box, Button, Dialog, DialogActions, DialogContent, TextField } from "@mui/material";
import { ChangeEvent, useContext, useState } from "react";
import { AppContext } from "../contexts/AppContext";
import { AppProgressContext } from "../contexts/AppProgressContext";
import { PlayerContext } from "../contexts/PlayerContext";
import { saveEntriesAsZip } from "../utils/saver";

export function SaveAsZipDialog() {
const app = useContext(AppContext);
const context = useContext(PlayerContext);
const progress = useContext(AppProgressContext);

const id = "save-as-zip-dialog";

const [zipName, setZipName] = useState<string>("m3disp.zip");

const onChange = (evt: ChangeEvent<HTMLInputElement>) => {
setZipName(evt.target.value.trim());
};

const onExport = async () => {
if (zipName.length > 0) {
saveEntriesAsZip(zipName, context.entries, context.storage, progress.setProgress);
app.closeDialog(id);
}
};

return (
<Dialog open={app.isOpen(id)}>
<DialogContent>
<Box
sx={{
display: "flex",
flexDirection: "column",
alignItems: "stretch",
minWidth: "288px",
}}
>
<TextField label="Filename" variant="standard" value={zipName} onChange={onChange} />
</Box>
</DialogContent>
<DialogActions>
<Button onClick={() => app.closeDialog(id)}>Cancel</Button>
<Button disabled={zipName.length == 0} onClick={onExport}>Export</Button>
</DialogActions>
</Dialog>
);
}
47 changes: 31 additions & 16 deletions src/widgets/PlayListToolBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,14 @@ import {
Divider,
IconButton,
Link,
ListItem,
ListItemText,
Menu,
MenuItem,
MenuList,
Stack,
SxProps,
Theme
Theme,
} from "@mui/material";
import { useContext, useRef, useState } from "react";
import { AppContext } from "../contexts/AppContext";
Expand All @@ -22,20 +25,28 @@ function PlayListAddMenu(props: {
}) {
return (
<Menu open={props.anchorEl != null} anchorEl={props.anchorEl} onClose={props.onClose}>
<MenuItem onClick={() => props.onClick("open-file")}>Open File...</MenuItem>
<MenuItem onClick={() => props.onClick("open-url")}>
Open Url...
<IconButton sx={{ ml: 4 }}>
<Link
href="https://github.com/digital-sound-antiques/m3disp/wiki/Experimental-Feature"
target="_blank"
>
<HelpOutline />
</Link>
</IconButton>
</MenuItem>
<Divider />
<MenuItem onClick={() => props.onClick("open-sample")}>Open Samples...</MenuItem>
<MenuList sx={{ width: 220 }}>
<MenuItem onClick={() => props.onClick("open-file")}>
<ListItemText>Open File...</ListItemText>
</MenuItem>

<MenuItem onClick={() => props.onClick("open-url")}>
<ListItemText>Open Url...</ListItemText>
<IconButton onClick={(e) => e.stopPropagation()}>
<Link
href="https://github.com/digital-sound-antiques/m3disp/wiki/Experimental-Feature"
target="_blank"
>
<HelpOutline />
</Link>
</IconButton>
</MenuItem>

<Divider />
<MenuItem onClick={() => props.onClick("open-sample")}>
<ListItemText>Open Samples...</ListItemText>
</MenuItem>
</MenuList>
</Menu>
);
}
Expand All @@ -47,7 +58,11 @@ function PlayListSaveMenu(props: {
}) {
return (
<Menu open={props.anchorEl != null} anchorEl={props.anchorEl} onClose={props.onClose}>
<MenuItem onClick={() => props.onClick("export")}>Save As Zip...</MenuItem>
<MenuList>
<MenuItem onClick={() => props.onClick("export")}>
<ListItemText>Save as Zip...</ListItemText>
</MenuItem>
</MenuList>
</Menu>
);
}
Expand Down

0 comments on commit 45531c0

Please sign in to comment.