diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile
new file mode 100644
index 0000000..7750aa2
--- /dev/null
+++ b/.devcontainer/Dockerfile
@@ -0,0 +1,66 @@
+# ------------------------------------------------------------
+# Base Image
+# ------------------------------------------------------------
+FROM debian:unstable-slim
+
+ENV DEBIAN_FRONTEND=noninteractive
+ENV LANG=C.UTF-8
+ENV LC_ALL=C.UTF-8
+
+# ------------------------------------------------------------
+# System Dependencies
+# ------------------------------------------------------------
+RUN apt-get update && apt-get upgrade -y && \
+ apt-get install -y --no-install-recommends \
+ curl \
+ ca-certificates \
+ git \
+ build-essential \
+ sudo \
+ fish \
+ unzip \
+ xz-utils \
+ libatomic1 \
+ libc6 \
+ wget && \
+ rm -rf /var/lib/apt/lists/*
+
+# ------------------------------------------------------------
+# Create Non-Root User
+# ------------------------------------------------------------
+ARG USERNAME=devuser
+ARG UID=1000
+ARG GID=1000
+
+RUN groupadd --gid ${GID} ${USERNAME} && \
+ useradd --uid ${UID} --gid ${GID} -m -s /usr/bin/fish ${USERNAME} && \
+ echo "${USERNAME} ALL=(ALL) NOPASSWD:ALL" >> /etc/sudoers
+
+USER ${USERNAME}
+WORKDIR /home/${USERNAME}
+
+# ------------------------------------------------------------
+# Install Bun (Non-Root)
+# ------------------------------------------------------------
+ENV BUN_INSTALL=/home/${USERNAME}/.bun
+ENV PATH="${BUN_INSTALL}/bin:${PATH}"
+
+RUN curl -fsSL https://bun.sh/install | bash
+
+# ------------------------------------------------------------
+# Install OpenCode (Proper PATH Handling)
+# ------------------------------------------------------------
+RUN curl -fsSL https://opencode.ai/install -o opencode-install && \
+ chmod +x opencode-install && \
+ ./opencode-install --yes && \
+ rm opencode-install
+
+# Add OpenCode to PATH permanently
+ENV PATH="/home/${USERNAME}/.opencode/bin:${PATH}"
+
+# ------------------------------------------------------------
+# Ensure fish is Default Shell
+# ------------------------------------------------------------
+ENV SHELL=/usr/bin/fish
+
+CMD ["fish"]
\ No newline at end of file
diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json
new file mode 100644
index 0000000..6033fdf
--- /dev/null
+++ b/.devcontainer/devcontainer.json
@@ -0,0 +1,22 @@
+{
+ "name": "debian-bun-fish-devcontainer",
+ "build": {
+ "dockerfile": "Dockerfile"
+ },
+
+ "remoteUser": "devuser",
+
+ "features": {},
+
+ "customizations": {
+ "vscode": {
+ "extensions": ["dbaeumer.vscode-eslint", "esbenp.prettier-vscode"]
+ }
+ },
+
+ "postCreateCommand": "bun --version && code --version",
+
+ "remoteEnv": {
+ "SHELL": "/usr/bin/fish"
+ }
+}
diff --git a/bun.lock b/bun.lock
index f8a22d0..cb1af31 100644
--- a/bun.lock
+++ b/bun.lock
@@ -19,7 +19,7 @@
"@neutralinojs/neu": "^11.7.0",
"eslint": "^9.39.3",
"eslint-config-prettier": "^10.1.8",
- "globals": "^17.3.0",
+ "globals": "^17.4.0",
"htmlhint": "^1.9.1",
"prettier": "^3.8.1",
"stylelint": "^16.26.1",
diff --git a/index.html b/index.html
index 25b3685..07cbe4f 100644
--- a/index.html
+++ b/index.html
@@ -87,6 +87,7 @@
Go to album
Copy link
Open in new tab
+ Open in Harmony
Track info
Open original URL
Download
@@ -2710,6 +2711,29 @@
Save
+
diff --git a/js/app.js b/js/app.js
index 9d65754..a41e476 100644
--- a/js/app.js
+++ b/js/app.js
@@ -2323,14 +2323,19 @@ document.addEventListener('DOMContentLoaded', async () => {
const searchForm = document.getElementById('search-form');
const searchInput = document.getElementById('search-input');
- // Setup clear button for search bar
ui.setupSearchClearButton(searchInput);
- const performSearch = debounce((query) => {
+ const performSearch = (query) => {
if (query) {
navigate(`/search/${encodeURIComponent(query)}`);
}
- }, 0);
+ };
+
+ const debouncedSearch = debounce((query) => {
+ if (query && query === searchInput.value.trim()) {
+ performSearch(query);
+ }
+ }, 3000);
const handleExternalLink = (query) => {
const isExternalLink =
@@ -2343,9 +2348,7 @@ document.addEventListener('DOMContentLoaded', async () => {
try {
const urlObj = new URL(url);
let path = urlObj.pathname;
- // Remove trailing slashes and get just endpoint/id
path = path.replace(/\/+$/, '');
- // Get just the first two segments (e.g., /album/382839956)
const segments = path.split('/').filter((s) => s);
if (segments.length >= 2) {
path = '/' + segments[0] + '/' + segments[1];
@@ -2363,9 +2366,11 @@ document.addEventListener('DOMContentLoaded', async () => {
const query = e.target.value.trim();
if (!query) return;
- if (!handleExternalLink(query)) {
- performSearch(query);
+ if (handleExternalLink(query)) {
+ return;
}
+
+ debouncedSearch(query);
});
searchInput.addEventListener('change', (e) => {
@@ -2397,7 +2402,7 @@ document.addEventListener('DOMContentLoaded', async () => {
if (!handleExternalLink(query)) {
ui.addToSearchHistory(query);
- navigate(`/search/${encodeURIComponent(query)}`);
+ performSearch(query);
const historyEl = document.getElementById('search-history');
if (historyEl) historyEl.style.display = 'none';
}
diff --git a/js/events.js b/js/events.js
index 3e207a6..5a8af19 100644
--- a/js/events.js
+++ b/js/events.js
@@ -1200,6 +1200,10 @@ export async function handleTrackAction(
trackOpenInNewTab(type, item.id || item.uuid);
window.open(url, '_blank');
+ } else if (action === 'open-in-harmony') {
+ const albumId = item.id;
+ const harmonyUrl = `https://harmony.pulsewidth.org.uk/release?url=${encodeURIComponent(`https://tidal.com/album/${albumId}`)}>in=®ion=&musicbrainz=&deezer=&itunes=&spotify=&tidal=&beatport=`;
+ window.open(harmonyUrl, '_blank');
} else if (action === 'track-info') {
// Show detailed track info modal
const isTracker = item.isTracker;
@@ -1428,9 +1432,11 @@ export async function handleTrackAction(
async function updateContextMenuLikeState(contextMenu, contextTrack) {
if (!contextMenu || !contextTrack) return;
+ const type = contextMenu._contextType || 'track';
+
const likeItem = contextMenu.querySelector('li[data-action="toggle-like"]');
if (likeItem) {
- const isLiked = await db.isFavorite('track', contextTrack.id);
+ const isLiked = await db.isFavorite(type, contextTrack.id);
likeItem.textContent = isLiked ? 'Unlike' : 'Like';
}
@@ -1455,7 +1461,6 @@ async function updateContextMenuLikeState(contextMenu, contextTrack) {
// Update block/unblock labels
const { contentBlockingSettings } = await import('./storage.js');
- const type = contextMenu._contextType || 'track';
const blockTrackItem = contextMenu.querySelector('li[data-action="block-track"]');
if (blockTrackItem) {
@@ -1572,7 +1577,7 @@ export function initializeTrackInteractions(player, api, mainContent, contextMen
return;
}
- const cardMenuBtn = e.target.closest('.card-menu-btn');
+ const cardMenuBtn = e.target.closest('.card-menu-btn, #album-menu-btn');
if (cardMenuBtn) {
e.stopPropagation();
const card = cardMenuBtn.closest('.card');
@@ -1581,9 +1586,14 @@ export function initializeTrackInteractions(player, api, mainContent, contextMen
let item = card ? trackDataStore.get(card) : null;
+ if (!item) {
+ // Check if item is stored on the button itself (e.g., album page header menu)
+ item = trackDataStore.get(cardMenuBtn);
+ }
+
if (!item) {
// Fallback: create a shell item
- item = { id, uuid: id, title: card.querySelector('.card-title')?.textContent || 'Item' };
+ item = { id, uuid: id, title: card?.querySelector('.card-title')?.textContent || 'Item' };
}
if (contextMenu._originalHTML) {
diff --git a/js/ui.js b/js/ui.js
index 504f0e1..269dab4 100644
--- a/js/ui.js
+++ b/js/ui.js
@@ -31,6 +31,7 @@ import {
homePageSettings,
fontSettings,
contentBlockingSettings,
+ settingsUiState,
} from './storage.js';
import { db } from './db.js';
import { getVibrantColorFromImage } from './vibrant-color.js';
@@ -1448,6 +1449,17 @@ export class UIRenderer {
if (pageId === 'settings') {
this.renderApiSettings();
+ const savedTabName = settingsUiState.getActiveTab();
+ const savedTab = document.querySelector(`.settings-tab[data-tab="${savedTabName}"]`);
+ if (savedTab) {
+ document.querySelectorAll('.settings-tab').forEach((t) => t.classList.remove('active'));
+ document.querySelectorAll('.settings-tab-content').forEach((c) => c.classList.remove('active'));
+ savedTab.classList.add('active');
+ document.getElementById(`settings-tab-${savedTabName}`)?.classList.add('active');
+ }
+ } else {
+ document.querySelectorAll('.settings-tab').forEach((t) => t.classList.remove('active'));
+ document.querySelectorAll('.settings-tab-content').forEach((c) => c.classList.remove('active'));
}
}
@@ -2461,6 +2473,13 @@ export class UIRenderer {
albumLikeBtn.classList.toggle('active', isLiked);
}
+ // Store album data for menu button
+ const albumMenuBtn = document.getElementById('album-menu-btn');
+ if (albumMenuBtn) {
+ albumMenuBtn.dataset.id = album.id;
+ trackDataStore.set(albumMenuBtn, album);
+ }
+
document.title = `${album.title} - ${album.artist.name}`;
// "More from Artist" and Related Sections
diff --git a/styles.css b/styles.css
index a31eb46..5540c85 100644
--- a/styles.css
+++ b/styles.css
@@ -2401,6 +2401,13 @@ input[type='search']::-webkit-search-cancel-button {
flex-shrink: 0;
}
+.detail-header-actions .card-menu-btn,
+.detail-header-actions #album-menu-btn {
+ position: static !important;
+ opacity: 1 !important;
+ transform: none !important;
+}
+
.detail-header-actions .btn-primary span,
.detail-header-actions .btn-secondary span {
display: none;
diff --git a/todo.md b/todo.md
deleted file mode 100644
index b982ace..0000000
--- a/todo.md
+++ /dev/null
@@ -1,8 +0,0 @@
-# Feature Requests
-
-Sorted by ease of implementation (easiest to hardest):
-
-- [ ] effects like reverb, delay, and bitcrushing
-- [ ] Customizable EQ: Allow users to change the number of EQ bands and their range (-30 to 30), with a drag-to-adjust interface similar to FL Studio's velocity editor
-
-[ ] SoundCloud support: Integrate SoundCloud through SoundCloak