Merge branch 'neutralino' into main-samidy

This commit is contained in:
Julien Maille 2026-02-10 00:41:23 +01:00
commit 731c7a7a0b
15 changed files with 1462 additions and 13 deletions

96
.github/workflows/desktop-build.yml vendored Normal file
View file

@ -0,0 +1,96 @@
name: Desktop Build
on:
push:
branches: [main, neutralino]
workflow_dispatch:
permissions:
contents: write
jobs:
build:
runs-on: ${{ matrix.os }}
strategy:
matrix:
include:
- os: windows-latest
platform: windows
binary_source: Monochrome-win_x64.exe
binary_dest: Monochrome.exe
archive_ext: zip
- os: ubuntu-latest
platform: linux
binary_source: Monochrome-linux_x64
binary_dest: Monochrome
archive_ext: tar.gz
- os: macos-latest
platform: macos
binary_source: Monochrome-mac_universal
binary_dest: Monochrome
archive_ext: tar.gz
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 1
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Install dependencies
run: npm install
- name: Download Neutralino binaries
run: npx neu update
- name: Build application
run: npm run build
- name: Prepare Release
run: |
mkdir release
cp dist/Monochrome/resources.neu release/
cp neutralino.config.json release/
cp -r extensions release/
cp dist/Monochrome/${{ matrix.binary_source }} release/${{ matrix.binary_dest }}
shell: bash
- name: Set Permissions (Linux/macOS)
if: matrix.platform != 'windows'
run: chmod +x release/${{ matrix.binary_dest }}
# Upload the uncompressed directory as the workflow artifact.
# GitHub will zip this automatically when downloaded, avoiding "zip inside zip".
- name: Upload Artifact
uses: actions/upload-artifact@v4
with:
name: Monochrome-${{ matrix.platform }}
path: release/
retention-days: 30
# Create an archive specifically for the GitHub Release (tags only)
- name: Create Release Archive (Windows)
if: startsWith(github.ref, 'refs/tags/v') && matrix.platform == 'windows'
run: |
Compress-Archive -Path release/* -DestinationPath monochrome-${{ matrix.platform }}-x64.zip
shell: pwsh
- name: Create Release Archive (Linux/macOS)
if: startsWith(github.ref, 'refs/tags/v') && matrix.platform != 'windows'
run: |
cd release
tar -czf ../monochrome-${{ matrix.platform }}-x64.tar.gz *
- name: Create Release
if: startsWith(github.ref, 'refs/tags/v')
uses: softprops/action-gh-release@v1
with:
files: monochrome-${{ matrix.platform }}-x64.${{ matrix.archive_ext }}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

9
.gitignore vendored
View file

@ -6,3 +6,12 @@ dist
# Docker
.env
# Neutralino
.tmp/
bin/
*.log
.storage/
auth_storage/
www

View file

@ -4,7 +4,7 @@ import prettierConfig from 'eslint-config-prettier';
export default [
{
ignores: ['dist/', 'node_modules/', 'legacy/', 'sw.js'],
ignores: ['**/dist/**', '**/node_modules/**', '**/legacy/**', '**/bin/**'],
},
js.configs.recommended,
prettierConfig,

View file

@ -0,0 +1,91 @@
# bridge.ps1 - JSON Depth Fix
# $Log = Join-Path $PSScriptRoot "bridge_final.log"
function Log($m) { }
Log "--- START (DEPTH FIX) ---"
# 1. PID
$p = Get-Process Monochrome -ErrorAction SilentlyContinue | Select-Object -First 1
if (-not $p) { $p = Get-Process neutralino-win_x64 -ErrorAction SilentlyContinue | Select-Object -First 1 }
$pid_to_send = if ($p) { $p.Id } else { [System.Diagnostics.Process]::GetCurrentProcess().Id }
# 2. Discord Connection
function Get-Pipe {
for ($i = 0; $i -le 9; $i++) {
try {
$pn = "discord-ipc-$i"
$p = New-Object System.IO.Pipes.NamedPipeClientStream(".", $pn, [System.IO.Pipes.PipeDirection]::InOut)
$p.Connect(100)
return $p
} catch { }
}
return $null
}
$pipe = Get-Pipe; if (-not $pipe) { Log "Discord Fail"; exit }
function Send-Packet($op, $json) {
if ($op -eq 1) { Log "Sending Activity: $json" }
$j = [System.Text.Encoding]::UTF8.GetBytes($json)
[byte[]]$pkt = [BitConverter]::GetBytes([int]$op) + [BitConverter]::GetBytes([int]$j.Length) + $j
$pipe.Write($pkt, 0, $pkt.Length); $pipe.Flush()
}
# 3. Handshake
Send-Packet 0 (@{ v = 1; client_id = "1462186088184549661" } | ConvertTo-Json -Compress)
$h = New-Object byte[] 8; if ($pipe.Read($h, 0, 8) -eq 8) {
$l = [BitConverter]::ToInt32($h, 4); $b = New-Object byte[] $l; $pipe.Read($b, 0, $l) | Out-Null
Log "Handshake OK"
}
function Set-Activity($d, $s, $img) {
$activity = @{
details = [string]$d
state = [string]$s
type = 2
assets = @{
large_image = if ($img -and $img.StartsWith("http")) { [string]$img } else { "monochrome" }
large_text = "Monochrome"
}
}
# CRITICAL: -Depth 10 ensures 'assets' is not stringified as a class name
$payload = @{
cmd = "SET_ACTIVITY"
args = @{ pid = [int]$pid_to_send; activity = $activity }
nonce = [Guid]::NewGuid().ToString()
} | ConvertTo-Json -Compress -Depth 10
Send-Packet 1 $payload
}
Start-Sleep -Seconds 1
Set-Activity "Idling" "Monochrome" $null
# 4. Config & WS
$line = [Console]::In.ReadLine()
if (-not $line) { exit }
$config = $line | ConvertFrom-Json
$ws = New-Object System.Net.WebSockets.ClientWebSocket
try {
$uri = [Uri]"ws://127.0.0.1:$($config.nlPort)?extensionId=$($config.nlExtensionId)&connectToken=$($config.nlConnectToken)"
$ws.ConnectAsync($uri, [System.Threading.CancellationToken]::None).Wait()
Log "WS Connected"
} catch { exit }
# 5. Loop
$buf = New-Object byte[] 65536
while ($ws.State -eq "Open") {
$task = $ws.ReceiveAsync((New-Object ArraySegment[byte] -ArgumentList @(,$buf)), [System.Threading.CancellationToken]::None)
while (-not $task.Wait(1000)) { if (-not (Get-Process -Id $pid_to_send -ErrorAction SilentlyContinue)) { exit } }
if ($task.Result.Count -gt 0) {
try {
$raw = [System.Text.Encoding]::UTF8.GetString($buf, 0, $task.Result.Count)
$msg = $raw | ConvertFrom-Json
if ($msg.event -eq "discord:update") {
Set-Activity $msg.data.details $msg.data.state $msg.data.largeImageKey
}
elseif ($msg.event -eq "discord:clear") { Set-Activity "Idling" "Monochrome" $null }
} catch {}
}
}

View file

@ -0,0 +1,142 @@
# bridge.py - Production Discord RPC Bridge (Linux/macOS)
import sys, json, socket, struct, os, uuid, base64, time
CLIENT_ID = "1462186088184549661"
LAST_STATUS = ""
def get_discord_path():
for i in range(10):
path = os.path.join(os.environ.get('XDG_RUNTIME_DIR', '/tmp'), f'discord-ipc-{i}')
if os.path.exists(path): return path
return None
def send_packet(s, op, data):
payload = json.dumps(data).encode('utf-8')
header = struct.pack('<II', op, len(payload))
s.sendall(header + payload)
def recv_packet(s):
try:
header = s.recv(8)
if len(header) < 8: return None
op, length = struct.unpack('<II', header)
payload = s.recv(length)
return json.loads(payload.decode('utf-8'))
except: return None
def set_activity(ds, pid, details, state, img=None):
global LAST_STATUS
current = f"{details}-{state}-{img}"
if current == LAST_STATUS: return
LAST_STATUS = current
activity = {
"details": str(details or "Idling"),
"state": str(state or "Monochrome"),
"type": 2, # Listening
"assets": {
"large_image": img if img and img.startswith('http') else "monochrome",
"large_text": "Monochrome"
}
}
send_packet(ds, 1, {
"cmd": "SET_ACTIVITY",
"args": {"pid": pid, "activity": activity},
"nonce": str(uuid.uuid4())
})
def main():
# 1. Read config
try:
line = sys.stdin.readline()
if not line: return
config = json.loads(line)
except: return
ppid = os.getppid()
# 2. Connect to Discord
ipc_path = get_discord_path()
if not ipc_path: return
try:
ds = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
ds.connect(ipc_path)
except: return
# 3. Handshake
send_packet(ds, 0, {"v": 1, "client_id": CLIENT_ID})
recv_packet(ds) # Mandatory read
time.sleep(0.5)
set_activity(ds, ppid, "Idling", "Monochrome")
# 4. Minimal WebSocket Client
ws = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
ws.settimeout(1.0)
try:
ws.connect(('127.0.0.1', int(config['nlPort'])))
except: return
key = base64.b64encode(os.urandom(16)).decode()
handshake = (
f"GET /?extensionId={config['nlExtensionId']}&connectToken={config['nlConnectToken']} HTTP/1.1\r\n"
f"Host: 127.0.0.1:{config['nlPort']}\r\n"
"Upgrade: websocket\r\n"
"Connection: Upgrade\r\n"
f"Sec-WebSocket-Key: {key}\r\n"
"Sec-WebSocket-Version: 13\r\n\r\n"
)
ws.sendall(handshake.encode())
# Skip HTTP response header
resp = b""
while b"\r\n\r\n" not in resp:
try:
chunk = ws.recv(1024)
if not chunk: break
resp += chunk
except socket.timeout: continue
# 5. Loop
while True:
# Watchdog
try:
os.kill(ppid, 0)
except OSError: break
try:
head = ws.recv(2)
if not head: break
length = head[1] & 127
if length == 126: length = struct.unpack(">H", ws.recv(2))[0]
elif length == 127: length = struct.unpack(">Q", ws.recv(8))[0]
data = b""
while len(data) < length:
data += ws.recv(length - len(data))
msg = json.loads(data.decode('utf-8'))
if msg['event'] == 'discord:update':
d = msg['data']
set_activity(ds, ppid, d.get('details'), d.get('state'), d.get('largeImageKey'))
elif msg['event'] == 'discord:clear':
set_activity(ds, ppid, "Idling", "Monochrome")
elif msg['event'] == 'windowClose':
break
except socket.timeout: continue
except: continue
# Cleanup
try:
send_packet(ds, 1, {
"cmd": "SET_ACTIVITY",
"args": {"pid": ppid, "activity": None},
"nonce": str(uuid.uuid4())
})
time.sleep(0.1)
ds.close()
except: pass
if __name__ == "__main__":
main()

View file

@ -2,6 +2,8 @@
import { auth, provider } from './config.js';
import {
signInWithPopup,
signInWithRedirect,
getRedirectResult,
signOut as firebaseSignOut,
onAuthStateChanged,
signInWithEmailAndPassword,
@ -26,6 +28,12 @@ export class AuthManager {
this.authListeners.forEach((listener) => listener(user));
});
// Handle redirect result (for Linux/Mobile where popup might be blocked)
getRedirectResult(auth).catch((error) => {
console.error('Redirect Login failed:', error);
alert(`Login failed: ${error.message}`);
});
}
onAuthStateChanged(callback) {
@ -43,11 +51,29 @@ export class AuthManager {
}
try {
// Check for Linux environment (Neutralino) where popups are often blocked
if (window.NL_OS === 'Linux') {
await signInWithRedirect(auth, provider);
// The page will redirect, so no return value needed immediately
return;
}
const result = await signInWithPopup(auth, provider);
// The onAuthStateChanged listener will handle the rest
return result.user;
} catch (error) {
console.error('Login failed:', error);
if (error.code === 'auth/popup-blocked') {
console.log('Popup blocked, falling back to redirect...');
try {
await signInWithRedirect(auth, provider);
return;
} catch (redirectError) {
console.error('Redirect fallback failed:', redirectError);
alert(`Login failed: ${redirectError.message}`);
throw redirectError;
}
}
alert(`Login failed: ${error.message}`);
throw error;
}

View file

@ -868,7 +868,6 @@ export class LosslessAPI {
const seenTrackIds = new Set(tracks.map((t) => t.id));
const artistsToProcess = artists.slice(0, Math.min(5, artists.length));
console.log(`Processing ${artistsToProcess.length} artists for recommendations`);
const artistPromises = artistsToProcess.map(async (artist) => {
try {
@ -876,8 +875,6 @@ export class LosslessAPI {
const artistData = await this.getArtist(artist.id, { lightweight: true });
if (artistData && artistData.tracks && artistData.tracks.length > 0) {
const newTracks = artistData.tracks.filter((track) => !seenTrackIds.has(track.id)).slice(0, 4);
console.log(`Found ${newTracks.length} new tracks from ${artist.name}`);
return newTracks;
} else {
console.warn(`No tracks found for artist ${artist.name}`);
@ -897,8 +894,6 @@ export class LosslessAPI {
}
});
console.log(`Total recommended tracks found: ${recommendedTracks.length}`);
const shuffled = recommendedTracks.sort(() => 0.5 - Math.random());
return shuffled.slice(0, limit);
}

View file

@ -13,8 +13,15 @@ import { sidePanelManager } from './side-panel.js';
import { db } from './db.js';
import { syncManager } from './accounts/pocketbase.js';
import { registerSW } from 'virtual:pwa-register';
import { initializeDiscordRPC } from './discord-rpc.js';
import * as Neutralino from '@neutralinojs/lib';
import './smooth-scrolling.js';
// Assign Neutralino to window for global access
if (typeof window !== 'undefined') {
window.Neutralino = Neutralino;
}
// Lazy-loaded modules
let settingsModule = null;
let downloadsModule = null;
@ -378,6 +385,24 @@ document.addEventListener('DOMContentLoaded', async () => {
const { initTracker } = await loadTrackerModule();
initTracker(player);
// Initialize desktop environment (Neutralino)
if (window.Neutralino) {
console.log('Initializing Neutralino desktop environment (Lite Mode)...');
try {
Neutralino.init();
// Register events immediately
Neutralino.events.on('windowClose', () => {
Neutralino.app.exit();
});
// Start RPC immediately after init
initializeDiscordRPC(player);
} catch (error) {
console.error('Failed to initialize desktop environment:', error);
}
}
const castBtn = document.getElementById('cast-btn');
initializeCasting(audioPlayer, castBtn);
@ -497,6 +522,9 @@ document.addEventListener('DOMContentLoaded', async () => {
// Update UI with current track info for theme
ui.setCurrentTrack(player.currentTrack);
// Update Media Session with new track
player.updateMediaSession(player.currentTrack);
const currentTrackId = player.currentTrack.id;
if (currentTrackId === previousTrackId) return;
previousTrackId = currentTrackId;

View file

@ -384,8 +384,6 @@ export class MusicDatabase {
});
}
console.log(`${storeName}: Adding item with ID ${item.id || item.uuid || item.timestamp}`);
// Critical: Ensure key exists for IndexedDB store.put()
const keyPath = store.keyPath;
if (keyPath && !item[keyPath]) {

87
js/discord-rpc.js Normal file
View file

@ -0,0 +1,87 @@
import { getTrackTitle, getTrackArtists } from './utils.js';
export function initializeDiscordRPC(player) {
console.log('[DiscordRPC] Initializing...');
const EXTENSION_ID = 'js.neutralino.discordrpc';
function sendUpdate(track, isPaused = false) {
if (!track) return;
let coverUrl = 'monochrome';
if (track.album?.cover) {
const coverId = track.album.cover.replace(/-/g, '/');
coverUrl = `https://resources.tidal.com/images/${coverId}/320x320.jpg`;
}
const data = {
details: getTrackTitle(track),
state: getTrackArtists(track),
largeImageKey: coverUrl,
largeImageText: track.album?.title || 'Monochrome',
smallImageKey: isPaused ? 'pause' : 'play',
smallImageText: isPaused ? 'Paused' : 'Playing',
instance: false,
};
if (!isPaused && track.duration) {
const now = Date.now();
const elapsed = player.audio.currentTime * 1000;
data.startTimestamp = Math.floor((now - elapsed) / 1000);
}
console.log('[DiscordRPC] Dispatching to', EXTENSION_ID, data);
Neutralino.events.broadcast('discord:update', data).catch(e => console.error('Broadcast failed', e));
Neutralino.extensions.dispatch(EXTENSION_ID, 'discord:update', data).catch(e => console.error('Dispatch failed', e));
}
// Heartbeat & Debug Ping
setInterval(() => {
if (player.currentTrack) {
sendUpdate(player.currentTrack, player.audio.paused);
} else {
const idlingData = {
details: 'Idling',
state: 'Monochrome',
largeImageKey: 'monochrome',
largeImageText: 'Monochrome',
smallImageKey: 'pause',
smallImageText: 'Paused'
};
Neutralino.events.broadcast('discord:update', idlingData).catch(() => { });
Neutralino.extensions.dispatch(EXTENSION_ID, 'discord:update', idlingData).catch(() => { });
}
}, 5000);
function sendClear() {
Neutralino.events.broadcast('discord:clear', {}).catch(() => { });
}
player.audio.addEventListener('play', () => {
sendUpdate(player.currentTrack);
});
player.audio.addEventListener('pause', () => {
sendUpdate(player.currentTrack, true);
});
player.audio.addEventListener('loadedmetadata', () => {
if (!player.audio.paused) {
sendUpdate(player.currentTrack);
}
});
// Send initial status
if (player.currentTrack) {
sendUpdate(player.currentTrack, player.audio.paused);
} else {
Neutralino.events.broadcast('discord:update', {
details: 'Idling',
state: 'Monochrome',
largeImageKey: 'monochrome',
largeImageText: 'Monochrome',
smallImageKey: 'pause',
smallImageText: 'Paused'
}).catch(() => { });
}
}

View file

@ -402,6 +402,7 @@ export class Player {
this.updatePlayingTrackIndicator();
this.updateMediaSession(track);
this.updateMediaSessionPlaybackState();
this.updateNativeWindow(track);
try {
let streamUrl;
@ -1038,4 +1039,16 @@ export class Player {
updateBtn(timerBtn);
updateBtn(timerBtnDesktop);
}
async updateNativeWindow(track) {
if (!window.Neutralino) return;
const trackTitle = getTrackTitle(track);
const artist = getTrackArtists(track);
try {
await Neutralino.window.setTitle(`${trackTitle}${artist}`);
} catch (e) {
console.error('Failed to set window title:', e);
}
}
}

53
neutralino.config.json Normal file
View file

@ -0,0 +1,53 @@
{
"applicationId": "com.monochrome.app",
"applicationName": "Monochrome",
"applicationIcon": "public/assets/512.png",
"author": "Monochrome",
"description": "Lossless music streaming",
"version": "1.0.0",
"defaultMode": "window",
"documentRoot": "www/",
"url": "https://monochrome.tf",
"enableServer": true,
"enableNativeAPI": true,
"enableExtensions": true,
"tokenSecurity": "one-time",
"modes": {
"window": {
"title": "Monochrome",
"icon": "public/assets/512.png",
"width": 1280,
"height": 800,
"minWidth": 800,
"minHeight": 600,
"center": true,
"resizable": true,
"hidden": false,
"borderless": false,
"enableInspector": true,
"openInspectorOnStartup": false,
"exitProcessOnClose": true
}
},
"port": 5050,
"cli": {
"binaryName": "Monochrome",
"resourcesPath": "www/",
"binaryVersion": "6.5.0",
"clientVersion": "6.5.0"
},
"extensions": [
{
"id": "js.neutralino.discordrpc",
"commandLinux": "python3 \"${NL_PATH}/extensions/js.neutralino.discordrpc/bridge.py\"",
"commandMac": "python3 \"${NL_PATH}/extensions/js.neutralino.discordrpc/bridge.py\"",
"commandWindows": "powershell.exe -ExecutionPolicy Bypass -File \"${NL_PATH}/extensions/js.neutralino.discordrpc/bridge.ps1\""
}
],
"nativeAllowList": [
"app.exit",
"window.*",
"extensions.*",
"events.*"
]
}

910
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -6,7 +6,7 @@
"main": "sw.js",
"scripts": {
"dev": "vite",
"build": "vite build",
"build": "vite build && npx neu build",
"preview": "vite preview",
"start": "vite preview",
"lint:js": "eslint .",
@ -28,6 +28,7 @@
},
"homepage": "https://github.com/SamidyFR/monochrome#readme",
"devDependencies": {
"@neutralinojs/neu": "^11.7.0",
"eslint": "^9.39.2",
"eslint-config-prettier": "^10.1.8",
"globals": "^17.0.0",
@ -37,6 +38,7 @@
"stylelint-config-standard": "^39.0.1",
"stylelint-config-standard-scss": "^16.0.0",
"vite": "^7.3.0",
"vite-plugin-neutralino": "^1.0.3",
"vite-plugin-pwa": "^1.2.0"
},
"overrides": {
@ -44,6 +46,7 @@
"source-map": "^0.7.4"
},
"dependencies": {
"@neutralinojs/lib": "^6.5.0",
"cookie-session": "^2.1.0",
"jose": "^6.0.11",
"butterchurn": "^2.6.7",
@ -51,4 +54,4 @@
"dashjs": "^5.1.1",
"pocketbase": "^0.26.5"
}
}
}

View file

@ -1,14 +1,16 @@
import { defineConfig } from 'vite';
import { VitePWA } from 'vite-plugin-pwa';
import neutralino from 'vite-plugin-neutralino';
import authGatePlugin from './vite-plugin-auth-gate.js';
export default defineConfig({
base: './',
build: {
outDir: 'dist',
outDir: 'www',
emptyOutDir: true,
},
plugins: [
neutralino(),
authGatePlugin(),
VitePWA({
registerType: 'prompt',