Merge branch 'neutralino' into main-samidy
This commit is contained in:
commit
731c7a7a0b
15 changed files with 1462 additions and 13 deletions
96
.github/workflows/desktop-build.yml
vendored
Normal file
96
.github/workflows/desktop-build.yml
vendored
Normal 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
9
.gitignore
vendored
|
|
@ -6,3 +6,12 @@ dist
|
||||||
|
|
||||||
# Docker
|
# Docker
|
||||||
.env
|
.env
|
||||||
|
|
||||||
|
# Neutralino
|
||||||
|
.tmp/
|
||||||
|
bin/
|
||||||
|
*.log
|
||||||
|
.storage/
|
||||||
|
auth_storage/
|
||||||
|
|
||||||
|
www
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import prettierConfig from 'eslint-config-prettier';
|
||||||
|
|
||||||
export default [
|
export default [
|
||||||
{
|
{
|
||||||
ignores: ['dist/', 'node_modules/', 'legacy/', 'sw.js'],
|
ignores: ['**/dist/**', '**/node_modules/**', '**/legacy/**', '**/bin/**'],
|
||||||
},
|
},
|
||||||
js.configs.recommended,
|
js.configs.recommended,
|
||||||
prettierConfig,
|
prettierConfig,
|
||||||
|
|
|
||||||
91
extensions/js.neutralino.discordrpc/bridge.ps1
Normal file
91
extensions/js.neutralino.discordrpc/bridge.ps1
Normal 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 {}
|
||||||
|
}
|
||||||
|
}
|
||||||
142
extensions/js.neutralino.discordrpc/bridge.py
Normal file
142
extensions/js.neutralino.discordrpc/bridge.py
Normal 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()
|
||||||
|
|
@ -2,6 +2,8 @@
|
||||||
import { auth, provider } from './config.js';
|
import { auth, provider } from './config.js';
|
||||||
import {
|
import {
|
||||||
signInWithPopup,
|
signInWithPopup,
|
||||||
|
signInWithRedirect,
|
||||||
|
getRedirectResult,
|
||||||
signOut as firebaseSignOut,
|
signOut as firebaseSignOut,
|
||||||
onAuthStateChanged,
|
onAuthStateChanged,
|
||||||
signInWithEmailAndPassword,
|
signInWithEmailAndPassword,
|
||||||
|
|
@ -26,6 +28,12 @@ export class AuthManager {
|
||||||
|
|
||||||
this.authListeners.forEach((listener) => listener(user));
|
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) {
|
onAuthStateChanged(callback) {
|
||||||
|
|
@ -43,11 +51,29 @@ export class AuthManager {
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
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);
|
const result = await signInWithPopup(auth, provider);
|
||||||
// The onAuthStateChanged listener will handle the rest
|
// The onAuthStateChanged listener will handle the rest
|
||||||
return result.user;
|
return result.user;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Login failed:', 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}`);
|
alert(`Login failed: ${error.message}`);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -868,7 +868,6 @@ export class LosslessAPI {
|
||||||
const seenTrackIds = new Set(tracks.map((t) => t.id));
|
const seenTrackIds = new Set(tracks.map((t) => t.id));
|
||||||
|
|
||||||
const artistsToProcess = artists.slice(0, Math.min(5, artists.length));
|
const artistsToProcess = artists.slice(0, Math.min(5, artists.length));
|
||||||
console.log(`Processing ${artistsToProcess.length} artists for recommendations`);
|
|
||||||
|
|
||||||
const artistPromises = artistsToProcess.map(async (artist) => {
|
const artistPromises = artistsToProcess.map(async (artist) => {
|
||||||
try {
|
try {
|
||||||
|
|
@ -876,8 +875,6 @@ export class LosslessAPI {
|
||||||
const artistData = await this.getArtist(artist.id, { lightweight: true });
|
const artistData = await this.getArtist(artist.id, { lightweight: true });
|
||||||
if (artistData && artistData.tracks && artistData.tracks.length > 0) {
|
if (artistData && artistData.tracks && artistData.tracks.length > 0) {
|
||||||
const newTracks = artistData.tracks.filter((track) => !seenTrackIds.has(track.id)).slice(0, 4);
|
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;
|
return newTracks;
|
||||||
} else {
|
} else {
|
||||||
console.warn(`No tracks found for artist ${artist.name}`);
|
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());
|
const shuffled = recommendedTracks.sort(() => 0.5 - Math.random());
|
||||||
return shuffled.slice(0, limit);
|
return shuffled.slice(0, limit);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
28
js/app.js
28
js/app.js
|
|
@ -13,8 +13,15 @@ import { sidePanelManager } from './side-panel.js';
|
||||||
import { db } from './db.js';
|
import { db } from './db.js';
|
||||||
import { syncManager } from './accounts/pocketbase.js';
|
import { syncManager } from './accounts/pocketbase.js';
|
||||||
import { registerSW } from 'virtual:pwa-register';
|
import { registerSW } from 'virtual:pwa-register';
|
||||||
|
import { initializeDiscordRPC } from './discord-rpc.js';
|
||||||
|
import * as Neutralino from '@neutralinojs/lib';
|
||||||
import './smooth-scrolling.js';
|
import './smooth-scrolling.js';
|
||||||
|
|
||||||
|
// Assign Neutralino to window for global access
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
window.Neutralino = Neutralino;
|
||||||
|
}
|
||||||
|
|
||||||
// Lazy-loaded modules
|
// Lazy-loaded modules
|
||||||
let settingsModule = null;
|
let settingsModule = null;
|
||||||
let downloadsModule = null;
|
let downloadsModule = null;
|
||||||
|
|
@ -378,6 +385,24 @@ document.addEventListener('DOMContentLoaded', async () => {
|
||||||
const { initTracker } = await loadTrackerModule();
|
const { initTracker } = await loadTrackerModule();
|
||||||
initTracker(player);
|
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');
|
const castBtn = document.getElementById('cast-btn');
|
||||||
initializeCasting(audioPlayer, castBtn);
|
initializeCasting(audioPlayer, castBtn);
|
||||||
|
|
||||||
|
|
@ -497,6 +522,9 @@ document.addEventListener('DOMContentLoaded', async () => {
|
||||||
// Update UI with current track info for theme
|
// Update UI with current track info for theme
|
||||||
ui.setCurrentTrack(player.currentTrack);
|
ui.setCurrentTrack(player.currentTrack);
|
||||||
|
|
||||||
|
// Update Media Session with new track
|
||||||
|
player.updateMediaSession(player.currentTrack);
|
||||||
|
|
||||||
const currentTrackId = player.currentTrack.id;
|
const currentTrackId = player.currentTrack.id;
|
||||||
if (currentTrackId === previousTrackId) return;
|
if (currentTrackId === previousTrackId) return;
|
||||||
previousTrackId = currentTrackId;
|
previousTrackId = currentTrackId;
|
||||||
|
|
|
||||||
2
js/db.js
2
js/db.js
|
|
@ -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()
|
// Critical: Ensure key exists for IndexedDB store.put()
|
||||||
const keyPath = store.keyPath;
|
const keyPath = store.keyPath;
|
||||||
if (keyPath && !item[keyPath]) {
|
if (keyPath && !item[keyPath]) {
|
||||||
|
|
|
||||||
87
js/discord-rpc.js
Normal file
87
js/discord-rpc.js
Normal 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(() => { });
|
||||||
|
}
|
||||||
|
}
|
||||||
13
js/player.js
13
js/player.js
|
|
@ -402,6 +402,7 @@ export class Player {
|
||||||
this.updatePlayingTrackIndicator();
|
this.updatePlayingTrackIndicator();
|
||||||
this.updateMediaSession(track);
|
this.updateMediaSession(track);
|
||||||
this.updateMediaSessionPlaybackState();
|
this.updateMediaSessionPlaybackState();
|
||||||
|
this.updateNativeWindow(track);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
let streamUrl;
|
let streamUrl;
|
||||||
|
|
@ -1038,4 +1039,16 @@ export class Player {
|
||||||
updateBtn(timerBtn);
|
updateBtn(timerBtn);
|
||||||
updateBtn(timerBtnDesktop);
|
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
53
neutralino.config.json
Normal 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
910
package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -6,7 +6,7 @@
|
||||||
"main": "sw.js",
|
"main": "sw.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "vite build",
|
"build": "vite build && npx neu build",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"start": "vite preview",
|
"start": "vite preview",
|
||||||
"lint:js": "eslint .",
|
"lint:js": "eslint .",
|
||||||
|
|
@ -28,6 +28,7 @@
|
||||||
},
|
},
|
||||||
"homepage": "https://github.com/SamidyFR/monochrome#readme",
|
"homepage": "https://github.com/SamidyFR/monochrome#readme",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@neutralinojs/neu": "^11.7.0",
|
||||||
"eslint": "^9.39.2",
|
"eslint": "^9.39.2",
|
||||||
"eslint-config-prettier": "^10.1.8",
|
"eslint-config-prettier": "^10.1.8",
|
||||||
"globals": "^17.0.0",
|
"globals": "^17.0.0",
|
||||||
|
|
@ -37,6 +38,7 @@
|
||||||
"stylelint-config-standard": "^39.0.1",
|
"stylelint-config-standard": "^39.0.1",
|
||||||
"stylelint-config-standard-scss": "^16.0.0",
|
"stylelint-config-standard-scss": "^16.0.0",
|
||||||
"vite": "^7.3.0",
|
"vite": "^7.3.0",
|
||||||
|
"vite-plugin-neutralino": "^1.0.3",
|
||||||
"vite-plugin-pwa": "^1.2.0"
|
"vite-plugin-pwa": "^1.2.0"
|
||||||
},
|
},
|
||||||
"overrides": {
|
"overrides": {
|
||||||
|
|
@ -44,6 +46,7 @@
|
||||||
"source-map": "^0.7.4"
|
"source-map": "^0.7.4"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@neutralinojs/lib": "^6.5.0",
|
||||||
"cookie-session": "^2.1.0",
|
"cookie-session": "^2.1.0",
|
||||||
"jose": "^6.0.11",
|
"jose": "^6.0.11",
|
||||||
"butterchurn": "^2.6.7",
|
"butterchurn": "^2.6.7",
|
||||||
|
|
@ -51,4 +54,4 @@
|
||||||
"dashjs": "^5.1.1",
|
"dashjs": "^5.1.1",
|
||||||
"pocketbase": "^0.26.5"
|
"pocketbase": "^0.26.5"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1,14 +1,16 @@
|
||||||
import { defineConfig } from 'vite';
|
import { defineConfig } from 'vite';
|
||||||
import { VitePWA } from 'vite-plugin-pwa';
|
import { VitePWA } from 'vite-plugin-pwa';
|
||||||
|
import neutralino from 'vite-plugin-neutralino';
|
||||||
import authGatePlugin from './vite-plugin-auth-gate.js';
|
import authGatePlugin from './vite-plugin-auth-gate.js';
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
base: './',
|
base: './',
|
||||||
build: {
|
build: {
|
||||||
outDir: 'dist',
|
outDir: 'www',
|
||||||
emptyOutDir: true,
|
emptyOutDir: true,
|
||||||
},
|
},
|
||||||
plugins: [
|
plugins: [
|
||||||
|
neutralino(),
|
||||||
authGatePlugin(),
|
authGatePlugin(),
|
||||||
VitePWA({
|
VitePWA({
|
||||||
registerType: 'prompt',
|
registerType: 'prompt',
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue