Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,33 @@ CoolModFiles plays three families of Amiga music:
| `/` or `q` | open in-app Help |
| `esc` | close the side drawer |

## Sound settings

The Sound pane (side drawer → Sound) persists three preferences across
sessions: **Amiga emulation** (off / A500 / A1200; also bound to `m`),
**Stereo separation** (slider), and **Filename style**.

The **Filename style** toggle is display-only and has three options:

- **Auto** (default) — render filenames verbatim as on disk.
- **Amiga** — render Amiga-native module filenames in scene prefix form
across every catalog: `echoing.mod` → `mod.echoing`, `space.med` →
`med.space`, `quartet.okt` → `okt.quartet`, `dexter.ahx` (and
`dexter.thx`) → `ahx.dexter`. Covers `.mod`, `.med`, `.okt`, `.ahx`,
and `.thx`; PC-era tracker formats are left unchanged.
- **Amiga everywhere** — the same prefix transform, extended to every
supported module format including PC-era trackers: `dreamland.xm` →
`xm.dreamland`, `groove.it` → `it.groove`, `rush.s3m` → `s3m.rush`,
etc. Trades historical accuracy for visual consistency across all
catalog rows.

TFMX pair rows always render as `<base> (TFMX)` regardless of the
chosen style — the `(TFMX)` suffix carries the format identity, and the
underlying file shapes (`*.tfx + *.sam`, `mdat.* + smpl.*`, `*.mdat +
*.smpl`) make a single prefix label misleading. Downloads always keep
the canonical on-disk filename, and hovering a row reveals the on-disk
basename in a tooltip so it remains copyable for search/share.

## Development

```bash
Expand Down
22 changes: 22 additions & 0 deletions components/Player.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ import {
import type { FavoriteTrack } from "./LikedMod";
import type { ModItem } from "../lib/modarchive/types";
import { AudioPlayer, type EngineKind } from "../lib/audio-player";
import { FilenameStyleProvider } from "../lib/filename/context";
import { type FilenameStyle } from "../lib/filename/amiga-style";

const DEFAULT_VOLUME = 80;

Expand All @@ -47,6 +49,16 @@ function readAmigaModel(): AmigaModel {
: DEFAULT_AMIGA_MODEL;
}

const FILENAME_STYLES: FilenameStyle[] = ["auto", "amiga", "amiga-all"];
const DEFAULT_FILENAME_STYLE: FilenameStyle = "auto";

function readFilenameStyle(): FilenameStyle {
const raw = localStorage.getItem("display.filenameStyle");
return FILENAME_STYLES.includes(raw as FilenameStyle)
? (raw as FilenameStyle)
: DEFAULT_FILENAME_STYLE;
}

/**
* Pick the file extension for a Mod Archive download by sniffing the
* first 4 bytes of the fetched blob. Returns:
Expand Down Expand Up @@ -284,6 +296,8 @@ function Player({ initialSource, backSideContent, latestId }: PlayerProps) {
React.useState<AmigaModel>(readAmigaModel);
const [stereoSeparation, setStereoSeparation] =
React.useState<number>(readStereoSeparation);
const [filenameStyle, setFilenameStyle] =
React.useState<FilenameStyle>(readFilenameStyle);
// Mirror of AudioPlayer.activeEngine in React state, set synchronously
// after each player.play() call so the Sound pane's per-control
// gating updates immediately (without waiting for the new worklet's
Expand Down Expand Up @@ -631,6 +645,10 @@ function Player({ initialSource, backSideContent, latestId }: PlayerProps) {
localStorage.setItem("audio.stereoSeparation", String(stereoSeparation));
}, [stereoSeparation]);

React.useEffect(() => {
localStorage.setItem("display.filenameStyle", filenameStyle);
}, [filenameStyle]);

React.useEffect(() => {
if (player && playerReady) {
playFromSource(playingSource);
Expand Down Expand Up @@ -1101,6 +1119,7 @@ function Player({ initialSource, backSideContent, latestId }: PlayerProps) {
}, [selectedSubsong]);

return (
<FilenameStyleProvider style={filenameStyle}>
<div>
<ToastContainer />
{playingSource.type !== "modarchive" && (
Expand Down Expand Up @@ -1193,6 +1212,8 @@ function Player({ initialSource, backSideContent, latestId }: PlayerProps) {
setStereoSeparation(val);
player?.setStereoSeparation(val);
},
filenameStyle,
setFilenameStyle,
}}
/>
</div>
Expand All @@ -1219,6 +1240,7 @@ function Player({ initialSource, backSideContent, latestId }: PlayerProps) {
</div>
)}
</div>
</FilenameStyleProvider>
);
}

Expand Down
10 changes: 9 additions & 1 deletion components/SoundPane.module.scss
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,19 @@
// work, and the banner stays full-opacity so the explanation remains
// readable.
.inactive {
> :not(.note) {
> :not(.note):not(.filenameStyleSection) {
opacity: 0.45;
}
}

// Filename style is a display preference, not an audio preference, so
// it stays fully interactive and visually un-dimmed even when the
// audio-side controls are inactive (e.g. during TFMX playback).
.filenameStyleSection {
// Intentionally empty — exists as a selector hook for .inactive's
// exemption list above. Layout inherits from .stereoSection sibling.
}

.optionDisabled {
// pointer-events: none makes the label entirely inert, which is the
// right outcome since the inner radio is already disabled. Side
Expand Down
46 changes: 46 additions & 0 deletions components/SoundPane.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import React from "react";
import Slider from "rc-slider";
import styles from "./SoundPane.module.scss";
import { type FilenameStyle } from "../lib/filename/amiga-style";

type AmigaModel = "off" | "a500" | "a1200";

Expand All @@ -26,8 +27,24 @@ type SoundPaneProps = {
activeEngine?: EngineKind;
stereoSeparation: number;
setStereoSeparation: (v: number) => void;
filenameStyle: FilenameStyle;
setFilenameStyle: (s: FilenameStyle) => void;
};

const FILENAME_STYLE_OPTIONS: {
value: FilenameStyle;
label: string;
sub: string;
}[] = [
{ value: "auto", label: "Auto", sub: "Render filenames as on disk" },
{ value: "amiga", label: "Amiga", sub: "Prefix form for Amiga-native formats" },
{
value: "amiga-all",
label: "Amiga everywhere",
sub: "Prefix form for all module formats",
},
];

const OPTIONS: { value: AmigaModel; label: string; sub: string }[] = [
{ value: "off", label: "Off", sub: "Modern clean resampler" },
{ value: "a500", label: "A500", sub: "Warm, ~4.9 kHz filter" },
Expand All @@ -40,6 +57,8 @@ function SoundPane({
activeEngine,
stereoSeparation,
setStereoSeparation,
filenameStyle,
setFilenameStyle,
}: SoundPaneProps) {
// Per-control disabled predicates per D9. activeEngine === undefined
// means "no track yet" — both controls stay live so users can
Expand Down Expand Up @@ -130,6 +149,33 @@ function SoundPane({
</div>
</section>

<section
className={`${styles.stereoSection} ${styles.filenameStyleSection}`}
>
<h2 className={styles.sectionHeading}>Filename style</h2>
<div
className={styles.options}
role="radiogroup"
aria-label="Filename style"
>
{FILENAME_STYLE_OPTIONS.map((opt) => (
<label key={opt.value} className={styles.option}>
<input
type="radio"
name="filenameStyle"
value={opt.value}
checked={filenameStyle === opt.value}
onChange={() => setFilenameStyle(opt.value)}
/>
<span className={styles.optionLabel}>
<span className={styles.optionTitle}>{opt.label}</span>
<span className={styles.optionSub}>{opt.sub}</span>
</span>
</label>
))}
</div>
</section>

{/* Future Sound-panel sections (interpolation filter length,
channel-mute view, ...) drop in below as sibling <section>s. */}
</div>
Expand Down
3 changes: 3 additions & 0 deletions components/SourceDrawer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
import type { FavoriteTrack } from "./LikedMod";
import type { ModItem } from "../lib/modarchive/types";
import type { EngineKind } from "../lib/audio-player";
import type { FilenameStyle } from "../lib/filename/amiga-style";

export type DrawerTabId =
| "modarchive"
Expand Down Expand Up @@ -62,6 +63,8 @@
activeEngine?: EngineKind;
stereoSeparation: number;
setStereoSeparation: (v: number) => void;
filenameStyle: FilenameStyle;
setFilenameStyle: (s: FilenameStyle) => void;
};

type SourceDrawerProps = {
Expand Down Expand Up @@ -98,7 +101,7 @@
const escKey = useKeyPress("Escape");
React.useEffect(() => {
if (escKey && open) onClose();
}, [escKey, open]);

Check warning on line 104 in components/SourceDrawer.tsx

View workflow job for this annotation

GitHub Actions / verify

React Hook React.useEffect has a missing dependency: 'onClose'. Either include it or remove the dependency array. If 'onClose' changes too often, find the parent component that defines it and wrap that definition in useCallback

const drawerClass = [
player.playerBack,
Expand Down
43 changes: 27 additions & 16 deletions components/library/LibraryCatalog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
type LibrarySource,
type TfmxLibrarySource,
} from "../sources";
import { useFilenameStyle } from "../../lib/filename/context";

type PairEntry = { base: string; tfx: string; sam: string };

Expand Down Expand Up @@ -35,6 +36,7 @@ function LibraryCatalog({
setCurrentPath,
onPlay,
}: LibraryCatalogProps) {
const { render, renderPair } = useFilenameStyle();
const [listing, setListing] = React.useState<Listing | null>(null);
const [error, setError] = React.useState<number | null>(null);
const [searchQuery, setSearchQuery] = React.useState("");
Expand Down Expand Up @@ -98,17 +100,26 @@ function LibraryCatalog({
<div className={styles.empty}>No matches.</div>
) : (
<ul className={styles.list}>
{searchResults.map((r) =>
r.kind === "mod" ? (
<li
key={`mod:${r.path}`}
className={`${styles.row} ${styles.file}`}
onClick={() => onPlay(library(r.path))}
title={r.path}
>
{r.path}
</li>
) : (
{searchResults.map((r) => {
if (r.kind === "mod") {
const lastSlash = r.path.lastIndexOf("/");
const dir =
lastSlash === -1 ? "" : r.path.slice(0, lastSlash + 1);
const base =
lastSlash === -1 ? r.path : r.path.slice(lastSlash + 1);
return (
<li
key={`mod:${r.path}`}
className={`${styles.row} ${styles.file}`}
onClick={() => onPlay(library(r.path))}
title={r.path}
>
{dir}
{render(base)}
</li>
);
}
return (
<li
key={`tfmx:${r.tfxPath}`}
className={`${styles.row} ${styles.file}`}
Expand All @@ -117,10 +128,10 @@ function LibraryCatalog({
}
title={`${r.tfxPath} + ${r.samPath}`}
>
{r.base} (TFMX)
{renderPair(r.base)}
</li>
)
)}
);
})}
</ul>
)}
</div>
Expand Down Expand Up @@ -192,7 +203,7 @@ function LibraryCatalog({
}
title={`${p.tfx} + ${p.sam}`}
>
{p.base} (TFMX)
{renderPair(p.base)}
</li>
))}
{listing.files.map((f) => (
Expand All @@ -202,7 +213,7 @@ function LibraryCatalog({
onClick={() => onPlay(library(joinPath([...segments, f])))}
title={f}
>
{f}
{render(f)}
</li>
))}
{listing.truncated && (
Expand Down
6 changes: 4 additions & 2 deletions components/local/LocalCatalog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
} from "../sources";
import { detectTfmxPairs } from "./tfmx-pairs";
import { showToast } from "../../utils";
import { useFilenameStyle } from "../../lib/filename/context";

type LocalCatalogProps = {
pickedFiles: File[];
Expand All @@ -25,6 +26,7 @@ function LocalCatalog({
setPickedTfmxPairs,
onPlay,
}: LocalCatalogProps) {
const { render, renderPair } = useFilenameStyle();
const [dragActive, setDragActive] = React.useState(false);
const inputRef = React.useRef<HTMLInputElement | null>(null);

Expand Down Expand Up @@ -135,7 +137,7 @@ function LocalCatalog({
onClick={() => onPlay(pair)}
title={`${pair.tfx.name} + ${pair.sam.name}`}
>
{pair.base} (TFMX)
{renderPair(pair.base)}
</li>
))}
{pickedFiles.map((file, idx) => (
Expand All @@ -145,7 +147,7 @@ function LocalCatalog({
onClick={() => onPlay(local(file))}
title={file.name}
>
{file.name}
{render(file.name)}
</li>
))}
</ul>
Expand Down
6 changes: 5 additions & 1 deletion components/modarchive/ChartList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import type {
ModChartResponse,
Pagination,
} from "../../lib/modarchive/types";
import { useFilenameStyle } from "../../lib/filename/context";

type ChartListProps = {
kind: Extract<ChartKind, "featured" | "tophits" | "topfavourites" | "topscore">;
Expand All @@ -23,6 +24,7 @@ type State =
| { status: "ok"; items: ModItem[]; pagination: Pagination };

function ChartList({ kind, onPick }: ChartListProps) {
const { render } = useFilenameStyle();
const [state, setState] = React.useState<State>({ status: "loading" });
const [reloadCounter, setReloadCounter] = React.useState(0);
const [page, setPage] = React.useState(1);
Expand Down Expand Up @@ -97,7 +99,9 @@ function ChartList({ kind, onPick }: ChartListProps) {
)}
<span className={styles.title}>{item.title}</span>
{item.filename && (
<span className={styles.subtitle}>{item.filename}</span>
<span className={styles.subtitle} title={item.filename}>
{render(item.filename)}
</span>
)}
</li>
))}
Expand Down
6 changes: 5 additions & 1 deletion components/modarchive/GenreMods.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import type {
ModItem,
Pagination,
} from "../../lib/modarchive/types";
import { useFilenameStyle } from "../../lib/filename/context";

type GenreModsProps = {
id: number;
Expand All @@ -22,6 +23,7 @@ type State =
| { status: "ok"; items: ModItem[]; pagination: Pagination };

function GenreMods({ id, onPick }: GenreModsProps) {
const { render } = useFilenameStyle();
const [state, setState] = React.useState<State>({ status: "loading" });
const [reloadCounter, setReloadCounter] = React.useState(0);
const [page, setPage] = React.useState(1);
Expand Down Expand Up @@ -93,7 +95,9 @@ function GenreMods({ id, onPick }: GenreModsProps) {
>
<span className={styles.title}>{item.title}</span>
{item.filename && (
<span className={styles.subtitle}>{item.filename}</span>
<span className={styles.subtitle} title={item.filename}>
{render(item.filename)}
</span>
)}
</li>
))}
Expand Down
Loading
Loading