Merge branch 'main' of github.com:monochrome-music/monochrome
This commit is contained in:
commit
e013ad0de5
9 changed files with 166 additions and 21 deletions
66
.devcontainer/Dockerfile
Normal file
66
.devcontainer/Dockerfile
Normal file
|
|
@ -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"]
|
||||||
22
.devcontainer/devcontainer.json
Normal file
22
.devcontainer/devcontainer.json
Normal file
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
2
bun.lock
2
bun.lock
|
|
@ -19,7 +19,7 @@
|
||||||
"@neutralinojs/neu": "^11.7.0",
|
"@neutralinojs/neu": "^11.7.0",
|
||||||
"eslint": "^9.39.3",
|
"eslint": "^9.39.3",
|
||||||
"eslint-config-prettier": "^10.1.8",
|
"eslint-config-prettier": "^10.1.8",
|
||||||
"globals": "^17.3.0",
|
"globals": "^17.4.0",
|
||||||
"htmlhint": "^1.9.1",
|
"htmlhint": "^1.9.1",
|
||||||
"prettier": "^3.8.1",
|
"prettier": "^3.8.1",
|
||||||
"stylelint": "^16.26.1",
|
"stylelint": "^16.26.1",
|
||||||
|
|
|
||||||
24
index.html
24
index.html
|
|
@ -87,6 +87,7 @@
|
||||||
<li data-action="go-to-album" data-type-filter="track">Go to album</li>
|
<li data-action="go-to-album" data-type-filter="track">Go to album</li>
|
||||||
<li data-action="copy-link">Copy link</li>
|
<li data-action="copy-link">Copy link</li>
|
||||||
<li data-action="open-in-new-tab">Open in new tab</li>
|
<li data-action="open-in-new-tab">Open in new tab</li>
|
||||||
|
<li data-action="open-in-harmony" data-type-filter="album">Open in Harmony</li>
|
||||||
<li data-action="track-info" data-type-filter="track">Track info</li>
|
<li data-action="track-info" data-type-filter="track">Track info</li>
|
||||||
<li data-action="open-original-url" data-type-filter="track">Open original URL</li>
|
<li data-action="open-original-url" data-type-filter="track">Open original URL</li>
|
||||||
<li data-action="download">Download</li>
|
<li data-action="download">Download</li>
|
||||||
|
|
@ -2710,6 +2711,29 @@
|
||||||
</svg>
|
</svg>
|
||||||
<span>Save</span>
|
<span>Save</span>
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
id="album-menu-btn"
|
||||||
|
class="btn-secondary"
|
||||||
|
data-action="card-menu"
|
||||||
|
data-type="album"
|
||||||
|
title="More options"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="24"
|
||||||
|
height="24"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
>
|
||||||
|
<circle cx="12" cy="12" r="1"></circle>
|
||||||
|
<circle cx="12" cy="5" r="1"></circle>
|
||||||
|
<circle cx="12" cy="19" r="1"></circle>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
|
||||||
21
js/app.js
21
js/app.js
|
|
@ -2323,14 +2323,19 @@ document.addEventListener('DOMContentLoaded', async () => {
|
||||||
const searchForm = document.getElementById('search-form');
|
const searchForm = document.getElementById('search-form');
|
||||||
const searchInput = document.getElementById('search-input');
|
const searchInput = document.getElementById('search-input');
|
||||||
|
|
||||||
// Setup clear button for search bar
|
|
||||||
ui.setupSearchClearButton(searchInput);
|
ui.setupSearchClearButton(searchInput);
|
||||||
|
|
||||||
const performSearch = debounce((query) => {
|
const performSearch = (query) => {
|
||||||
if (query) {
|
if (query) {
|
||||||
navigate(`/search/${encodeURIComponent(query)}`);
|
navigate(`/search/${encodeURIComponent(query)}`);
|
||||||
}
|
}
|
||||||
}, 0);
|
};
|
||||||
|
|
||||||
|
const debouncedSearch = debounce((query) => {
|
||||||
|
if (query && query === searchInput.value.trim()) {
|
||||||
|
performSearch(query);
|
||||||
|
}
|
||||||
|
}, 3000);
|
||||||
|
|
||||||
const handleExternalLink = (query) => {
|
const handleExternalLink = (query) => {
|
||||||
const isExternalLink =
|
const isExternalLink =
|
||||||
|
|
@ -2343,9 +2348,7 @@ document.addEventListener('DOMContentLoaded', async () => {
|
||||||
try {
|
try {
|
||||||
const urlObj = new URL(url);
|
const urlObj = new URL(url);
|
||||||
let path = urlObj.pathname;
|
let path = urlObj.pathname;
|
||||||
// Remove trailing slashes and get just endpoint/id
|
|
||||||
path = path.replace(/\/+$/, '');
|
path = path.replace(/\/+$/, '');
|
||||||
// Get just the first two segments (e.g., /album/382839956)
|
|
||||||
const segments = path.split('/').filter((s) => s);
|
const segments = path.split('/').filter((s) => s);
|
||||||
if (segments.length >= 2) {
|
if (segments.length >= 2) {
|
||||||
path = '/' + segments[0] + '/' + segments[1];
|
path = '/' + segments[0] + '/' + segments[1];
|
||||||
|
|
@ -2363,9 +2366,11 @@ document.addEventListener('DOMContentLoaded', async () => {
|
||||||
const query = e.target.value.trim();
|
const query = e.target.value.trim();
|
||||||
if (!query) return;
|
if (!query) return;
|
||||||
|
|
||||||
if (!handleExternalLink(query)) {
|
if (handleExternalLink(query)) {
|
||||||
performSearch(query);
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
debouncedSearch(query);
|
||||||
});
|
});
|
||||||
|
|
||||||
searchInput.addEventListener('change', (e) => {
|
searchInput.addEventListener('change', (e) => {
|
||||||
|
|
@ -2397,7 +2402,7 @@ document.addEventListener('DOMContentLoaded', async () => {
|
||||||
|
|
||||||
if (!handleExternalLink(query)) {
|
if (!handleExternalLink(query)) {
|
||||||
ui.addToSearchHistory(query);
|
ui.addToSearchHistory(query);
|
||||||
navigate(`/search/${encodeURIComponent(query)}`);
|
performSearch(query);
|
||||||
const historyEl = document.getElementById('search-history');
|
const historyEl = document.getElementById('search-history');
|
||||||
if (historyEl) historyEl.style.display = 'none';
|
if (historyEl) historyEl.style.display = 'none';
|
||||||
}
|
}
|
||||||
|
|
|
||||||
18
js/events.js
18
js/events.js
|
|
@ -1200,6 +1200,10 @@ export async function handleTrackAction(
|
||||||
|
|
||||||
trackOpenInNewTab(type, item.id || item.uuid);
|
trackOpenInNewTab(type, item.id || item.uuid);
|
||||||
window.open(url, '_blank');
|
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') {
|
} else if (action === 'track-info') {
|
||||||
// Show detailed track info modal
|
// Show detailed track info modal
|
||||||
const isTracker = item.isTracker;
|
const isTracker = item.isTracker;
|
||||||
|
|
@ -1428,9 +1432,11 @@ export async function handleTrackAction(
|
||||||
async function updateContextMenuLikeState(contextMenu, contextTrack) {
|
async function updateContextMenuLikeState(contextMenu, contextTrack) {
|
||||||
if (!contextMenu || !contextTrack) return;
|
if (!contextMenu || !contextTrack) return;
|
||||||
|
|
||||||
|
const type = contextMenu._contextType || 'track';
|
||||||
|
|
||||||
const likeItem = contextMenu.querySelector('li[data-action="toggle-like"]');
|
const likeItem = contextMenu.querySelector('li[data-action="toggle-like"]');
|
||||||
if (likeItem) {
|
if (likeItem) {
|
||||||
const isLiked = await db.isFavorite('track', contextTrack.id);
|
const isLiked = await db.isFavorite(type, contextTrack.id);
|
||||||
likeItem.textContent = isLiked ? 'Unlike' : 'Like';
|
likeItem.textContent = isLiked ? 'Unlike' : 'Like';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1455,7 +1461,6 @@ async function updateContextMenuLikeState(contextMenu, contextTrack) {
|
||||||
|
|
||||||
// Update block/unblock labels
|
// Update block/unblock labels
|
||||||
const { contentBlockingSettings } = await import('./storage.js');
|
const { contentBlockingSettings } = await import('./storage.js');
|
||||||
const type = contextMenu._contextType || 'track';
|
|
||||||
|
|
||||||
const blockTrackItem = contextMenu.querySelector('li[data-action="block-track"]');
|
const blockTrackItem = contextMenu.querySelector('li[data-action="block-track"]');
|
||||||
if (blockTrackItem) {
|
if (blockTrackItem) {
|
||||||
|
|
@ -1572,7 +1577,7 @@ export function initializeTrackInteractions(player, api, mainContent, contextMen
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const cardMenuBtn = e.target.closest('.card-menu-btn');
|
const cardMenuBtn = e.target.closest('.card-menu-btn, #album-menu-btn');
|
||||||
if (cardMenuBtn) {
|
if (cardMenuBtn) {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
const card = cardMenuBtn.closest('.card');
|
const card = cardMenuBtn.closest('.card');
|
||||||
|
|
@ -1581,9 +1586,14 @@ export function initializeTrackInteractions(player, api, mainContent, contextMen
|
||||||
|
|
||||||
let item = card ? trackDataStore.get(card) : null;
|
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) {
|
if (!item) {
|
||||||
// Fallback: create a shell 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) {
|
if (contextMenu._originalHTML) {
|
||||||
|
|
|
||||||
19
js/ui.js
19
js/ui.js
|
|
@ -31,6 +31,7 @@ import {
|
||||||
homePageSettings,
|
homePageSettings,
|
||||||
fontSettings,
|
fontSettings,
|
||||||
contentBlockingSettings,
|
contentBlockingSettings,
|
||||||
|
settingsUiState,
|
||||||
} from './storage.js';
|
} from './storage.js';
|
||||||
import { db } from './db.js';
|
import { db } from './db.js';
|
||||||
import { getVibrantColorFromImage } from './vibrant-color.js';
|
import { getVibrantColorFromImage } from './vibrant-color.js';
|
||||||
|
|
@ -1448,6 +1449,17 @@ export class UIRenderer {
|
||||||
|
|
||||||
if (pageId === 'settings') {
|
if (pageId === 'settings') {
|
||||||
this.renderApiSettings();
|
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);
|
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}`;
|
document.title = `${album.title} - ${album.artist.name}`;
|
||||||
|
|
||||||
// "More from Artist" and Related Sections
|
// "More from Artist" and Related Sections
|
||||||
|
|
|
||||||
|
|
@ -2401,6 +2401,13 @@ input[type='search']::-webkit-search-cancel-button {
|
||||||
flex-shrink: 0;
|
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-primary span,
|
||||||
.detail-header-actions .btn-secondary span {
|
.detail-header-actions .btn-secondary span {
|
||||||
display: none;
|
display: none;
|
||||||
|
|
|
||||||
8
todo.md
8
todo.md
|
|
@ -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
|
|
||||||
Loading…
Reference in a new issue