Merge branch 'main' of github.com:monochrome-music/monochrome
This commit is contained in:
commit
f5a481dbcd
15 changed files with 135 additions and 28 deletions
2
.github/pull_request_template.md
vendored
2
.github/pull_request_template.md
vendored
|
|
@ -6,7 +6,7 @@
|
|||
- [ ] Docs only
|
||||
|
||||
### Checklist
|
||||
- [ ] **I have read the [Contributing Guidelines](../CONTRIBUTING.md).**
|
||||
- [ ] **I have read the [Contributing Guidelines](https://github.com/monochrome-music/monochrome/blob/main/CONTRIBUTING.md).**
|
||||
- [ ] **I understand every line of code I am submitting.**
|
||||
- [ ] I have tested these changes locally, and they work as expected.
|
||||
|
||||
|
|
|
|||
1
.github/workflows/lint.yml
vendored
1
.github/workflows/lint.yml
vendored
|
|
@ -8,6 +8,7 @@ on:
|
|||
|
||||
permissions:
|
||||
contents: write
|
||||
workflows: write
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
|
|
|
|||
20
Dockerfile
20
Dockerfile
|
|
@ -1,5 +1,5 @@
|
|||
# Node Alpine -- multi-arch (amd64 + arm64)
|
||||
FROM node:lts-alpine
|
||||
FROM oven/bun:1.3.10-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
|
|
@ -7,12 +7,6 @@ WORKDIR /app
|
|||
RUN apk add --no-cache wget curl bash
|
||||
RUN apk add --no-cache python3 make g++ && ln -sf python3 /usr/bin/python
|
||||
|
||||
# Install Bun
|
||||
RUN curl -fsSL https://bun.sh/install | bash
|
||||
|
||||
# Add Bun to PATH so it can be used in subsequent steps
|
||||
ENV PATH="/root/.bun/bin:${PATH}"
|
||||
|
||||
# Copy package files first for caching
|
||||
COPY package.json package-lock.json ./
|
||||
|
||||
|
|
@ -25,8 +19,14 @@ COPY . .
|
|||
# Build the project (Bun is now available for "bun x neu build")
|
||||
RUN bun run build
|
||||
|
||||
# Expose Vite preview port
|
||||
# Serve with nginx
|
||||
FROM nginx:1.28.2-alpine
|
||||
|
||||
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
|
||||
# Expose the nginx port
|
||||
EXPOSE 4173
|
||||
|
||||
# Run the built project
|
||||
CMD ["bun", "run", "preview", "--", "--host", "0.0.0.0"]
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
|
|
|
|||
|
|
@ -35,7 +35,7 @@
|
|||
|
||||
## What is Monochrome?
|
||||
|
||||
**Monochrome** is an open-source, privacy-respecting, ad-free [TIDAL](https://tidal.com) web UI, built on top of [Hi-Fi](https://github.com/binimum/hifi-api). It provides a beautiful, minimalist interface for streaming high-quality music without the clutter of traditional streaming platforms.
|
||||
**Monochrome** is an open-source, privacy-respecting, ad-free [TIDAL](https://tidal.com) web UI, built on top of Hi-Fi. It provides a beautiful, minimalist interface for streaming high-quality music without the clutter of traditional streaming platforms.
|
||||
|
||||
<p align="center">
|
||||
<a href="https://monochrome.tf/album/90502209">
|
||||
|
|
@ -263,6 +263,12 @@ We welcome contributions from the community! Please see our [Contributing Guide]
|
|||
</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://notbyai.fyi">
|
||||
<img src="https://i.samidy.xyz/Developed-By-Humans-Not-By-AI-Badge-black%402x.png" alt="Developed by Humans" height="50">
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
Made with ❤️ by the Monochrome team
|
||||
</p>
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ services:
|
|||
networks:
|
||||
- monochrome-network
|
||||
healthcheck:
|
||||
test: ['CMD', 'wget', '--no-verbose', '--tries=1', '--spider', 'http://localhost:4173/health']
|
||||
test: ['CMD', 'wget', '--no-verbose', '--tries=1', '--spider', 'http://localhost:4173/']
|
||||
interval: 30s
|
||||
timeout: 3s
|
||||
retries: 3
|
||||
|
|
|
|||
17
index.html
17
index.html
|
|
@ -528,7 +528,6 @@
|
|||
color: var(--foreground);
|
||||
cursor: pointer;
|
||||
padding: 0.25rem 0.5rem;
|
||||
opacity: 0.7;
|
||||
"
|
||||
>
|
||||
CSV
|
||||
|
|
@ -669,6 +668,22 @@
|
|||
Make sure its headers are in English.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="setting-item"
|
||||
style="margin-top: 1rem; margin-bottom: 1rem; background: transparent"
|
||||
>
|
||||
<div class="info">
|
||||
<span style="font-size: 0.9rem; font-weight: 600">Strict Album Matching</span>
|
||||
<span style="font-size: 0.8rem; max-width: 14rem">
|
||||
Album name should strictly match CSV metadata. Disable for better discovery.
|
||||
</span>
|
||||
</div>
|
||||
<label class="toggle-switch">
|
||||
<input type="checkbox" id="strict-album-match-toggle" />
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="jspf-import-panel" class="import-panel" style="display: none">
|
||||
|
|
|
|||
|
|
@ -303,6 +303,13 @@ const syncManager = {
|
|||
await this._updateUserJSON(user.$id, 'history', newHistory);
|
||||
},
|
||||
|
||||
async clearHistory() {
|
||||
const user = authManager.user;
|
||||
if (!user) return;
|
||||
|
||||
await this._updateUserJSON(user.$id, 'history', []);
|
||||
},
|
||||
|
||||
async syncUserPlaylist(playlist, action) {
|
||||
const user = authManager.user;
|
||||
if (!user) return;
|
||||
|
|
|
|||
22
js/api.js
22
js/api.js
|
|
@ -20,8 +20,10 @@ import { loadFfmpeg, FfmpegError } from './ffmpeg.js';
|
|||
import { triggerDownload, applyAudioPostProcessing } from './download-utils.ts';
|
||||
import { isCustomFormat } from './ffmpegFormats.ts';
|
||||
import { DownloadProgress } from './progressEvents.js';
|
||||
import { resolveDownloadTotalBytes } from './downloadProgressUtils.js';
|
||||
|
||||
export const DASH_MANIFEST_UNAVAILABLE_CODE = 'DASH_MANIFEST_UNAVAILABLE';
|
||||
export { resolveDownloadTotalBytes };
|
||||
const TIDAL_V2_TOKEN = 'txNoH4kkV41MfH25';
|
||||
|
||||
export class LosslessAPI {
|
||||
|
|
@ -1398,6 +1400,22 @@ export class LosslessAPI {
|
|||
throw hlsError;
|
||||
}
|
||||
} else {
|
||||
// Try HEAD first to get Content-Length when GET uses chunked encoding (fixes #278)
|
||||
let headContentLength = null;
|
||||
try {
|
||||
const headResponse = await fetch(streamUrl, {
|
||||
method: 'HEAD',
|
||||
cache: 'no-store',
|
||||
signal: options.signal,
|
||||
});
|
||||
if (headResponse.ok) {
|
||||
const cl = headResponse.headers.get('Content-Length');
|
||||
if (cl) headContentLength = parseInt(cl, 10);
|
||||
}
|
||||
} catch (_) {
|
||||
/* ignore HEAD failure; proceed with GET */
|
||||
}
|
||||
|
||||
const response = await fetch(streamUrl, {
|
||||
cache: 'no-store',
|
||||
signal: options.signal,
|
||||
|
|
@ -1407,8 +1425,8 @@ export class LosslessAPI {
|
|||
throw new Error(`Fetch failed: ${response.status}`);
|
||||
}
|
||||
|
||||
const contentLength = response.headers.get('Content-Length');
|
||||
const totalBytes = contentLength ? parseInt(contentLength, 10) : 0;
|
||||
const contentLengthHeader = response.headers.get('Content-Length');
|
||||
const totalBytes = resolveDownloadTotalBytes(contentLengthHeader, headContentLength);
|
||||
|
||||
let receivedBytes = 0;
|
||||
|
||||
|
|
|
|||
|
|
@ -1265,6 +1265,7 @@ document.addEventListener('DOMContentLoaded', async () => {
|
|||
let name = document.getElementById('playlist-name-input').value.trim();
|
||||
let description = document.getElementById('playlist-description-input').value.trim();
|
||||
const isPublic = document.getElementById('playlist-public-toggle')?.checked;
|
||||
const isStrictAlbumMatch = document.getElementById('strict-album-match-toggle')?.checked;
|
||||
|
||||
if (name) {
|
||||
const modal = document.getElementById('playlist-modal');
|
||||
|
|
@ -1317,7 +1318,7 @@ document.addEventListener('DOMContentLoaded', async () => {
|
|||
const xmlFileInput = document.getElementById('xml-file-input');
|
||||
const m3uFileInput = document.getElementById('m3u-file-input');
|
||||
|
||||
const importOptions = { strictArtistMatch: true, albumMatch: true };
|
||||
const importOptions = { strictArtistMatch: true, strictAlbumMatch: isStrictAlbumMatch };
|
||||
|
||||
let tracks = [];
|
||||
let importSource = 'manual';
|
||||
|
|
|
|||
14
js/downloadProgressUtils.js
Normal file
14
js/downloadProgressUtils.js
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
/**
|
||||
* Helpers for download progress. Extracted for testability (fixes #278).
|
||||
* Resolve total byte count from GET and optional HEAD Content-Length.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @param {string | null} contentLengthFromGet - Content-Length header from GET response
|
||||
* @param {number | null} headContentLength - Content-Length from prior HEAD request
|
||||
* @returns {number}
|
||||
*/
|
||||
export function resolveDownloadTotalBytes(contentLengthFromGet, headContentLength) {
|
||||
const fromGet = contentLengthFromGet ? parseInt(contentLengthFromGet, 10) : null;
|
||||
return fromGet ?? headContentLength ?? 0;
|
||||
}
|
||||
|
|
@ -5,21 +5,21 @@ function isFuzzyMatch(str1, str2) {
|
|||
return s1.includes(s2) || s2.includes(s1);
|
||||
}
|
||||
|
||||
function findBestMatch(items, targetArtist, targetAlbum, options) {
|
||||
function findBestMatch(items, targetArtist, targetAlbum, importOptions) {
|
||||
if (!items || items.length === 0) return null;
|
||||
if (!options?.strictArtistMatch && !options?.albumMatch) return items[0];
|
||||
if (!importOptions?.strictArtistMatch && !importOptions?.strictAlbumMatch) return items[0];
|
||||
|
||||
return (
|
||||
items.find((item) => {
|
||||
let artistOk = true;
|
||||
let albumOk = true;
|
||||
|
||||
if (options.strictArtistMatch && targetArtist) {
|
||||
if (importOptions.strictArtistMatch && targetArtist) {
|
||||
const itemArtist = item.artist?.name || item.artists?.[0]?.name;
|
||||
if (!isFuzzyMatch(itemArtist, targetArtist)) artistOk = false;
|
||||
}
|
||||
|
||||
if (options.albumMatch && targetAlbum) {
|
||||
if (importOptions.strictAlbumMatch && targetAlbum) {
|
||||
const itemAlbum = item.album?.title;
|
||||
if (itemAlbum && !isFuzzyMatch(itemAlbum, targetAlbum)) albumOk = false;
|
||||
}
|
||||
|
|
@ -294,8 +294,7 @@ export async function parseDynamicCSV(csvText, api, onProgress, options = {}) {
|
|||
const albumName = mappedHeaders.album !== undefined ? values[mappedHeaders.album] : '';
|
||||
const isrc = mappedHeaders.isrc !== undefined ? values[mappedHeaders.isrc] : '';
|
||||
const playlistName = mappedHeaders.playlistName !== undefined ? values[mappedHeaders.playlistName] : '';
|
||||
const typeValue =
|
||||
mappedHeaders.type !== undefined ? values[mappedHeaders.type]?.toLowerCase().trim() : '';
|
||||
const typeValue = mappedHeaders.type !== undefined ? values[mappedHeaders.type]?.toLowerCase().trim() : '';
|
||||
const isFavorite = typeValue.includes('favorite');
|
||||
|
||||
if (onProgress) {
|
||||
|
|
@ -497,7 +496,7 @@ export async function importToLibrary(csvResult, db, onProgress, options = {}) {
|
|||
return results;
|
||||
}
|
||||
|
||||
export async function parseCSV(csvText, api, onProgress, options = {}) {
|
||||
export async function parseCSV(csvText, api, onProgress, importOptions = {}) {
|
||||
const lines = csvText.trim().split('\n');
|
||||
if (lines.length < 2) return { tracks: [], missingTracks: [] };
|
||||
|
||||
|
|
@ -583,7 +582,7 @@ export async function parseCSV(csvText, api, onProgress, options = {}) {
|
|||
const searchResult = await api.searchTracks(searchQuery);
|
||||
|
||||
if (searchResult.items && searchResult.items.length > 0) {
|
||||
const match = findBestMatch(searchResult.items, artistNames, albumName, options);
|
||||
const match = findBestMatch(searchResult.items, artistNames, albumName, importOptions);
|
||||
if (match) tracks.push(match);
|
||||
else missingTracks.push({ title: trackTitle, artist: artistNames, album: albumName });
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -3125,7 +3125,20 @@ export function initializeSettings(scrobbler, player, api, ui) {
|
|||
|
||||
// Clear IndexedDB - try to clear individual stores, fallback to deleting database
|
||||
try {
|
||||
const stores = ['tracks', 'albums', 'artists', 'playlists', 'settings', 'history'];
|
||||
const stores = [
|
||||
'favorites_tracks',
|
||||
'favorites_videos',
|
||||
'favorites_albums',
|
||||
'favorites_artists',
|
||||
'favorites_playlists',
|
||||
'favorites_mixes',
|
||||
'history_tracks',
|
||||
'user_playlists',
|
||||
'user_folders',
|
||||
'settings',
|
||||
'pinned_items',
|
||||
];
|
||||
|
||||
for (const storeName of stores) {
|
||||
try {
|
||||
await db.performTransaction(storeName, 'readwrite', (store) => store.clear());
|
||||
|
|
@ -3133,11 +3146,12 @@ export function initializeSettings(scrobbler, player, api, ui) {
|
|||
// Store might not exist, continue
|
||||
}
|
||||
}
|
||||
|
||||
} catch (dbError) {
|
||||
console.log('Could not clear IndexedDB stores:', dbError);
|
||||
// Try to delete the entire database as fallback
|
||||
try {
|
||||
const deleteRequest = indexedDB.deleteDatabase('monochromeDB');
|
||||
const deleteRequest = indexedDB.deleteDatabase('MonochromeDB');
|
||||
await new Promise((resolve, reject) => {
|
||||
deleteRequest.onsuccess = resolve;
|
||||
deleteRequest.onerror = reject;
|
||||
|
|
|
|||
1
js/ui.js
1
js/ui.js
|
|
@ -4451,6 +4451,7 @@ export class UIRenderer {
|
|||
if (confirm('Clear all recently played tracks? This cannot be undone.')) {
|
||||
try {
|
||||
await db.clearHistory();
|
||||
await syncManager.clearHistory();
|
||||
container.innerHTML = createPlaceholder("You haven't played any tracks yet.");
|
||||
clearBtn.style.display = 'none';
|
||||
} catch (err) {
|
||||
|
|
|
|||
15
nginx.conf
Normal file
15
nginx.conf
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
server {
|
||||
listen 4173;
|
||||
listen [::]:4173;
|
||||
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
location ~* \.(?:css|js|mjs|map|json|wasm|mp3|flac|wav|ogg|png|jpg|jpeg|svg|webp|ico|gz|br|utf|ttf|woff2?)$ {
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
}
|
||||
18
styles.css
18
styles.css
|
|
@ -1733,6 +1733,14 @@ input[type='search']::-webkit-search-cancel-button {
|
|||
animation: fade-in-slide-up 0.4s var(--ease-out-back);
|
||||
}
|
||||
|
||||
/* Prevent settings tab content from overflowing on small displays (fixes #313) */
|
||||
.settings-tab-content {
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
overflow-x: auto;
|
||||
overflow-y: visible;
|
||||
}
|
||||
|
||||
.card-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||
|
|
@ -2571,6 +2579,9 @@ input[type='search']::-webkit-search-cancel-button {
|
|||
|
||||
.settings-list {
|
||||
max-width: 800px;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.settings-group {
|
||||
|
|
@ -2615,6 +2626,7 @@ input[type='search']::-webkit-search-cancel-button {
|
|||
padding: var(--spacing-lg) 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
gap: var(--spacing-lg);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.setting-item.sidebar-setting-item {
|
||||
|
|
@ -2654,6 +2666,7 @@ input[type='search']::-webkit-search-cancel-button {
|
|||
.setting-item .info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.setting-item .label {
|
||||
|
|
@ -3641,7 +3654,8 @@ input:checked + .slider::before {
|
|||
/* Use a CSS variable for the image so we can set it in JS */
|
||||
--bg-image: none;
|
||||
|
||||
padding-bottom: 0;
|
||||
/* Reserve space above taskbar / system UI so volume controls stay visible (fixes #322) */
|
||||
padding-bottom: max(env(safe-area-inset-bottom), 1.5rem);
|
||||
}
|
||||
|
||||
#fullscreen-cover-overlay::before {
|
||||
|
|
@ -5390,11 +5404,13 @@ img[src=''] {
|
|||
|
||||
.fullscreen-main-view {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 6rem 2rem 2rem;
|
||||
overflow-y: auto;
|
||||
transition: all 0.5s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue