-
Notifications
You must be signed in to change notification settings - Fork 0
Plugin Development
- Introduction
- Plugin Structure
- Manifest File
- Plugin API
- Hooks System
- Permissions System
- Development Tools
- Examples
- Best Practices
- Troubleshooting
KORAI Player features a powerful plugin system that allows developers to extend the application's functionality. Plugins can:
- Interact with audio playback
- Access track metadata and library
- Process audio in real-time
- Add custom UI elements
- Persist settings and data
- Listen to playback events
- Node.js 18+ installed
- Basic JavaScript/Node.js knowledge
- Understanding of CommonJS modules
A KORAI plugin is a directory containing at minimum:
my-plugin/
├── manifest.json # Plugin metadata and configuration
├── index.js # Main plugin entry point
├── package.json # Optional: for dependencies
├── ui.css # Optional: custom styles
├── ui.js # Optional: UI component script
└── node_modules/ # Optional: dependencies
The manifest.json file defines your plugin's metadata, permissions, and entry point.
{
"id": "com.example.my-plugin",
"name": "My Awesome Plugin",
"version": "1.0.0",
"entry": "index.js",
"description": "What this plugin does",
"author": "Your Name",
"license": "MIT",
"permissions": ["api:logging", "api:storage", "api:events"],
"hooks": ["onLoad", "onTrackPlay", "onTrackPause"],
"builtin": false,
"enabled": true
}| Field | Type | Required | Description |
|---|---|---|---|
id |
string | Yes | Unique identifier (reverse domain or simple name) |
name |
string | Yes | Display name shown in Plugin Manager |
version |
string | Yes | Semantic version (e.g., "1.0.0") |
entry |
string | Yes | Main JavaScript file path (relative to plugin root) |
description |
string | No | Brief description of plugin functionality |
author |
string | No | Creator name |
license |
string | No | License identifier (e.g., "MIT", "GPL-3.0") |
permissions |
array | No | Required permissions (see Permissions) |
hooks |
array | No | Declared hooks for validation |
builtin |
boolean | No | Whether plugin ships with KORAI (default: false) |
enabled |
boolean | No | Initial enabled state (default: false) |
-
Recommended:
com.yourname.plugin-name(reverse domain) -
Alternative:
yourname/plugin-name -
Simple:
my-plugin
Example IDs:
com.behdad.visualizerkorai/equalizer-protrack-logger
Your plugin receives a context object containing the API interface.
class MyPlugin {
constructor(context) {
this.context = context;
this.api = context.api;
this.id = context.id;
}
async activate(context) {
this.api.log(`${this.context.name} activated!`);
// Initialization code here
}
async deactivate(context) {
this.api.log(`${this.context.name} deactivated`);
// Cleanup code here
}
}
module.exports = MyPlugin;Log a message to the console with plugin prefix.
this.api.log('Hello from my plugin!');
// Output: [my-plugin] Hello from my plugin!Show a desktop notification.
this.api.notify('Track changed!', { duration: 3000 });
// options: { duration: number, type: 'info'|'success'|'error' }Emit custom events that other plugins can listen to.
this.api.emit('my-custom-event', { value: 42 });Register a custom hook at runtime (for dynamic hooks).
this.api.registerHook('onCustomAction');Request additional permissions at runtime. Returns Promise.
const granted = await this.api.requestPermission('network');
if (granted) {
// Make network request
}Retrieve a stored value. Returns Promise.
const settings = await this.api.storage.get('userSettings');Store a value persistently.
await this.api.storage.set('lastTrack', trackId);Read a file within the project (requires project:read permission). Returns Promise<{content: string} | {error: string}>.
const result = await this.api.fs.read('src/config.json');
if (result.content) {
const config = JSON.parse(result.content);
}Write a file within the project (requires project:write permission).
await this.api.fs.write('logs/plugin.log', 'Log entry');Hooks are lifecycle methods that KORAI calls when specific events occur.
| Hook | Parameters | Description |
|---|---|---|
onLoad() |
none | Called when plugin is loaded |
onUnload() |
none | Called when plugin is unloaded |
onActivate() |
none | Called when plugin is activated |
onDeactivate() |
none | Called when plugin is deactivated |
onTrackPlay(track) |
track object |
Called when a track starts playing |
onTrackPause(track) |
track object |
Called when playback pauses |
onTrackStop(track) |
track object |
Called when playback stops |
onTrackSeek(track, position) |
track, position
|
Called when seeking |
onVolumeChange(volume) |
volume (0-1) |
Called when volume changes |
onQueueChange(queue) |
queue array |
Called when playlist queue changes |
onLibraryChange(tracks) |
tracks array |
Called when library is updated |
onAudioProcess(data) |
audio data buffer | Called during audio processing (real-time) |
onBpmDetect(bpm) |
bpm number |
Called when BPM is detected |
onEnergyDetect(energy) |
energy (0-1) |
Called when energy level changes |
class AudioVisualizer {
constructor(context) {
this.context = context;
this.api = context.api;
}
async activate() {
this.api.log('Visualizer ready');
}
async onTrackPlay(track) {
this.api.log(`Now playing: ${track.title} by ${track.artist}`);
this.startVisualization(track.bpm);
}
async onAudioProcess(data) {
// Process audio samples in real-time
const average = this.calculateAverage(data);
this.updateVisualizer(average);
}
async onTrackPause(track) {
this.api.log('Playback paused');
this.stopVisualization();
}
calculateAverage(data) {
// Custom logic
return 0.5;
}
startVisualization(bpm) {
// Visualization logic
}
stopVisualization() {
// Cleanup
}
}
module.exports = AudioVisualizer;When hooks receive a track object, it contains:
{
id: number, // Unique track ID
title: string, // Track title
artist: string, // Artist name
album: string, // Album name
duration: number, // Duration in seconds
bpm: number, // Beats per minute
energy: number, // Energy level (0-1)
genre: string, // Genre
filePath: string, // Full file path
hasCover: boolean, // Whether cover art exists
playCount: number, // Number of plays
likeCount: number, // Number of likes
isLiked: boolean // User liked status
}Plugins must declare required permissions in manifest.json. Users grant permissions during installation.
| Permission | Description |
|---|---|
api:logging |
Write to console/log files |
api:events |
Emit and listen to events |
api:storage |
Read/write persistent storage |
api:notifications |
Show desktop notifications |
api:clipboard |
Access clipboard |
filesystem |
Read/write file system |
network |
Make network requests |
audio |
Process audio data |
preferences |
Modify user preferences |
project:read |
Read project files |
project:write |
Write project files |
project:rw |
Read and write project files |
class NetworkPlugin {
async activate(context) {
const hasNetwork = await this.api.requestPermission('network');
if (!hasNetwork) {
this.api.log('Network permission denied');
return;
}
// Make API call
const response = await fetch('https://api.example.com/data');
const data = await response.json();
this.processData(data);
}
}KORAI provides a CLI tool for plugin development.
npm install -g korai-plugin
# OR use locally:
npx korai-pluginkorai-plugin create my-pluginThis generates:
-
manifest.jsonwith basic configuration -
index.jswith template code -
README.mdwith documentation -
package.jsonfor npm dependencies
korai-plugin validate ./my-pluginChecks:
- Manifest schema compliance
- Required fields presence
- Entry file existence
- Version format
korai-plugin pack ./my-pluginCreates a ZIP file named com.example.my-plugin_v1.0.0.zip ready for installation.
korai-plugin dev ./my-plugin [port]Starts a hot-reload development server (port defaults to 3333).
Logs every played track to storage.
manifest.json
{
"id": "com.example.track-logger",
"name": "Track Logger",
"version": "1.0.0",
"entry": "index.js",
"description": "Logs every track played",
"permissions": ["api:storage", "api:logging"],
"hooks": ["onTrackPlay"]
}index.js
class TrackLogger {
constructor(context) {
this.api = context.api;
this.logs = [];
}
async activate() {
// Load previous logs
const saved = await this.api.storage.get('playLogs');
if (saved) this.logs = saved;
this.api.log(`Loaded ${this.logs.length} previous logs`);
}
async onTrackPlay(track) {
const logEntry = {
trackId: track.id,
title: track.title,
artist: track.artist,
timestamp: Date.now()
};
this.logs.push(logEntry);
await this.api.storage.set('playLogs', this.logs);
this.api.log(`Logged: ${track.title}`);
this.api.notify(`Logged: ${track.title}`, { duration: 2000 });
}
async deactivate() {
await this.api.storage.set('playLogs', this.logs);
this.api.log('TrackLogger saved and deactivated');
}
}
module.exports = TrackLogger;Displays BPM in a custom UI element.
manifest.json
{
"id": "com.example.bpm-display",
"name": "BPM Display",
"version": "1.0.0",
"entry": "index.js",
"permissions": ["api:logging", "api:notifications"],
"hooks": ["onTrackPlay", "onBpmDetect"]
}index.js
class BPMDisplay {
constructor(context) {
this.api = context.api;
this.currentBpm = 120;
}
async activate() {
this.api.log('BPM Display activated');
this.createUI();
}
createUI() {
// Find or create container for BPM display
const container = document.querySelector('.player-center-controls');
if (!container) return;
// Check if element already exists
if (document.getElementById('bpm-display')) return;
const bpmDiv = document.createElement('div');
bpmDiv.id = 'bpm-display';
bpmDiv.style.cssText = `
position: absolute;
bottom: -30px;
left: 50%;
transform: translateX(-50%);
background: rgba(0,0,0,0.7);
padding: 4px 12px;
border-radius: 20px;
font-size: 12px;
font-weight: bold;
color: #00ffd5;
font-family: monospace;
`;
bpmDiv.textContent = `BPM: ${this.currentBpm}`;
container.style.position = 'relative';
container.appendChild(bpmDiv);
this.bpmElement = bpmDiv;
}
async onTrackPlay(track) {
this.currentBpm = track.bpm || 120;
this.updateDisplay();
this.api.log(`Track BPM: ${this.currentBpm}`);
}
async onBpmDetect(bpm) {
this.currentBpm = bpm;
this.updateDisplay();
if (bpm > 150) {
this.api.notify('🔥 High energy track detected!', { duration: 1500 });
}
}
updateDisplay() {
if (this.bpmElement) {
this.bpmElement.textContent = `BPM: ${this.currentBpm}`;
// Change color based on BPM
if (this.currentBpm > 150) {
this.bpmElement.style.color = '#ff6b6b';
} else if (this.currentBpm > 120) {
this.bpmElement.style.color = '#ffd93d';
} else {
this.bpmElement.style.color = '#00ffd5';
}
}
}
async deactivate() {
if (this.bpmElement) {
this.bpmElement.remove();
}
this.api.log('BPM Display deactivated');
}
}
module.exports = BPMDisplay;Applies real-time audio effects.
manifest.json
{
"id": "com.example.reverb-effect",
"name": "Reverb Effect",
"version": "1.0.0",
"entry": "index.js",
"permissions": ["api:storage", "api:logging", "audio"],
"hooks": ["onActivate", "onDeactivate", "onAudioProcess"]
}index.js
class ReverbEffect {
constructor(context) {
this.api = context.api;
this.enabled = false;
this.intensity = 0.5;
}
async activate() {
// Load saved settings
const saved = await this.api.storage.get('reverbSettings');
if (saved) {
this.enabled = saved.enabled || false;
this.intensity = saved.intensity || 0.5;
}
this.api.log(`Reverb effect ${this.enabled ? 'enabled' : 'disabled'}`);
// Register UI controls (would be displayed in plugin settings)
this.api.notify('Reverb effect ready! Use plugin settings to configure.', { duration: 3000 });
}
async onAudioProcess(audioData) {
if (!this.enabled) return audioData;
// Apply simple reverb simulation (delay + feedback)
const processed = new Float32Array(audioData.length);
const delaySamples = Math.floor(44100 * 0.3); // 300ms delay at 44.1kHz
for (let i = 0; i < audioData.length; i++) {
let sample = audioData[i];
// Add delayed signal
if (i >= delaySamples) {
sample += audioData[i - delaySamples] * this.intensity * 0.5;
}
if (i >= delaySamples * 2) {
sample += audioData[i - delaySamples * 2] * this.intensity * 0.25;
}
// Avoid clipping
processed[i] = Math.max(-1, Math.min(1, sample));
}
return processed;
}
async setEnabled(enabled) {
this.enabled = enabled;
await this.api.storage.set('reverbSettings', {
enabled: this.enabled,
intensity: this.intensity
});
this.api.log(`Reverb ${enabled ? 'enabled' : 'disabled'}`);
}
async setIntensity(value) {
this.intensity = Math.max(0, Math.min(1, value));
await this.api.storage.set('reverbSettings', {
enabled: this.enabled,
intensity: this.intensity
});
}
async deactivate() {
this.enabled = false;
this.api.log('Reverb effect deactivated');
}
}
module.exports = ReverbEffect;Creates a floating panel with custom styles.
manifest.json
{
"id": "com.example.stats-panel",
"name": "Stats Panel",
"version": "1.0.0",
"entry": "index.js",
"permissions": ["api:logging", "api:storage"],
"hooks": ["onTrackPlay", "onTrackPause"]
}ui.css
.stats-floating-panel {
position: fixed;
bottom: 100px;
right: 20px;
width: 280px;
background: linear-gradient(135deg, rgba(26,26,36,0.95), rgba(18,18,26,0.95));
backdrop-filter: blur(12px);
border: 1px solid rgba(0,255,213,0.2);
border-radius: 16px;
padding: 16px;
z-index: 1000;
font-family: monospace;
transition: transform 0.3s ease;
}
.stats-floating-panel:hover {
transform: translateY(-4px);
border-color: rgba(0,255,213,0.4);
}
.stats-title {
font-size: 12px;
font-weight: bold;
color: #00ffd5;
margin-bottom: 12px;
display: flex;
align-items: center;
gap: 8px;
}
.stats-row {
display: flex;
justify-content: space-between;
margin-bottom: 8px;
font-size: 11px;
color: #c7ced3;
}
.stats-value {
color: #00ffd5;
font-weight: bold;
}
.stats-progress-bar {
height: 3px;
background: rgba(255,255,255,0.1);
border-radius: 3px;
margin-top: 8px;
overflow: hidden;
}
.stats-progress-fill {
height: 100%;
background: linear-gradient(90deg, #00ffd5, #00a884);
width: 0%;
transition: width 0.3s ease;
}ui.js
// This script runs in the main renderer context
// and can interact with the plugin via postMessage
(function() {
// Listen for messages from the plugin
window.addEventListener('korai-plugin-message', (event) => {
const { pluginId, message } = event.detail;
if (pluginId === 'com.example.stats-panel') {
updateStats(message);
}
});
function updateStats(data) {
const titleEl = document.querySelector('.stats-title span');
if (titleEl && data.trackTitle) {
titleEl.textContent = data.trackTitle;
}
const rows = document.querySelectorAll('.stats-row');
if (rows.length >= 4 && data) {
rows[0].querySelector('.stats-value').textContent = data.bpm || '--';
rows[1].querySelector('.stats-value').textContent = data.energy ? `${Math.round(data.energy * 100)}%` : '--';
rows[2].querySelector('.stats-value').textContent = data.plays || '0';
rows[3].querySelector('.stats-value').textContent = data.likes || '0';
const fill = document.querySelector('.stats-progress-fill');
if (fill && data.energy) {
fill.style.width = `${data.energy * 100}%`;
}
}
}
})();index.js
class StatsPanel {
constructor(context) {
this.api = context.api;
this.statsInterval = null;
}
async activate() {
this.api.log('Stats Panel activated');
this.createPanel();
this.startStatsUpdate();
}
createPanel() {
// Check if panel already exists
if (document.getElementById('stats-floating-panel')) return;
const panel = document.createElement('div');
panel.id = 'stats-floating-panel';
panel.className = 'stats-floating-panel';
panel.innerHTML = `
<div class="stats-title">
<i class="fa-solid fa-chart-line"></i>
<span>Waiting for playback...</span>
</div>
<div class="stats-row">
<span>🎵 BPM</span>
<span class="stats-value">--</span>
</div>
<div class="stats-row">
<span>⚡ Energy</span>
<span class="stats-value">--</span>
</div>
<div class="stats-row">
<span>▶️ Plays</span>
<span class="stats-value">0</span>
</div>
<div class="stats-row">
<span>❤️ Likes</span>
<span class="stats-value">0</span>
</div>
<div class="stats-progress-bar">
<div class="stats-progress-fill"></div>
</div>
`;
document.body.appendChild(panel);
this.panel = panel;
}
startStatsUpdate() {
this.statsInterval = setInterval(async () => {
if (!this.currentTrack) return;
// Get current stats (you'd normally fetch these from API)
const message = {
trackTitle: this.currentTrack.title,
bpm: this.currentTrack.bpm,
energy: this.currentTrack.energy,
plays: this.currentTrack.playCount,
likes: this.currentTrack.likeCount
};
// Send to UI via postMessage
const event = new CustomEvent('korai-plugin-message', {
detail: {
pluginId: 'com.example.stats-panel',
message: message
}
});
window.dispatchEvent(event);
}, 1000);
}
async onTrackPlay(track) {
this.currentTrack = track;
this.api.log(`Stats panel tracking: ${track.title}`);
}
async onTrackPause() {
// Optionally handle pause
}
async deactivate() {
if (this.statsInterval) {
clearInterval(this.statsInterval);
}
if (this.panel) {
this.panel.remove();
}
this.api.log('Stats Panel deactivated');
}
}
module.exports = StatsPanel;- Use
api.storagefor persistent data, not global variables - Keep stored data under 10MB per plugin
- Clean up old data in
deactivate()
- Avoid heavy synchronous operations in hooks
- Use async/await for all API calls
- Throttle high-frequency hooks like
onAudioProcess - Release resources in
deactivate()
let lastProcessTime = 0;
const THROTTLE_MS = 50;
async onAudioProcess(data) {
const now = Date.now();
if (now - lastProcessTime < THROTTLE_MS) return;
lastProcessTime = now;
// Process audio...
}- Always wrap async operations in try/catch
- Use
api.log()for debugging - Gracefully handle missing permissions
async onTrackPlay(track) {
try {
await this.processTrack(track);
} catch (error) {
this.api.log(`Error processing track: ${error.message}`);
// Don't crash the plugin
}
}- Use
ui.cssandui.jsfor custom UI - Don't modify core DOM outside your containers
- Clean up DOM elements in
deactivate()
- Follow semantic versioning (MAJOR.MINOR.PATCH)
- Increment version for any manifest changes
- Test upgrades from previous versions
- Request minimal permissions
- Don't store sensitive data in storage
- Validate external input
- Use
api.requestPermission()for privileged operations
- Verify
manifest.jsonis valid JSON - Check plugin directory is in correct location (
userData/plugins/) - Ensure
idfield is unique
- Check console for errors (View → Toggle Developer Tools)
- Verify all permissions are granted
- Ensure
index.jsexports a class or object withactivate()
- Use
await api.storage.set(key, value)with await - Check if
api:storagepermission is granted
- Declare hooks in
manifest.jsonunder"hooks"array - Ensure method names match exactly (case-sensitive)
Enable verbose logging:
class MyPlugin {
async activate() {
this.api.log('=== PLUGIN DEBUG START ===');
this.api.log(`Context: ${JSON.stringify(this.context)}`);
this.api.log('=== PLUGIN DEBUG END ===');
}
}- Check console output in Developer Tools (F12)
- Review KORAI logs at
userData/logs/korai.log - Visit GitHub Issues
-
Pack your plugin:
korai-plugin pack ./my-plugin
-
Test the ZIP:
- Install in KORAI via Plugin Manager → Install Plugin
- Verify functionality
-
Share:
- Upload to your website or GitHub
- Share the
.zipfile
-
Marketplace (coming soon):
- Submit to official KORAI Plugin Store
| Version | Changes |
|---|---|
| 1.0.0 | Initial plugin system release |
| 1.1.0 | Added UI CSS/JS support |
| 1.2.0 | Added runtime permission requests |
| 1.3.0 | Added file system access APIs |
| 1.4.0 | Added custom hook registration |
KORAI Plugin API is licensed under Apache 2.0 with Commons Clause. Plugins can use any license, but must comply with KORAI's terms of service.