- BRIDGE-SIGNALS: yeni "Transitions & Discord" slot bolumu (setCrossfade/setGapless/ setDiscordRpc/setDiscordClientId), getState semasina crossfade/gapless/discord*/ discordAvail; YANLIS "setLanguage yok" notu duzeltildi (v1.1.0'da eklendi) - ARCHITECTURE: 6b bolumu — siradaki parcayi onden coz, callback'te es-guclu harman/ gapless terfi, reload'suz UI guncelleme, Discord RPC tasarimi
17 KiB
Bridge Signal / Slot Catalog (QWebChannel)
Reference for the QWebChannel contract between the Python Bridge
(justmusic/bridge.py) and the web UI (justmusic/web/app.js).
- Signals travel Python → JS. Each is a
pyqtSignalclass attribute onBridge, wired to a JS handler inwireSignals()(app.js:710). - Slots are called JS → Python as
bridge.<method>(...). Each is a@pyqtSlot(...)method onBridge. - JSON-carrying signals/slots pass a string; the schemas below come from the Python builder functions.
Argument types in this doc are the declared
pyqtSignal/pyqtSlotsignatures. QWebChannel marshals Pythondict/listonly when serialized to a JSON string first — every "JSON" payload here is astron the wire.
1. Signals (Python → JS)
| Signal | Args (declared) | Payload / meaning | JS handler in wireSignals() |
|---|---|---|---|
stateChanged |
() |
No payload. "State is dirty, re-pull." Handler calls bridge.getState(...). |
refreshState |
trackChanged |
(str) |
JSON track object (schema below), or {"none": true} when nothing is loaded. Fires on every now-playing change and same-song metadata updates. |
j => track = JSON.parse(j); onTrackChanged() |
playingChanged |
(bool) |
true/false play state. Only emitted when it flips (_last_playing guard). |
p => active.playing=p; updatePlayBtn(); if(clip) clipMirrorPlay(p) |
positionChanged |
(float, float) |
(pos_sec, dur_sec). The single clock — drives the seek bar and, in clip mode, the muted video + LRC sync. |
(pos,dur) => updateSeek(pos,dur); if(clip){syncClipVideo(pos); clipSyncLyrics(pos)} |
spectrumSignal |
(str) |
JSON array of ~56 floats (rounded to 3 dp), the FFT bars. Emitted every poll (60 ms). | j => spectrum = JSON.parse(j) |
coverReadySignal |
(str, str) |
(song_id, cover_url). URL form: app://app/cover/<md5(song_id)>.jpg. |
(id,url) => updateCovers(id,url) |
toastSignal |
(str) |
Plain message string for the toast. | showToast |
downloadProgressSignal |
(float, str, int) |
(pct, status, queue_remaining). status carries the batch counter "X/Y" (see notes). (0, "", 0) means the queue drained. |
(pct,status,q) => updateDlPill(pct,status,q) |
downloadDoneSignal |
(str) |
JSON of the finished song = _song_view() shape. |
() => {} (no-op; UI refreshes via stateChanged) |
searchResultsSignal |
(str) |
JSON array of search rows (schema below), or {"error": "<msg>"}. Used by both searchYouTube and importPlaylist. |
onSearchResults |
scanDoneSignal |
(int, int) |
(songs_added, new_playlists) from a music-folder scan. |
(none — not connected; see note) |
durationSignal |
(str, int) |
(song_id, duration_ms) — late-probed track length. |
onDuration |
queueChanged |
(str) |
JSON array of up-next entries = _song_view() + playlist field; "[]" when cleared. |
onQueue |
videoReadySignal |
(str) |
JSON clip stream object (schema below). | onVideoReady |
analysisSignal |
(str) |
JSON song-DNA object (schema below). | onAnalysis |
loopSignal |
(float, float) |
(A_sec, B_sec) A-B loop points. -1 in a slot = unset (e.g. (-1,-1) clears, (A,-1) = only A set). |
onLoop |
updateAvailableSignal |
(str, str) |
(version, notes) — a newer release exists. |
(v,notes) => updateInfo={version:v,notes:notes} |
updateProgressSignal |
(float) |
Download percent of the staged .msi. |
p => onUpdateProgress(p) |
updateReadySignal |
(str) |
version — update downloaded, installs on exit (or via installUpdateNow). |
v => onUpdateReady(v) |
separationProgressSignal |
(float, str) |
(pct, status) for htdemucs vocal separation. (100.0, "Hazır") = done, (100.0, "") = failed. |
(pct,status) => updateSepPill(pct,status) |
karaokeModeSignal |
(str) |
Current mode: off / quick / instrumental / vocals. |
m => onKaraokeMode(m) |
Note on
scanDoneSignal: it is declared and emitted (_on_scan_done), but is not connected insidewireSignals(); scan results reach the UI mainly through thetoastSignal+stateChangedthat fire alongside it.
JSON payload schemas
trackChanged — _track_json() (built from _song_view() + extra fields):
{
"id": "...", "title": "...", "artist": "...",
"favorite": false,
"duration_ms": 0, "play_count": 0,
"source": "local", // or "youtube"
"cover": "app://app/cover/<md5>.jpg",
"hasCover": true, // thumbnail file exists on disk
"mood": "", "bpm": 0,
"playlist": "<active playlist>",
"lyrics": "", // may be LRC ([mm:ss] timestamped)
"shuffle": false,
"repeat": false, // == (repeat_mode == 2)
"repeat_mode": 1 // 0=off, 1=list, 2=one
}
// when nothing is loaded:
{ "none": true }
stateChanged → getState() (JS pulls it via the getState slot; full app state):
{
"playlists": { "<name>": [ /* _song_view objects */ ] },
"protected": ["<protected playlist names>"],
"version": "<APP_VERSION>",
"current": "<current playlist>",
"settings": { /* library.settings: theme, accent, lang, volume, speed,
eq_gains, eq_enabled, eq_preset, effects, normalize,
soundscape, crossfade, gapless, discord_rpc, discord_client_id,
recent, playlist_covers, playlist_desc, ... */ },
"presets": { "<preset name>": [10 EQ gains] },
"presetNames": ["<ordered preset names>"],
"bandLabels": ["<10 EQ band labels>"],
"effectDefaults": { "<effect>": <default> },
"effectDefsOrder": ["<effect keys in order>"],
"active": { "playlist": "...", "song_id": "...", "playing": false,
"shuffle": false, "repeat": false, "repeat_mode": 1 },
"stats": { "total_seconds": 0, ... },
"recent": [ /* _song_view objects, each with an added "playlist" field */ ],
"favoritesName": "<favorites playlist name>",
"demucs": false, // studio-karaoke (htdemucs) engine available?
"karaokeMode": "off",
"discordAvail": false // pypresence importable? (Discord RPC available) — v1.3.0
}
_song_view() (the per-song shape reused in playlists, recent,
queueChanged, downloadDoneSignal):
{
"id": "...", "title": "...", "artist": "...",
"favorite": false, "duration_ms": 0, "play_count": 0,
"source": "local", "cover": "app://app/cover/<md5>.jpg",
"hasCover": true, "mood": "", "bpm": 0
}
(queueChanged and recent entries additionally carry a "playlist" field.)
searchResultsSignal — search rows (SearchThread.run, also importPlaylist):
[
{ "id": "<videoId>", "title": "...", "uploader": "...",
"duration_ms": 0,
"thumbnail": "https://i.ytimg.com/vi/<id>/hqdefault.jpg",
"url": "https://www.youtube.com/watch?v=<id>" }
]
// on failure:
{ "error": "<message>" }
videoReadySignal — clip stream (VideoStreamThread + _on_video_ready):
{
"id": "<song_id>",
"video": "<webm VP9 video-only stream URL>",
"audio": "<opus audio-only stream URL>", // may be empty
"title": "...", "duration": 0,
"start": 12.34, // seconds; handoff position injected by _on_video_ready
"muted": true // ALWAYS true — audio comes from the DSP engine, not the video
}
analysisSignal — song DNA (AnalyzeThread):
{
"id": "<song_id>",
"waveform": [ /* 480 peak floats */ ],
"mood": "<label>", "color": "<hex>",
"bpm": 0, "gain": 1.0, // gain feeds loudness normalize
"spectrogram": [ /* rows */ ]
}
2. Slots (JS → Python)
Called as bridge.<name>(args). Slots that return a value (result=str)
are invoked with a callback in JS: bridge.getState(json => {...}).
Playback / transport
| Slot | Args | Purpose |
|---|---|---|
play |
(song_id: str, playlist: str) |
Play a specific song from a playlist. |
toggle |
() |
Play/pause; if nothing loaded, starts the current playlist. |
playPause |
() |
Alias for toggle. |
next |
() |
Next track (honors shuffle). |
prev |
() |
Previous track (or restart if >3 s in). |
seek |
(seconds: float) |
Seek the engine to an absolute position. |
setShuffle |
(on: bool) |
Toggle shuffle. |
setRepeat |
(on: bool) |
Toggle repeat-one (legacy boolean). |
setRepeatMode |
(mode: int) |
Cycle repeat: 0=off, 1=list, 2=one (mode % 3). |
setVolume |
(v: float) |
Base volume 0–150 (persisted when >0). |
setSpeed |
(r: float) |
Playback speed (0.5–2.0). |
setNormalize |
(on: bool) |
Loudness normalize on/off. |
setLoopA |
() |
Set A-B loop start = current position. |
setLoopB |
() |
Set A-B loop end = current position. |
clearLoop |
() |
Clear the A-B loop. |
playPlaylistShuffled |
(playlist: str, _unused: str="") |
Turn on shuffle and start the playlist at a random track. |
Queue (up-next)
| Slot | Args | Purpose |
|---|---|---|
addToQueue |
(sid: str, playlist: str) |
Append a track to the user queue. |
playNext |
(sid: str, playlist: str) |
Insert a track at the front of the queue. |
addAllToQueue |
(playlist: str) |
Queue an entire playlist. |
startRadio |
(sid: str, playlist: str) |
Queue ~20 mood-similar tracks after the seed. |
getQueue |
() → str |
JSON of the queue (_song_view + playlist). Callback. |
clearQueue |
() |
Empty the queue. |
removeFromQueue |
(index: int) |
Drop the queue item at index. |
playQueueIndex |
(index: int) |
Pop and play the queue item at index. |
Playlists
| Slot | Args | Purpose |
|---|---|---|
selectPlaylist |
(name: str) |
Set the current playlist (no playback change). |
addPlaylist |
(name: str) |
Create a playlist. |
deletePlaylist |
(name: str) |
Delete a playlist. |
renamePlaylist |
(old: str, new: str) |
Rename (protected lists blocked). |
reorderSong |
(playlist: str, frm: int, to: int) |
Drag-reorder within a playlist. |
setPlaylistCover |
(playlist: str, url: str) |
Set a custom cover URL. |
setPlaylistDescription |
(playlist: str, text: str) |
Set the playlist description. |
removeDuplicates |
(playlist: str) |
Drop duplicate file paths. |
exportM3U |
(playlist: str) |
Save playlist as .m3u (native file dialog). |
Song actions
| Slot | Args | Purpose |
|---|---|---|
toggleFavorite |
(song_id: str, playlist: str) |
Toggle favorite flag. |
removeSong |
(song_id: str, playlist: str) |
Remove a song from a playlist. |
copyToPlaylist |
(song_id: str, playlist: str, target: str) |
Copy a song into another playlist. |
editMetadata |
(song_id: str, playlist: str, title: str, artist: str) |
Edit title/artist. |
openFileLocation |
(sid: str, playlist: str) |
Reveal the file in Explorer. |
exportTrim |
(sid: str, playlist: str, a: float, b: float) |
Export the A–B segment to MP3 (ffmpeg). |
Search / download
| Slot | Args | Purpose |
|---|---|---|
searchYouTube |
(query: str) |
YouTube search (20 rows) → searchResultsSignal. |
importPlaylist |
(url: str) |
Resolve a YouTube playlist URL into preview-and-select rows → searchResultsSignal. |
downloadUrls |
(urls_json: str) |
Enqueue one or many URLs (JSON array, or a single string) for download; grows the "X/Y" batch counter. |
Effects / EQ
| Slot | Args | Purpose |
|---|---|---|
setEq |
(gains_json: str) |
Set 10-band EQ gains (JSON array). |
setEqPreset |
(name: str) |
Persist selected preset name. |
setEqEnabled |
(on: bool) |
Enable/disable EQ. |
autoEq |
() → str |
Auto-compute EQ from current audio; returns JSON gains. Callback. |
setEffect |
(name: str, val: float) |
Set one effect (preamp/bass/echo/reverb/spatial/…). |
setSoundscape |
(kind: str, level: float) |
Ambient soundscape (rain/white/brown/off). |
Karaoke / vocal separation
| Slot | Args | Purpose |
|---|---|---|
setKaraoke |
(mode: str) |
off / quick (instant mid-side) / instrumental / vocals (htdemucs stems). Falls back to quick when demucs is unavailable; drives karaokeModeSignal + separationProgressSignal. |
Transitions & Discord (v1.3.0)
| Slot | Args | Purpose |
|---|---|---|
setCrossfade |
(sec: float) |
Crossfade length 0–12s (0 = off). Engine pre-decodes the next sequential track and equal-power blends over the last sec seconds. Persists settings.crossfade. |
setGapless |
(on: bool) |
Seamless boundary join (no gap, no overlap) when a next track is preloaded. Persists settings.gapless. |
setDiscordRpc |
(on: bool) |
Enable/disable Discord Rich Presence. Needs a Client ID first (else a toast). Persists settings.discord_rpc. |
setDiscordClientId |
(cid: str) |
Store the Discord Application ID (settings.discord_client_id); reconnects if RPC is on. |
Engine mechanics: the transition only fires for predictable sequential playback — not when shuffle is on,
user_queueis non-empty, orrepeat_mode == 2(repeat-one). The poll (_maybe_preload_next) callsengine.preload_next(path)whendur - pos ≤ crossfade + 8s; the engine promotes next→current inside the audio callback (_promote_next) and flags it viaconsume_advanced(), which the poll turns into a gapless UI update (_on_crossfade_advanced) with no reload. Default off = byte-for-byte the old behavior. Seedocs/ARCHITECTURE.md.
Clip (music-video) mode
| Slot | Args | Purpose |
|---|---|---|
playVideo |
(sid: str, playlist: str) |
Resolve a muted webm video stream synced to the DSP engine → videoReadySignal. |
resumeMusic |
(pos: float=-1.0) |
Called when the clip closes; ensures the engine is still playing (engine never stopped). JS calls resumeMusic(-1). |
Lyrics
| Slot | Args | Purpose |
|---|---|---|
fetchLyrics |
(sid: str, playlist: str) |
Fetch timestamped (LRC) lyrics from Lyrica; result lands via trackChanged. |
saveLyrics |
(song_id: str, playlist: str, text: str) |
Save manually edited lyrics. |
Import / scan / covers
| Slot | Args | Purpose |
|---|---|---|
importFiles |
() |
Open a file dialog and import audio into the current playlist. |
scanMusic |
() |
Scan the music folder in a background thread → scanDoneSignal. |
syncNamesCovers |
(playlist: str) |
Re-derive title/artist from filenames and re-fetch covers. |
requestCovers |
(playlist: str) |
Fetch missing covers + probe durations for a playlist. |
requestSidebarCovers |
() |
Fetch covers for the first few songs of every playlist. |
Updates (auto-update)
| Slot | Args | Purpose |
|---|---|---|
installUpdateNow |
() |
Install the staged .msi and relaunch immediately. |
State / theme / misc
| Slot | Args | Purpose |
|---|---|---|
getState |
() → str |
Full app state JSON (schema above). Callback. |
setTheme |
(name: str) |
Set the theme (clears custom accent). |
setAccent |
(hex_color: str) |
Set a custom accent color. |
setLanguage |
(code: str) |
Persist UI language (tr/en). Since v1.1.0; display-only translate-by-source via web/i18n.js T() — internal data stays Turkish. |
backup |
() |
Save a JSON library backup (native file dialog). |
i18n note (v1.1.0+): localization is translate-by-source — the UI calls
T(turkishString)fromweb/i18n.js; only display text is translated, internal logic/data (mood labels, protected-playlist keys, status matching) stays Turkish. Add a language by extendingLANGSini18n.js, no Python change needed.
3. Non-obvious contract facts
-
downloadProgressSignal(pct, status, queue_remaining)— thestatusstring now carries "X/Y"._dl_counter()appendsdone/total(e.g."İndiriliyor… 2/5") whenever a batch of more than one URL is queued viadownloadUrls.queue_remainingislen(download_queue)after the current item was popped. When the queue drains,(0, "", 0)is emitted to clear the pill. -
positionChanged(pos, dur)is the single clock. The engine is the sole audio source; in clip mode the muted video is driven off this same signal (syncClipVideo(pos)), and LRC lyric highlighting rides it too (clipSyncLyrics/syncLyrics). There is no second timeline for the video. -
trackChangedfires for same-song metadata updates too. It is emitted not only on song change but also for late-arriving lyrics (_on_lyrics), metadata edits (editMetadata), and name/cover sync (syncNamesCovers) — always for the currently active song.onTrackChanged()must tolerate this: it comparestrack.id === active.song_idand, if the song is unchanged, refreshes clip lyrics instead of tearing down the clip. -
clip/video audio is always muted ("muted": trueforced in_on_video_ready); all DSP effects therefore apply to the clip because the sound still comes from the engine, avoiding double playback. -
loopSignal/ progress sentinels:loopSignaluses-1for "unset" ((-1,-1)= cleared).separationProgressSignaluses(100.0, "Hazır")for success and(100.0, "")for failure. -
Callback-style slots:
getState,getQueue, andautoEqareresult=strand are called with a JS callback (bridge.getState(json => ...)), not synchronously.