diff --git a/.github/workflows/static.yml b/.github/workflows/static.yml index 9d1a318..23026d2 100644 --- a/.github/workflows/static.yml +++ b/.github/workflows/static.yml @@ -37,6 +37,8 @@ jobs: node-version: 18 - run: npm ci - run: npm run build + env: + VITE_GD_API_KEY: ${{ secrets.GD_API_KEY }} - name: Setup Pages uses: actions/configure-pages@v3 - name: Upload artifact diff --git a/package-lock.json b/package-lock.json index b4e365b..1d1a1af 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "m3disp", - "version": "0.11.2", + "version": "0.12.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "m3disp", - "version": "0.11.2", + "version": "0.12.0", "dependencies": { "@emotion/react": "^11.11.0", "@emotion/styled": "^11.11.0", @@ -14,7 +14,6 @@ "@mui/icons-material": "^5.14.19", "@mui/material": "^5.14.20", "@types/sha1": "^1.1.5", - "fflate": "^0.8.1", "libkss-js": "^2.2.0", "md5": "^2.3.0", "mgsc-js": "^2.0.0", @@ -31,6 +30,7 @@ "@types/react-beautiful-dnd": "^13.1.4", "@types/react-dom": "^18.0.11", "@vitejs/plugin-react": "^4.2.1", + "fflate": "^0.8.1", "typescript": "^5.3.3", "vite": "^5.0.7" } @@ -1883,7 +1883,8 @@ "node_modules/fflate": { "version": "0.8.1", "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.1.tgz", - "integrity": "sha512-/exOvEuc+/iaUm105QIiOt4LpBdMTWsXxqR0HDF35vx3fmaKzw7354gTilCh5rkzEt8WYyG//ku3h3nRmd7CHQ==" + "integrity": "sha512-/exOvEuc+/iaUm105QIiOt4LpBdMTWsXxqR0HDF35vx3fmaKzw7354gTilCh5rkzEt8WYyG//ku3h3nRmd7CHQ==", + "dev": true }, "node_modules/find-replace": { "version": "3.0.0", diff --git a/package.json b/package.json index 607f967..e4d97ae 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "m3disp", "private": true, - "version": "0.11.2", + "version": "0.12.0", "type": "module", "scripts": { "dev": "vite", @@ -16,7 +16,6 @@ "@mui/icons-material": "^5.14.19", "@mui/material": "^5.14.20", "@types/sha1": "^1.1.5", - "fflate": "^0.8.1", "libkss-js": "^2.2.0", "md5": "^2.3.0", "mgsc-js": "^2.0.0", @@ -33,6 +32,7 @@ "@types/react-beautiful-dnd": "^13.1.4", "@types/react-dom": "^18.0.11", "@vitejs/plugin-react": "^4.2.1", + "fflate": "^0.8.1", "typescript": "^5.3.3", "vite": "^5.0.7" } diff --git a/src/contexts/AppProgressContext.tsx b/src/contexts/AppProgressContext.tsx index 8280dbd..555922b 100644 --- a/src/contexts/AppProgressContext.tsx +++ b/src/contexts/AppProgressContext.tsx @@ -19,6 +19,8 @@ const defaultContextData: AppProgresssContextData = { export const AppProgressContext = createContext(defaultContextData); export function AppProgressContextProvider(props: PropsWithChildren) { + const [state, setState] = useState(defaultContextData); + const setTitle = (value: string | null) => { setState((oldState) => ({ ...oldState, title: value })); }; @@ -27,8 +29,6 @@ export function AppProgressContextProvider(props: PropsWithChildren) { setState((oldState) => ({ ...oldState, progress: value, rev: oldState.rev + 1 })); }; - const [state, setState] = useState(defaultContextData); - return ( {props.children} diff --git a/src/contexts/FileDropContext.tsx b/src/contexts/FileDropContext.tsx index b35cddb..28b5b01 100644 --- a/src/contexts/FileDropContext.tsx +++ b/src/contexts/FileDropContext.tsx @@ -2,7 +2,7 @@ import { Box, useTheme } from "@mui/material"; import { PropsWithChildren, useContext, useRef, useState } from "react"; import { FileDrop } from "react-file-drop"; import { BinaryDataStorage } from "../utils/binary-data-storage"; -import { loadFilesFromFileList } from "../utils/load-urls"; +import { createEntriesFromFileList } from "../utils/loader"; import { PlayerContext } from "./PlayerContext"; export function useFileDrop(playOnDrop: boolean, clearOnDrop: boolean = false) { @@ -35,7 +35,7 @@ export function useFileDrop(playOnDrop: boolean, clearOnDrop: boolean = false) { }; const loadFiles = async (storage: BinaryDataStorage, files: FileList, insertionIndex: number) => { - const entries = await loadFilesFromFileList(storage, files); + const entries = await createEntriesFromFileList(storage, files); context.reducer.addEntries(entries, insertionIndex); if (playOnDrop) { context.reducer.play(insertionIndex); diff --git a/src/contexts/PlayerContext.tsx b/src/contexts/PlayerContext.tsx index 91080e8..98c88e1 100644 --- a/src/contexts/PlayerContext.tsx +++ b/src/contexts/PlayerContext.tsx @@ -3,7 +3,7 @@ import { AudioPlayerState } from "webaudio-stream-player"; import { KSSChannelMask } from "../kss/kss-device"; import { KSSPlayer } from "../kss/kss-player"; import { BinaryDataStorage } from "../utils/binary-data-storage"; -import { loadEntriesFromUrl } from "../utils/load-urls"; +import { loadEntriesFromFileList, loadEntriesFromUrl } from "../utils/loader"; import { isIOS, isSafari } from "../utils/platform-detect"; import { unmuteAudio } from "../utils/unmute"; import AppGlobal from "./AppGlobal"; @@ -163,9 +163,14 @@ export function PlayerContextProvider(props: React.PropsWithChildren) { const [initialized, setInitialized] = useState(false); useEffect(() => { + console.log('attach'); + console.log(window.opener); + window.addEventListener("message", onWindowMessage, false); state.player.addEventListener("statechange", onPlayerStateChange); initialize(); return () => { + console.log('detach'); + window.removeEventListener("message", onWindowMessage, false); state.player.removeEventListener("statechange", onPlayerStateChange); }; }, []); @@ -232,6 +237,18 @@ export function PlayerContextProvider(props: React.PropsWithChildren) { } }; + const onWindowMessage = async (ev: MessageEvent) => { + console.log(ev); + if (ev.data instanceof Uint8Array && ev.data.length <= 65536) { + reducer.clearEntries(); + const file = new File([ev.data], 'external.mgs'); + const entries = await loadEntriesFromFileList(state.storage, [new File([ev.data], file.name)]); + reducer.addEntries(entries, 0); + reducer.resume(); + reducer.play(0); + } + }; + return ( { - // MSXplay.com +/// Convert a given url to a download endpoint that allows CORS access. +export function toDownloadEndpoint(url: string) { + // f.msxplay.com let m = url.match(/^(https:\/\/)?f\.msxplay\.com\/([0-9a-z]+)/i); if (m != null) { return `https://firebasestorage.googleapis.com/v0/b/msxplay-63a7a.appspot.com/o/pastebin%2F${m[2]}?alt=media`; @@ -17,32 +19,106 @@ export const convertUrlIfRequired = (url: string) => { return `https://raw.githubusercontent.com/${m[1]}/${m[2]}`; } + // Google Drive Public URL + m = url.match(/^(?:https:\/\/)?drive.google.com\/file\/d\/([A-Za-z0-9_\-]+)/); + if (m != null) { + return `https://www.googleapis.com/drive/v3/files/${m[1]}?alt=media&key=${ + import.meta.env.VITE_GD_API_KEY + }`; + } return url; -}; +} + +export async function loadTextFromUrl(url: string): Promise { + const targetUrl = toDownloadEndpoint(url); + const res = await fetch(targetUrl); + if (res.status == 200) { + const blob = await res.blob(); + return loadBlobAsText(blob); + } else { + throw new Error(res.statusText); + } +} + +function isZipfile(data: Uint8Array) { + return data[0] == 0x50 && data[1] == 0x4b && data[2] == 0x03 && data[3] == 0x04; +} + +function _unzip(data: Uint8Array): { [key: string]: Uint8Array } { + return fflate.unzipSync(data, { + filter: (file) => { + if (/__MACOSX\//.test(file.name)) { + return false; + } + return /\.(mgs|bgm|opx|mpk|kss|mbm|m3u|m3u8|pls)$/i.test(file.name); + }, + }); +} + +export async function loadEntriesFromZip( + data: Uint8Array | Blob | ArrayBuffer, + storage: BinaryDataStorage, + progressCallback?: (value: number | null) => void +): Promise { + let u8a: Uint8Array; + if (data instanceof Blob) { + const ab = await data.arrayBuffer(); + u8a = new Uint8Array(ab); + } else if (data instanceof ArrayBuffer) { + u8a = new Uint8Array(data); + } else { + u8a = data; + } + const unzipped = _unzip(u8a); + const files: File[] = []; + for (const name in unzipped) { + const data = unzipped[name]; + files.push(new File([data], name)); + } + return createEntriesFromFileList(storage, files, progressCallback); +} export async function loadEntriesFromUrl( - url: string, // m3u, pls or single data file. + url: string, // single data, .m3u, .pls or archive (.zip) file. storage: BinaryDataStorage, progressCallback?: (value: number | null) => void ): Promise { - const targetUrl = convertUrlIfRequired(url); - const fileUrls = []; + const targetUrl = toDownloadEndpoint(url); - if (/[^/]*\.(m3u|pls)/i.test(url)) { - const baseUrl = targetUrl.replace(/[^/]*\.(m3u|pls)/i, ""); - const res = await fetch(targetUrl); - const items = parseM3U(await res.text()); - for (const item of items) { - if (/https?:\/\//.test(item.filename)) { - fileUrls.push(item.filename); + try { + progressCallback?.(0.0); + + if (/[^/]*\.(m3u|m3u8|pls)$/i.test(url)) { + // .m3u or .pls + const baseUrl = targetUrl.replace(/[^/]*\.(m3u|m3u8|pls)/i, ""); + const text = await loadTextFromUrl(targetUrl); + const items = parseM3U(text); + const fileUrls = []; + for (const item of items) { + if (/https?:\/\//.test(item.filename)) { + fileUrls.push(item.filename); + } else { + fileUrls.push(`${baseUrl}${item.filename}`); + } + } + return loadFilesFromUrls(fileUrls, storage, progressCallback); + } else if (/[^/]*\.zip$/i) { + // .zip file + const res = await fetch(targetUrl); + if (res.status == 200) { + return loadEntriesFromZip(await res.blob(), storage, progressCallback); } else { - fileUrls.push(`${baseUrl}${item.filename}`); + throw new Error(res.statusText); } + } else { + // single data file + const fileUrls = []; + fileUrls.push(targetUrl); + return loadFilesFromUrls(fileUrls, storage, progressCallback); } - } else { - fileUrls.push(targetUrl); + } finally { + progressCallback?.(null); } - return loadFilesFromUrls(fileUrls, storage, progressCallback); } export const loadFilesFromUrls = async ( @@ -55,7 +131,7 @@ export const loadFilesFromUrls = async ( const countRef = { count: 0 }; const runner = async (url: string) => { - const res = await fetch(convertUrlIfRequired(url)); + const res = await fetch(toDownloadEndpoint(url)); countRef.count++; if (setProgress != null) { setProgress((0.5 * countRef.count) / urls.length); @@ -130,7 +206,7 @@ export function compileIfRequired(u8: Uint8Array): Uint8Array { return u8; } -export async function loadFileAsText(blob: Blob): Promise { +export async function loadBlobAsText(blob: Blob): Promise { return new Promise((resolve, reject) => { const reader = new FileReader(); reader.onloadend = () => { @@ -150,14 +226,14 @@ export async function loadFileAsText(blob: Blob): Promise { }); } -export async function loadFilesFromFileList( +export async function createEntriesFromFileList( storage: BinaryDataStorage, - files: FileList, + files: File[] | FileList, progressCallback?: (value: number | null) => void ): Promise { let m3u = false; for (let i = 0; i < files.length; i++) { - if (/\.(pls|m3u)$/i.test(files[i].name)) { + if (/\.(pls|m3u|m3u8?)$/i.test(files[i].name)) { m3u = true; } } @@ -165,7 +241,7 @@ export async function loadFilesFromFileList( let entries: PlayListEntry[] = []; if (m3u) { for (let i = 0; i < files.length; i++) { - if (/\.(pls|m3u)$/i.test(files[i].name)) { + if (/\.(pls|m3u|m3u8)$/i.test(files[i].name)) { entries = [ ...entries, ...(await loadEntriesFromM3U(storage, files[i], files, progressCallback)), @@ -178,16 +254,14 @@ export async function loadFilesFromFileList( return entries; } -const getFilename = (path: string): string => { - return path.split(/[/\\]/).pop()!; -}; - -const getBasename = (path: string) => { - const filename = path.split(/[/\\]/).pop(); - const fragments = filename!.split("."); - fragments.pop(); - return fragments.join("."); -}; +function getDirname(path: string): string { + const fragments = path.split(/[/\\]/); + if (fragments.length >= 2) { + fragments.pop(); + return fragments.join("/") + "/"; + } + return ""; +} async function loadFromFile(blob: Blob): Promise { return new Promise((resolve, reject) => { @@ -222,14 +296,15 @@ const registerFile = async ( export async function loadEntriesFromM3U( storage: BinaryDataStorage, m3u: File, - files: FileList, + files: File[] | FileList, progressCallback?: (value: number | null) => void ): Promise { - const text = await loadFileAsText(m3u); + const text = await loadBlobAsText(m3u); if (typeof text !== "string") { throw new Error("Not a text file"); } + const items = parseM3U(text); const dataIds = items.map((e) => e.dataId); const dataMap: { @@ -239,16 +314,18 @@ export async function loadEntriesFromM3U( }; } = {}; + const m3uRoot = getDirname(m3u.name); + const processed = new Set(); for (const id of dataIds) { if (id.startsWith("ref://")) { if (processed.has(id)) continue; - const refName = id.substring(6).toLowerCase(); - const refBasename = getBasename(refName); + const refName = `${m3uRoot}${id.substring(6).toLowerCase()}`; + const refNameAlt = refName.replace(/\.[^/]+$/, "") + ".kss"; for (const file of files) { - const name = getFilename(file.name).toLowerCase(); - if (refName == name || refBasename + ".kss" == name) { + const name = file.name.toLowerCase(); + if (refName == name || refNameAlt == name) { try { dataMap[id] = await registerFile(storage, file); processed.add(id); @@ -272,7 +349,7 @@ export async function loadEntriesFromM3U( export async function loadEntriesFromFileList( storage: BinaryDataStorage, - files: FileList, + files: File[] | FileList, progressCallback?: (value: number | null) => void ): Promise { const res: PlayListEntry[] = []; @@ -282,9 +359,16 @@ export async function loadEntriesFromFileList( try { const data = await loadFromFile(file); if (data instanceof Uint8Array) { - const filename = file.name.split(/[/\\]/).pop() ?? "Unknown"; - const entry = await createPlayListEntry(storage, data, filename); - res.push(entry); + if (isZipfile(data)) { + const entries = await loadEntriesFromZip(data, storage, progressCallback); + for (const entry of entries) { + res.push(entry); + } + } else { + const filename = file.name.split(/[/\\]/).pop() ?? "Unknown"; + const entry = await createPlayListEntry(storage, data, filename); + res.push(entry); + } } } catch (e) { console.warn(e); diff --git a/src/utils/m3u-parser.ts b/src/utils/m3u-parser.ts index 748463f..72e4dd0 100644 --- a/src/utils/m3u-parser.ts +++ b/src/utils/m3u-parser.ts @@ -1,43 +1,57 @@ import { PlayListEntry } from "../contexts/PlayerContext"; -function parseDuration(value: string | null) { +function parseDuration(value: string | null): number | null { if (value == null) { return null; } - if (value?.trim() === "") { + if (value.trim() === "") { return null; } - let minus = false; - let text = value; - if (value.endsWith("-")) { - text = value.substring(0, value.length - 1); - minus = true; - } - - const columns = text.split(":"); + const columns = value.split(":"); let duration = 0; for (let i = 0; i < columns.length; i++) { duration = duration * 60 + parseInt(columns[i]); } - return (minus ? -1 : 1) * duration * 1000; + return duration * 1000; } -/// parseM3U or Playlist +function parseLoopDuration(value: string | null, mainDurationInMs: number | null): number | null { + if (value == null || mainDurationInMs == null) { + return null; + } + if (value.trim() === "") { + return null; + } + if (value.endsWith("-")) { + const text = value.substring(0, value.length - 1); + const durationInMs = parseDuration(text); + if (durationInMs != null && durationInMs <= mainDurationInMs) { + return mainDurationInMs - durationInMs; + } + return null; + } else { + return parseDuration(value); + } +} + +/// parse .m3u or Winamp .pls export function parseM3U(text: string): PlayListEntry[] { const lines = text.replaceAll("\r\n", "\n").replaceAll("\r", "\n").split("\n"); const plsPattern = /^(file[0-9]+=)?([^:]+)(::(kss|msx))?/i; - const m3uPattern = /^.+\.[a-z]+$/i; + const m3uPattern = /^.+\.[a-z0-9_\-]+$/i; const res: PlayListEntry[] = []; + for (const line of lines) { if (line.startsWith("#")) continue; let [head, id, title, mainDuration, loopDuration, fadeDuration] = line .replaceAll("\\,", "\t") .split(","); + title = title?.replaceAll("\t", ","); let filename; let m = head.match(plsPattern); @@ -51,8 +65,6 @@ export function parseM3U(text: string): PlayListEntry[] { } if (filename == null) continue; - - title = title?.replaceAll("\t", ","); let song; if (id == null || id == "") { @@ -64,19 +76,14 @@ export function parseM3U(text: string): PlayListEntry[] { } let mainDurationInMs = parseDuration(mainDuration); - let loopDurationInMs = parseDuration(loopDuration); + let loopDurationInMs = parseLoopDuration(loopDuration, mainDurationInMs); let fadeDurationInMs = parseDuration(fadeDuration); - let durationInMs = mainDurationInMs; - - if (durationInMs != null && loopDurationInMs != null) { - if (loopDurationInMs > 0) { - durationInMs += loopDurationInMs; - } else if (loopDurationInMs < 0 || loopDurationInMs === -0) { - durationInMs += durationInMs + loopDurationInMs; - } else { - // do nothind - } + let durationInMs; + if (mainDurationInMs != null && loopDurationInMs != null) { + durationInMs = mainDurationInMs + loopDurationInMs; + } else { + durationInMs = mainDurationInMs; } res.push({ diff --git a/src/views/App.tsx b/src/views/App.tsx index 114862e..13fb209 100644 --- a/src/views/App.tsx +++ b/src/views/App.tsx @@ -17,7 +17,7 @@ import { } from "@mui/material"; import { MoreVert } from "@mui/icons-material"; -import { Fragment, useContext, useEffect, useRef, useState } from "react"; +import React, { Fragment, useContext, useEffect, useRef, useState } from "react"; import { AppContext } from "../contexts/AppContext"; import { FileDropContext } from "../contexts/FileDropContext"; import { PlayerContext } from "../contexts/PlayerContext"; @@ -25,20 +25,20 @@ import { KeyboardList } from "../widgets/KeyboardList"; import { VolumeControl } from "../widgets/VolumeControl"; import { WaveSliderCard } from "../widgets/WavePreview"; import "./App.css"; +import { AppProgressDialog } from "./AppProgressDialog"; import { OptionMenu } from "./OptionMenu"; import { PlayListCard, PlayListView } from "./PlayListView"; import { PlayControl, PlayControlCard } from "./PlayerControl"; -import { AppProgressDialog } from "./AppProgressDialog"; import { SettingsDialog } from "./SettingsDialog"; -import { AboutDialog } from "./AboutDialog"; -import { TimeSlider } from "../widgets/TimeSlider"; -import { SampleDialog } from "./SampleDialog"; -import { OpenUrlDialog } from "./OpenUrlDialog"; import packageJson from "../../package.json"; import ghlogo from "../assets/github-mark-white.svg"; import { PianoRoll } from "../widgets/PianoRoll"; import { PianoRollControl } from "../widgets/PianoRollControl"; +import { TimeSlider } from "../widgets/TimeSlider"; +import { AboutDialog } from "./AboutDialog"; +import { OpenUrlDialog } from "./OpenUrlDialog"; +import { SampleDialog } from "./SampleDialog"; const gap = { xs: 0, sm: 1, md: 1.5, lg: 2 }; diff --git a/src/views/AppProgressDialog.tsx b/src/views/AppProgressDialog.tsx index ae69b7a..1c7a3d6 100644 --- a/src/views/AppProgressDialog.tsx +++ b/src/views/AppProgressDialog.tsx @@ -8,7 +8,10 @@ export function AppProgressDialog() { - + diff --git a/src/views/OpenUrlDialog.tsx b/src/views/OpenUrlDialog.tsx index 65316fb..5188723 100644 --- a/src/views/OpenUrlDialog.tsx +++ b/src/views/OpenUrlDialog.tsx @@ -2,7 +2,7 @@ import { ChangeEvent, useContext, useState } from "react"; import { AppContext } from "../contexts/AppContext"; import { Box, Button, Dialog, DialogActions, DialogContent, TextField } from "@mui/material"; import { PlayerContext } from "../contexts/PlayerContext"; -import { loadEntriesFromUrl } from "../utils/load-urls"; +import { loadEntriesFromUrl } from "../utils/loader"; import { AppProgressContext } from "../contexts/AppProgressContext"; export function OpenUrlDialog() { diff --git a/src/views/PlayerControl.tsx b/src/views/PlayerControl.tsx index 69d3b31..b368308 100644 --- a/src/views/PlayerControl.tsx +++ b/src/views/PlayerControl.tsx @@ -32,9 +32,9 @@ export function PlayControl(props: { small: boolean }) { diff --git a/src/widgets/WavePreview.tsx b/src/widgets/WavePreview.tsx index 55f4e6f..3f09c7c 100644 --- a/src/widgets/WavePreview.tsx +++ b/src/widgets/WavePreview.tsx @@ -167,7 +167,7 @@ function WaveCanvas(props: WaveCanvasProps) { } type WavePreviewProps = { - height?: string | number | undefined; + height?: string | number | null; }; export function WavePreview(props: WavePreviewProps) { @@ -220,7 +220,7 @@ function _toTimeString(ms: number): string { return `${mm}:${ss}`; } -export function WaveSlider() { +export function WaveSlider(props: { height?: string| number | null }) { const context = useContext(PlayerContext); const [progress, setProgress] = useState({ currentTime: 0, bufferedTime: 0 }); @@ -236,9 +236,11 @@ export function WaveSlider() { return ( - + - {_toTimeString(Math.min(progress.currentTime, progress.bufferedTime))} + + {_toTimeString(Math.min(progress.currentTime, progress.bufferedTime))} + {_toTimeString(progress.bufferedTime)}