updated screenshots, preliminary playlist management, some css updates, direct play from filebrowser

AuthorKonata <konata@posteo.jp>
Date
Commit495ee5b6e37650003fc38d8fdafec8a29e7e078c
Parente390947
12 files changed, 92 insertions(+), 46 deletions(-)
▾MREADME.md
@@ -11,7 +11,7 @@ Pico Pixel Player is a lightweight, self-hostable audio player with an [ElysiaJS
- **Directory Listing**: Browse files in their original directory structure
## Screenshot
![screenshot](webclient/public/assets/screenshot-narrow.png)
![screenshot](webclient/public/assets/screenshot-wide.png)
## Setup
1. Clone the repository:
▾Mwebclient/public/assets/screenshot-narrow.png
Bin 134280 → 324070 bytes@ 495ee5b@ master
Binary file — not shown
▾Mwebclient/public/assets/screenshot-wide.png
Bin 138765 → 275618 bytes@ 495ee5b@ master
Binary file — not shown
▾Mwebclient/src/App.tsx
@@ -4,18 +4,15 @@ import type { PlaylistItem, PlayerState, AppOptions } from "./types";
import OptionsModal from "./components/OptionsModal";
import PlayerControls from "./components/PlayerControls";
import FileBrowser from "./components/FileBrowser";
import { ClosedFolderSVG, CogSVG, FullscreenSVG, OpenFolderSVG } from "./components/SVG";
import { ClosedFolderSVG, CogSVG, FullscreenSVG, OpenFolderSVG, PlaylistSVG } from "./components/SVG";
import { LocalStorageValues, toast } from "./utils";
import Playlist from "./components/Playlist";
import { getSrc } from "./offline";
//TODO: download single file, playlist
//TODO: play directly from filebrowser
//TODO: playlist management
//TODO: implement playlist item download server side
//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
//TODO: allow syncing playlists to server
//TODO: Rework offline cache to use tree structure instead of flat map
@@ -24,6 +21,7 @@ import { getSrc } from "./offline";
//TODO: show cover of playing item
//TODO: delete confirmation?
//TODO: nicer errors when song can't be played because server is down
//TODO: setting export/import
const App: Component = () => {
const [showFileBrowser, setShowFileBrowser] = createSignal(false);
@@ -239,9 +237,11 @@ const App: Component = () => {
>
{showFileBrowser() ? <OpenFolderSVG /> : <ClosedFolderSVG forButton={true} />}
</button>
<button title="Manage playlists">
<PlaylistSVG />
</button>
<button
title="Toggle Fullscreen"
class="fullscreen-button"
onClick={() =>
document.fullscreenElement
? document.exitFullscreen()
@@ -250,7 +250,7 @@ const App: Component = () => {
>
<FullscreenSVG />
</button>
<button title="Settings" class="options-button" onClick={() => setShowOptions(true)}>
<button title="Settings" onClick={() => setShowOptions(true)}>
<CogSVG />
</button>
</div>
▾Mwebclient/src/components/DownloadButton.tsx
@@ -39,13 +39,13 @@ export function DownloadButton(props: DownloadButtonProps) {
if (props.isOffline()) {
//only happens if this is a directory
return (
<button>
<button title="Purge the cache from all files recursively in this directory">
<DeleteSVG />
</button>
);
}
return (
<button>
<button title="Cache this file/directory on your browser for offline use">
<DownloadSVG />
</button>
);
@@ -54,13 +54,13 @@ export function DownloadButton(props: DownloadButtonProps) {
if (progress === "CachedFile") {
if (props.isOffline()) {
return (
<button>
<button title="Purge this file from cache">
<DeleteSVG />
</button>
);
}
return (
<button>
<button title="Re-download this file">
<RefreshSVG />
</button>
);
▾Mwebclient/src/components/FileBrowser.tsx
@@ -55,30 +55,38 @@ export default function FileBrowser(props: FileBrowserProps) {
}
}
function fileToPlaylistItem(basePath: string, item: AudioFile) {
const normpath = basePath === "" ? "" : `${basePath}/`;
return {
id: generateUUID(),
name: item.name.split("/").pop() || item.name,
duration: item.duration,
path: `${normpath}${item.name.split("/").pop()}`,
};
}
// Recursively collect audio files
function collectAudioFiles(files: FileListing, basePath: string): PlaylistItem[] {
let result: PlaylistItem[] = [];
const normpath = basePath === "" ? "" : `${basePath}/`;
for (const file of files) {
if ("duration" in file) {
// It's an audio file
result.push({
id: generateUUID(),
name: file.name.split("/").pop() || file.name,
duration: file.duration,
path: `${normpath}${file.name.split("/").pop()}`,
});
result.push(fileToPlaylistItem(basePath, file));
} else if ("files" in file && file.files.length > 0) {
// It's a directory with files, recursively add its contents
const normpath = basePath === "" ? "" : `${basePath}/`;
const subPath = `${normpath}${file.name}`;
result = [...result, ...collectAudioFiles(file.files, subPath)];
}
}
return result;
}
function playFile(item: AudioFile) {
props.playTrack(fileToPlaylistItem(props.currentDir(), item));
}
function addToPlaylist(file: AudioFile, path: string) {
const newItem: PlaylistItem = {
id: generateUUID(),
@@ -178,6 +186,7 @@ export default function FileBrowser(props: FileBrowserProps) {
</button>
</Show>
<button
title="Switch between online and offline mode. In offline mode only locally cached files are listed"
class="offline-button"
style={{ background: isOffline() ? "lightgreen" : "indianred" }}
textContent={isOffline() ? "Go online" : "Go offline"}
@@ -199,7 +208,7 @@ export default function FileBrowser(props: FileBrowserProps) {
<>
<div
class={(isDirectory && "directory-item") || "file-item"}
onClick={() => isDirectory && props.setCurrentDir(fullPath)}
onClick={() => (isDirectory && props.setCurrentDir(fullPath)) || playFile(item as AudioFile)}
>
{(isDirectory && <ClosedFolderSVG forButton={false} />) || <MusicSVG />}
<span>{item.name}</span>
▾Mwebclient/src/components/OptionsModal.tsx
@@ -115,13 +115,13 @@ export default function OptionsModal(props: OptionsModalProps) {
/>
</div>
<div class="option-buttons">
<button type="button" class="cache-button" onclick={deleteServerCache}>
<button title="Clear server caches" type="button" class="cache-button" onclick={deleteServerCache}>
Clear server caches
</button>
<button type="button" onClick={handleSave}>
<button title="Save settings" type="button" onClick={handleSave}>
Save
</button>
<button type="button" class="cancel-button" onClick={handleClose}>
<button title="Cancel changes settings" type="button" class="cancel-button" onClick={handleClose}>
Cancel
</button>
</div>
▾Mwebclient/src/components/PlayerControls.tsx
@@ -98,17 +98,17 @@ export default function PlayerControls(props: PlayerControlsProps) {
</div>
<div class="control-buttons">
<button disabled={controlsDisabled()} onClick={props.playPrevious}>
<button title="Previous track" disabled={controlsDisabled()} onClick={props.playPrevious}>
<PreviousTrackSVG disabled={controlsDisabled} />
</button>
<button disabled={controlsDisabled()} onClick={props.togglePlay}>
<button title="Toggle play/pause" disabled={controlsDisabled()} onClick={props.togglePlay}>
{props.playerState().isPlaying ? (
<PauseSVG disabled={controlsDisabled} />
) : (
<PlaySVG disabled={controlsDisabled} />
)}
</button>
<button disabled={controlsDisabled()} onClick={props.playNext}>
<button title="Next track" disabled={controlsDisabled()} onClick={props.playNext}>
<NextTrackSVG disabled={controlsDisabled} />
</button>
▾Mwebclient/src/components/Playlist.tsx
@@ -2,7 +2,7 @@ import { type Accessor, For, type Setter, createSignal, onMount, onCleanup, crea
import "./Playlist.css";
import type { PlayerState, PlaylistItem } from "../types";
import { formatTime, joinPath, toast } from "../utils";
import { ClearSVG, DownloadSVG, DragSVG } from "./SVG";
import { ClearSVG, DownloadSVG, DragSVG, SaveSVG } from "./SVG";
import { createTar } from "../tar";
import Toastify from "toastify-js";
@@ -212,8 +212,11 @@ export default function Playlist(props: PlaylistProps) {
<div class="playlist-header">
<span>Playlist ({props.playlist().length} tracks)</span>
<div class="spacer" />
<button title="Save playlist">
<SaveSVG />
</button>
<button
title="Download all files in the playlist in their original format as a tar file"
title="Download all files in the playlist in their original format as a tar file. WARNING: Uses a lot of memory during downlad"
onClick={browserDownloadAll}
>
<DownloadSVG />
▾Mwebclient/src/components/SVG.tsx
@@ -310,3 +310,44 @@ export function SpinnerSVG() {
</svg>
);
}
export function PlaylistSVG() {
const [hovered, setHovered] = createSignal();
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
>
<path fill={hovered() ? highlightColor : defaultColor} d="M1 3h12v3H1zm0 6h12v3H1zm0 6h6v3H1z" />
<path
fill={hovered() ? highlightColor : defaultColor}
d="M23 1v6h-2v1h-2v13h-1v1h-1v1h-5v-1h-2v-1H9v-3h1v-1h2v-1h4V4h1V3h2V2h2V1z"
/>
</svg>
);
}
export function SaveSVG() {
const [hovered, setHovered] = createSignal();
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
>
<path
fill={hovered() ? highlightColor : defaultColor}
d="M22 7V6h-1V5h-1V4h-1V3h-1V2h-1V1H2v1H1v20h1v1h20v-1h1V7zM9 19v-4h1v-1h4v1h1v4h-1v1h-4v-1zm7-9H4V4h12z"
/>
</svg>
);
}
▾Mwebclient/src/style.css
@@ -75,6 +75,7 @@ html {
.header {
display: flex;
gap: 0.5rem;
justify-content: space-between;
align-items: center;
padding: 1rem;
@@ -86,19 +87,6 @@ html {
font-weight: bold;
}
.filebrowser-toggle,
.fullscreen-button {
margin-right: 1rem;
}
.filebrowser-toggle,
.fullscreen-button,
.options-button {
background: none;
&:hover {
background: none;
}
}
.main-content {
position: relative; /* for the loading screen in the file browser*/
display: flex;
@@ -112,7 +100,6 @@ html {
}
}
.spacer {
flex-grow: 1;
}
}
▾Mwebclient/vite.manifest.config.ts
@@ -5,6 +5,12 @@ export const pwaOptions: Partial<VitePWAOptions> = {
workbox: {
globPatterns: ["**/*.{js,wasm,css,html,woff2}"],
navigateFallback: undefined,
runtimeCaching: [
{
urlPattern: () => true,
handler: "NetworkFirst",
},
],
},
manifest: {
name: "Pico Pixel Player",