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
|
||||
.env
|
||||
|
||||
# Neutralino
|
||||
.tmp/
|
||||
bin/
|
||||
*.log
|
||||
.storage/
|
||||
auth_storage/
|
||||
|
||||
www
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
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 {
|
||||
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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
28
js/app.js
28
js/app.js
|
|
@ -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;
|
||||
|
|
|
|||
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()
|
||||
const keyPath = store.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.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
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",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
Loading…
Reference in a new issue