Compare commits
3 Commits
v0.3.4
...
8a49a5013b
| Author | SHA1 | Date | |
|---|---|---|---|
| 8a49a5013b | |||
|
|
85b3cb6852 | ||
|
|
bfc0675f5a |
31
.github/workflows/docker-publish.yml
vendored
31
.github/workflows/docker-publish.yml
vendored
@@ -2,6 +2,8 @@ name: Publish Server Image
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
|
branches:
|
||||||
|
- '**'
|
||||||
tags:
|
tags:
|
||||||
- 'v*.*.*'
|
- 'v*.*.*'
|
||||||
|
|
||||||
@@ -29,22 +31,29 @@ jobs:
|
|||||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
|
|
||||||
- name: Extract metadata (tags, labels) for Docker
|
- name: Determine version and tags
|
||||||
id: meta
|
id: info
|
||||||
uses: docker/metadata-action@v5
|
run: |
|
||||||
with:
|
IMAGE="${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}"
|
||||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
SHORT_SHA="$(echo '${{ github.sha }}' | cut -c1-7)"
|
||||||
tags: |
|
|
||||||
type=semver,pattern={{version}}
|
if [[ "${{ github.ref }}" == refs/tags/v* ]]; then
|
||||||
type=semver,pattern={{major}}.{{minor}}
|
TAG="${{ github.ref_name }}"
|
||||||
type=sha,format=short
|
VERSION="${TAG#v}"
|
||||||
|
echo "tags=${IMAGE}:${VERSION},${IMAGE}:latest" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
|
||||||
|
else
|
||||||
|
echo "tags=${IMAGE}:trunk,${IMAGE}:${SHORT_SHA}" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "version=${SHORT_SHA}" >> "$GITHUB_OUTPUT"
|
||||||
|
fi
|
||||||
|
|
||||||
- name: Build and push Docker image
|
- name: Build and push Docker image
|
||||||
uses: docker/build-push-action@v5
|
uses: docker/build-push-action@v5
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
push: true
|
push: true
|
||||||
tags: ${{ steps.meta.outputs.tags }}
|
tags: ${{ steps.info.outputs.tags }}
|
||||||
labels: ${{ steps.meta.outputs.labels }}
|
build-args: |
|
||||||
|
FURUMI_VERSION=${{ steps.info.outputs.version }}
|
||||||
cache-from: type=gha
|
cache-from: type=gha
|
||||||
cache-to: type=gha,mode=max
|
cache-to: type=gha,mode=max
|
||||||
|
|||||||
216
CHIPTUNE.md
Normal file
216
CHIPTUNE.md
Normal file
@@ -0,0 +1,216 @@
|
|||||||
|
# Chiptune Support Implementation Plan
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Add playback support for tracker/chiptune module formats (MOD, XM, S3M, IT, MPTM) to the
|
||||||
|
Furumi web player. The implementation consists of two parts:
|
||||||
|
|
||||||
|
1. **Server-side** — lightweight metadata parser in pure Rust (zero external dependencies)
|
||||||
|
2. **Client-side** — playback via libopenmpt WebAssembly using AudioWorklet API
|
||||||
|
|
||||||
|
## Supported Formats
|
||||||
|
|
||||||
|
| Format | Extension | Origin |
|
||||||
|
|--------|-----------|--------|
|
||||||
|
| MOD | `.mod` | Amiga ProTracker |
|
||||||
|
| XM | `.xm` | FastTracker II |
|
||||||
|
| S3M | `.s3m` | Scream Tracker 3 |
|
||||||
|
| IT | `.it` | Impulse Tracker |
|
||||||
|
| MPTM | `.mptm` | OpenMPT |
|
||||||
|
|
||||||
|
## Part 1: Server-Side Metadata Parser
|
||||||
|
|
||||||
|
### Rationale
|
||||||
|
|
||||||
|
libopenmpt must NOT be a server dependency. All tracker formats store metadata at fixed byte
|
||||||
|
offsets in their headers, making manual parsing trivial. Reading the first ~400 bytes of a file
|
||||||
|
is sufficient to extract all available metadata.
|
||||||
|
|
||||||
|
### Extracted Fields
|
||||||
|
|
||||||
|
- **Title** — song name embedded in the module header
|
||||||
|
- **Channels** — number of active audio channels
|
||||||
|
- **Patterns** — number of unique patterns in the module
|
||||||
|
- **Message** — song message/comment (IT/MPTM only)
|
||||||
|
|
||||||
|
Note: none of these formats have a dedicated "artist" field. Author information, when present,
|
||||||
|
is typically found in the IT/MPTM song message.
|
||||||
|
|
||||||
|
### Binary Format Reference
|
||||||
|
|
||||||
|
#### MOD
|
||||||
|
|
||||||
|
| Offset | Size | Field |
|
||||||
|
|--------|------|-------|
|
||||||
|
| 0 | 20 | Song title (space/null padded) |
|
||||||
|
| 952 | 128 | Pattern order table |
|
||||||
|
| 1080 | 4 | Signature (determines channel count) |
|
||||||
|
|
||||||
|
Channel count is derived from the 4-byte signature at offset 1080:
|
||||||
|
|
||||||
|
- `M.K.`, `M!K!`, `FLT4`, `4CHN` → 4 channels
|
||||||
|
- `6CHN` → 6, `8CHN` / `OCTA` → 8
|
||||||
|
- `xCHN` → x channels, `xxCH` → xx channels
|
||||||
|
|
||||||
|
Pattern count = max value in the order table (128 bytes at offset 952) + 1.
|
||||||
|
|
||||||
|
#### XM
|
||||||
|
|
||||||
|
All multi-byte values are little-endian.
|
||||||
|
|
||||||
|
| Offset | Size | Field |
|
||||||
|
|--------|------|-------|
|
||||||
|
| 0 | 17 | Magic: `"Extended Module: "` |
|
||||||
|
| 17 | 20 | Module name |
|
||||||
|
| 58 | 2 | Version number |
|
||||||
|
| 68 | 2 | Number of channels |
|
||||||
|
| 70 | 2 | Number of patterns |
|
||||||
|
|
||||||
|
#### S3M
|
||||||
|
|
||||||
|
| Offset | Size | Field |
|
||||||
|
|--------|------|-------|
|
||||||
|
| 0x00 | 28 | Song title (null-terminated) |
|
||||||
|
| 0x1C | 1 | Signature byte (`0x1A`) |
|
||||||
|
| 0x24 | 2 | Pattern count (LE u16) |
|
||||||
|
| 0x2C | 4 | Magic: `"SCRM"` |
|
||||||
|
| 0x40 | 32 | Channel settings |
|
||||||
|
|
||||||
|
Channel count = number of entries in channel settings (32 bytes) that are not `0xFF`.
|
||||||
|
|
||||||
|
#### IT
|
||||||
|
|
||||||
|
| Offset | Size | Field |
|
||||||
|
|--------|------|-------|
|
||||||
|
| 0x00 | 4 | Magic: `"IMPM"` |
|
||||||
|
| 0x04 | 26 | Song title (null-terminated) |
|
||||||
|
| 0x26 | 2 | Pattern count (LE u16) |
|
||||||
|
| 0x2E | 2 | Special flags (bit 0 = message attached) |
|
||||||
|
| 0x36 | 2 | Message length |
|
||||||
|
| 0x38 | 4 | Message file offset |
|
||||||
|
| 0x40 | 64 | Channel panning table |
|
||||||
|
|
||||||
|
Channel count = number of entries in channel panning (64 bytes) with value < 128.
|
||||||
|
|
||||||
|
Song message: if `special & 1`, read `message_length` bytes from `message_offset`. Uses `\r`
|
||||||
|
(0x0D) as line separator.
|
||||||
|
|
||||||
|
#### MPTM
|
||||||
|
|
||||||
|
Parsed identically to IT. Detection:
|
||||||
|
|
||||||
|
- Legacy: magic `tpm.` instead of `IMPM`
|
||||||
|
- Modern: magic `IMPM` with tracker version (offset 0x28) in range `0x0889..=0x0FFF`
|
||||||
|
|
||||||
|
### Integration Points
|
||||||
|
|
||||||
|
- **`browse.rs`** — add tracker extensions to the audio file whitelist
|
||||||
|
- **`meta.rs`** — add a chiptune metadata branch that runs before Symphonia (which does not
|
||||||
|
support tracker formats); return title, channel count, pattern count, and message
|
||||||
|
- **`stream.rs`** — serve tracker files as-is (no server-side transcoding); these files are
|
||||||
|
typically under 1 MB
|
||||||
|
|
||||||
|
### Implementation Notes
|
||||||
|
|
||||||
|
- Zero external crate dependencies — only `std::io::Read` + `std::io::Seek`
|
||||||
|
- Read at most the first 1084 bytes for header parsing (MOD needs offset 1080 + 4 byte sig)
|
||||||
|
- For IT/MPTM messages, a second seek to `message_offset` is needed
|
||||||
|
- All strings should be trimmed of null bytes and trailing whitespace
|
||||||
|
- Expected code size: ~200–300 lines of Rust
|
||||||
|
|
||||||
|
## Part 2: Client-Side Playback via libopenmpt WASM
|
||||||
|
|
||||||
|
### Rationale
|
||||||
|
|
||||||
|
Browsers cannot decode tracker formats natively. libopenmpt compiled to WebAssembly decodes
|
||||||
|
modules into PCM samples which are then rendered through the Web Audio API. Client-side
|
||||||
|
decoding keeps the server dependency-free and enables interactive features (pattern display,
|
||||||
|
channel visualization) in the future.
|
||||||
|
|
||||||
|
### libopenmpt WASM Source
|
||||||
|
|
||||||
|
Use the **chiptune3** library (npm: `chiptune3`, by DrSnuggles) which bundles libopenmpt as a
|
||||||
|
self-contained AudioWorklet-compatible ES6 module.
|
||||||
|
|
||||||
|
Package contents:
|
||||||
|
|
||||||
|
| File | Size | Purpose |
|
||||||
|
|------|------|---------|
|
||||||
|
| `chiptune3.js` | ~4 KB | Main API (load, play, pause, seek) |
|
||||||
|
| `chiptune3.worklet.js` | ~12 KB | AudioWorklet processor glue |
|
||||||
|
| `libopenmpt.worklet.js` | ~1.7 MB | libopenmpt WASM + JS (single-file bundle) |
|
||||||
|
|
||||||
|
Available via jsDelivr CDN or can be vendored into the project.
|
||||||
|
|
||||||
|
If a newer libopenmpt version is needed, the official project provides source tarballs with an
|
||||||
|
Emscripten build target:
|
||||||
|
|
||||||
|
```
|
||||||
|
make CONFIG=emscripten EMSCRIPTEN_TARGET=audioworkletprocessor
|
||||||
|
```
|
||||||
|
|
||||||
|
This produces a single ES6 module with WASM embedded inline (`SINGLE_FILE=1`), which is
|
||||||
|
required because AudioWorklet contexts cannot fetch separate `.wasm` files.
|
||||||
|
|
||||||
|
### Playback Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────────────────────────────────────────────┐
|
||||||
|
│ player.html │
|
||||||
|
│ │
|
||||||
|
│ Format detection (by file extension) │
|
||||||
|
│ ┌─────────────────────┐ ┌────────────────────────────┐ │
|
||||||
|
│ │ Standard audio │ │ Tracker module │ │
|
||||||
|
│ │ (mp3/flac/ogg/...) │ │ (mod/xm/s3m/it/mptm) │ │
|
||||||
|
│ │ │ │ │ │
|
||||||
|
│ │ <audio> element │ │ fetch() → ArrayBuffer │ │
|
||||||
|
│ │ src=/api/stream/path │ │ ↓ │ │
|
||||||
|
│ │ │ │ libopenmpt WASM decode │ │
|
||||||
|
│ │ │ │ ↓ │ │
|
||||||
|
│ │ │ │ AudioWorkletProcessor │ │
|
||||||
|
│ │ │ │ ↓ │ │
|
||||||
|
│ │ ↓ │ │ AudioContext.destination │ │
|
||||||
|
│ └────────┼─────────────┘ └────────────┼───────────────┘ │
|
||||||
|
│ └──────────┬──────────────────┘ │
|
||||||
|
│ ↓ │
|
||||||
|
│ Player controls │
|
||||||
|
│ (play/pause/seek/volume) │
|
||||||
|
│ MediaSession API │
|
||||||
|
└──────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### Integration Points
|
||||||
|
|
||||||
|
- **`player.html`** — detect tracker format by extension; use chiptune3 API instead of
|
||||||
|
`<audio>` element for tracker files; unify transport controls (play/pause/seek/volume)
|
||||||
|
across both playback engines
|
||||||
|
- **WASM assets** — serve `chiptune3.js`, `chiptune3.worklet.js`, and
|
||||||
|
`libopenmpt.worklet.js` via a static file endpoint or embed them inline
|
||||||
|
- **`mod.rs`** (routes) — add endpoint for serving WASM assets if not embedded
|
||||||
|
|
||||||
|
### Player Integration Details
|
||||||
|
|
||||||
|
The player must abstract over two playback backends behind a common interface:
|
||||||
|
|
||||||
|
```
|
||||||
|
play(path) — start playback (auto-detect engine by extension)
|
||||||
|
pause() — pause current playback
|
||||||
|
resume() — resume current playback
|
||||||
|
seek(seconds) — seek to position
|
||||||
|
setVolume(v) — set volume (0.0–1.0)
|
||||||
|
getDuration() — total duration in seconds
|
||||||
|
getPosition() — current position in seconds
|
||||||
|
isPlaying() — playback state
|
||||||
|
onEnded(cb) — callback when track finishes
|
||||||
|
```
|
||||||
|
|
||||||
|
For tracker modules, `getDuration()` and `getPosition()` are provided by libopenmpt's
|
||||||
|
`get_duration_seconds()` and `get_position_seconds()` APIs.
|
||||||
|
|
||||||
|
### Considerations
|
||||||
|
|
||||||
|
- Tracker files are small (typically < 1 MB) — fetch the entire file before playback; no
|
||||||
|
streaming/range-request needed
|
||||||
|
- AudioWorklet requires a secure context (HTTPS or localhost)
|
||||||
|
- The WASM bundle is ~1.7 MB — load it lazily on first tracker file playback
|
||||||
|
- MediaSession API metadata should display module title from `/api/meta` response
|
||||||
@@ -14,8 +14,10 @@ WORKDIR /usr/src/app
|
|||||||
# Option: Copy in root workspace files and source crates
|
# Option: Copy in root workspace files and source crates
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
|
ARG FURUMI_VERSION=dev
|
||||||
|
|
||||||
# Build only the server for release
|
# Build only the server for release
|
||||||
RUN cargo build --release --bin furumi-server
|
RUN FURUMI_VERSION=${FURUMI_VERSION} cargo build --release --bin furumi-server
|
||||||
|
|
||||||
# Stage 2: Create the minimal runtime image
|
# Stage 2: Create the minimal runtime image
|
||||||
FROM debian:bookworm-slim
|
FROM debian:bookworm-slim
|
||||||
|
|||||||
@@ -75,7 +75,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
MountOption::NoExec, // Better security for media mount
|
MountOption::NoExec, // Better security for media mount
|
||||||
];
|
];
|
||||||
|
|
||||||
println!("Mounting Furumi-ng v{} to {:?}", env!("CARGO_PKG_VERSION"), args.mount);
|
println!("Mounting Furumi-ng v{} to {:?}", option_env!("FURUMI_VERSION").unwrap_or(env!("CARGO_PKG_VERSION")), args.mount);
|
||||||
|
|
||||||
// Use Session + BackgroundSession for graceful unmount on exit
|
// Use Session + BackgroundSession for graceful unmount on exit
|
||||||
let session = Session::new(fuse_fs, &args.mount, &options)?;
|
let session = Session::new(fuse_fs, &args.mount, &options)?;
|
||||||
|
|||||||
@@ -108,7 +108,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
std::process::exit(1);
|
std::process::exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
println!("Mounted Furumi-ng v{} to {:?}", env!("CARGO_PKG_VERSION"), mount_path);
|
println!("Mounted Furumi-ng v{} to {:?}", option_env!("FURUMI_VERSION").unwrap_or(env!("CARGO_PKG_VERSION")), mount_path);
|
||||||
|
|
||||||
// Wait for shutdown signal
|
// Wait for shutdown signal
|
||||||
while running.load(Ordering::SeqCst) {
|
while running.load(Ordering::SeqCst) {
|
||||||
|
|||||||
@@ -108,7 +108,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
let svc = RemoteFileSystemServer::with_interceptor(remote_fs, auth.clone());
|
let svc = RemoteFileSystemServer::with_interceptor(remote_fs, auth.clone());
|
||||||
|
|
||||||
// Print startup info
|
// Print startup info
|
||||||
println!("Furumi-ng Server v{} listening on {}", env!("CARGO_PKG_VERSION"), addr);
|
println!("Furumi-ng Server v{} listening on {}", option_env!("FURUMI_VERSION").unwrap_or(env!("CARGO_PKG_VERSION")), addr);
|
||||||
if args.no_tls {
|
if args.no_tls {
|
||||||
println!("WARNING: TLS is DISABLED — traffic is unencrypted");
|
println!("WARNING: TLS is DISABLED — traffic is unencrypted");
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -61,6 +61,6 @@ async fn player_html(
|
|||||||
) -> axum::response::Html<String> {
|
) -> axum::response::Html<String> {
|
||||||
let html = include_str!("player.html")
|
let html = include_str!("player.html")
|
||||||
.replace("<!-- USERNAME_PLACEHOLDER -->", &user_info.0)
|
.replace("<!-- USERNAME_PLACEHOLDER -->", &user_info.0)
|
||||||
.replace("<!-- VERSION_PLACEHOLDER -->", env!("CARGO_PKG_VERSION"));
|
.replace("<!-- VERSION_PLACEHOLDER -->", option_env!("FURUMI_VERSION").unwrap_or(env!("CARGO_PKG_VERSION")));
|
||||||
axum::response::Html(html)
|
axum::response::Html(html)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -238,14 +238,15 @@ body {
|
|||||||
.queue-item .qi-info { flex: 1; overflow: hidden; }
|
.queue-item .qi-info { flex: 1; overflow: hidden; }
|
||||||
.queue-item .qi-title { font-size: 0.875rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
.queue-item .qi-title { font-size: 0.875rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
.queue-item .qi-artist { font-size: 0.75rem; color: var(--text-muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
.queue-item .qi-artist { font-size: 0.75rem; color: var(--text-muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
.queue-item .qi-dur { font-size: 0.72rem; color: var(--text-muted); flex-shrink: 0; }
|
.queue-item .qi-dur { font-size: 0.75rem; color: var(--text-muted); margin-left: auto; margin-right: 0.5rem; }
|
||||||
.queue-item .qi-remove {
|
.qi-remove, .qi-locate {
|
||||||
opacity: 0; font-size: 0.8rem; color: var(--text-muted);
|
background: none; border: none; font-size: 0.9rem;
|
||||||
background: none; border: none; cursor: pointer; padding: 2px 5px;
|
color: var(--text-muted); cursor: pointer; padding: 0.3rem;
|
||||||
border-radius: 4px; transition: all 0.15s;
|
border-radius: 4px; transition: all 0.2s; opacity: 0;
|
||||||
}
|
}
|
||||||
.queue-item:hover .qi-remove { opacity: 1; }
|
.queue-item:hover .qi-remove, .queue-item:hover .qi-locate { opacity: 1; }
|
||||||
.queue-item .qi-remove:hover { color: var(--danger); }
|
.qi-remove:hover { background: rgba(248,113,113,0.15); color: var(--danger); }
|
||||||
|
.qi-locate:hover { background: rgba(124,106,247,0.15); color: var(--primary); }
|
||||||
.queue-item.dragging { opacity: 0.5; background: var(--bg-active); }
|
.queue-item.dragging { opacity: 0.5; background: var(--bg-active); }
|
||||||
.queue-item.drag-over { border-top: 2px solid var(--accent); margin-top: -2px; }
|
.queue-item.drag-over { border-top: 2px solid var(--accent); margin-top: -2px; }
|
||||||
|
|
||||||
@@ -772,7 +773,7 @@ async function loadMeta(track) {
|
|||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const meta = await res.json();
|
const meta = await res.json();
|
||||||
metaCache[track.path] = meta;
|
metaCache[track.path] = meta;
|
||||||
track.meta = meta;
|
if (queue[idx]) { queue[idx].meta = meta; }
|
||||||
updateNowPlaying(track);
|
updateNowPlaying(track);
|
||||||
renderQueue();
|
renderQueue();
|
||||||
}
|
}
|
||||||
@@ -840,6 +841,7 @@ function renderQueue() {
|
|||||||
<div class="qi-artist">${esc(artist)}</div>
|
<div class="qi-artist">${esc(artist)}</div>
|
||||||
</div>
|
</div>
|
||||||
<span class="qi-dur">${dur}</span>
|
<span class="qi-dur">${dur}</span>
|
||||||
|
<button class="qi-locate" title="Go to folder" onclick="locateTrack(${origIdx}, event)">📂</button>
|
||||||
<button class="qi-remove" title="Remove track" onclick="removeFromQueue(${origIdx}, event)">✕</button>
|
<button class="qi-remove" title="Remove track" onclick="removeFromQueue(${origIdx}, event)">✕</button>
|
||||||
`;
|
`;
|
||||||
div.addEventListener('click', () => playIndex(origIdx));
|
div.addEventListener('click', () => playIndex(origIdx));
|
||||||
@@ -878,22 +880,45 @@ function scrollQueueToActive() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function removeFromQueue(origIdx, ev) {
|
function removeFromQueue(origIdx, ev) {
|
||||||
ev.stopPropagation();
|
if (ev) ev.stopPropagation();
|
||||||
|
const isPlaying = origIdx === queueIndex;
|
||||||
|
|
||||||
|
if (isPlaying) {
|
||||||
|
queueIndex = -1;
|
||||||
|
audio.pause();
|
||||||
|
audio.src = '';
|
||||||
|
updateNowPlaying();
|
||||||
|
} else if (queueIndex > origIdx) {
|
||||||
|
queueIndex--;
|
||||||
|
}
|
||||||
|
|
||||||
queue.splice(origIdx, 1);
|
queue.splice(origIdx, 1);
|
||||||
if (queueIndex === origIdx) { queueIndex = -1; audio.pause(); audio.src = ''; }
|
|
||||||
else if (queueIndex > origIdx) queueIndex--;
|
|
||||||
|
|
||||||
if (shuffle) {
|
if (shuffle) {
|
||||||
const sidx = shuffleOrder.indexOf(origIdx);
|
const sidx = shuffleOrder.indexOf(origIdx);
|
||||||
if (sidx !== -1) shuffleOrder.splice(sidx, 1);
|
if (sidx !== -1) shuffleOrder.splice(sidx, 1);
|
||||||
for (let i = 0; i < shuffleOrder.length; i++) {
|
for (let i = 0; i < shuffleOrder.length; i++) {
|
||||||
if (shuffleOrder[i] > origIdx) shuffleOrder[i]--;
|
if (shuffleOrder[i] > origIdx) shuffleOrder[i]--;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
renderQueue();
|
renderQueue();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function locateTrack(idx, ev) {
|
||||||
|
if (ev) ev.stopPropagation();
|
||||||
|
const track = queue[idx];
|
||||||
|
if (!track) return;
|
||||||
|
const parts = track.path.split('/');
|
||||||
|
parts.pop(); // remove filename
|
||||||
|
const folder = parts.join('/');
|
||||||
|
navigate(folder);
|
||||||
|
if (window.innerWidth <= 768) {
|
||||||
|
const sidebar = document.getElementById('sidebar');
|
||||||
|
if (!sidebar.classList.contains('open')) toggleSidebar();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function moveQueueItem(fromPos, toPos) {
|
function moveQueueItem(fromPos, toPos) {
|
||||||
if (fromPos === toPos) return;
|
if (fromPos === toPos) return;
|
||||||
|
|
||||||
@@ -1061,6 +1086,12 @@ const trackPath = urlParams.get('t');
|
|||||||
if (trackPath) {
|
if (trackPath) {
|
||||||
const parts = trackPath.split('/');
|
const parts = trackPath.split('/');
|
||||||
const name = parts[parts.length - 1];
|
const name = parts[parts.length - 1];
|
||||||
|
|
||||||
|
// Navigate to folder
|
||||||
|
const folderParts = [...parts];
|
||||||
|
folderParts.pop(); // remove name
|
||||||
|
navigate(folderParts.join('/'));
|
||||||
|
|
||||||
addToQueue(trackPath, name, true);
|
addToQueue(trackPath, name, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user