Scone is a media player built with Scala.js, Calico, Electron, and WebTorrent.
It features a plugin system for backend playback, see bishabosha/Scone-plugin-VLC (based on Scala Native).
- Local file playback
- Streaming torrent playback from 🧲 magnet links or
.torrentfiles - Queue multiple files and switch between them
- Audio track and subtitle
Screenshots above use the Big Buck Bunny magnet listed on WebTorrent's free torrents page.
- Node.js and npm
- JDK 17+ for building Scala.js code
- sbt
cwebpfor regenerating icon assets
Install dependencies first:
npm installStart the development app:
npm run devThis does three things together:
- fast-links the Scala.js projects
- starts the Vite dev server
- launches Electron
Build production assets:
npm run buildCreate a macOS app bundle in dist/:
npm run package:macBuild a distributable package:
npm run distRegenerate the app and README icons:
npm run icon:generateScone does not bundle a playback backend inside the host app. Instead, it discovers plugins from a player-plugins directory.
When running npm run dev, Scone discovers plugins from:
player-plugins/<plugin-id>/
Each plugin must live in its own direct child directory under player-plugins.
If you are building a plugin in a separate repo, (e.g. bishabosha/Scone-plugin-VLC) the typical flow is:
- build the plugin in its own repo
- copy or symlink the built plugin directory into
player-plugins/<plugin-id> - start Scone with
npm run dev
The build does not build external plugins for you. The host app only discovers what is already present when Electron launches.
In packaged builds, Scone discovers plugins from the app's resources directory.
On macOS that means:
Scone.app/Contents/Resources/player-plugins/<plugin-id>/
The packaged app already ships a small player-plugins scaffold, including:
Contents/Resources/player-plugins/README.txtContents/Resources/player-plugins/package.json
Leave that root package.json in place. It is part of how plugin entry files are loaded.
After installing, removing, or updating a plugin in a packaged app, quit and reopen Scone.
The host expects a plugin directory to contain a plugin.json manifest. Entry files are resolved relative to the plugin directory.
A typical shape looks like this:
<plugin-id>/
plugin.json
generated/
main.js
native/
runtime/
The exact extra folders are plugin-specific. The host only cares about the manifest and the selected JavaScript entrypoint.
Example plugin.json shape:
{
"id": "example-player",
"name": "Example Player",
"kind": "native",
"bundled": true,
"main": "generated/main.js",
}main can be either:
- a single string entrypoint
- or an object with separate
devandprodentrypoints, Scone will pick either based on the launch mode.
The public host/plugin boundary lives in shared/src/main/scala/sconeplayer/shared/PlayerPluginJsApi.scala.
That API defines:
- the plugin manifest shape
- the load context passed into a plugin
- the registration entrypoint
- the generic player bridge methods the shell expects
If an external plugin repo depends on the shared API through local Ivy, publish it first from the host repo:
npm run plugin-api:publish-localScone uses Scala.js as the primary programming language.
The renderer lives in src/main/scala/sconeplayer.
It is built with:
The main renderer app is src/main/scala/sconeplayer/MediaPlayerApp.scala. It uses functional reactive programming to drive UI changes via streaming of state changes.
The Electron main process bootstrap is tiny and lives in electron/main.mjs.
Its job is:
- choose dev or prod generated Scala.js modules
- discover player plugins before the shell starts
- import the generated shell entrypoint
Most Electron-side behavior is implemented in Scala.js under shell/src/main/scala/sconeplayer/shell, including:
- IPC handlers
- window lifecycle
- media inspection
- torrent support
- player plugin dispatch
The shell runtime starts from shell/src/main/scala/sconeplayer/shell/ElectronShellRuntime.scala.
The preload bridge is also Scala.js and lives in preload/src/main/scala/sconeplayer/preload/PreloadMain.scala.
It exposes a narrow mediaShell API into the renderer instead of letting UI code reach directly into Electron globals.
Plugin discovery and dynamic loading are implemented in Scala.js in plugin-loader/src/main/scala/sconeplayer/pluginloader/PlayerPluginLoader.scala.
The loader:
- enumerates plugin directories
- reads
plugin.json - picks the right entrypoint for dev or packaged mode
- imports the plugin module dynamically
- stores discovered plugins and load errors for the shell runtime
JSON payloads exchanged between renderer, preload, shell, and plugins are defined in shared/src/main/scala/sconeplayer/shared/model.scala.
This keeps playback snapshots, source items, track info, and plugin descriptors aligned across every runtime boundary.
The sbt build in build.sbt is split into several Scala.js projects:
root: renderershell: Electron shell runtimepreload: preload bridgeplugin-loader: plugin discovery bootstrappluginApi: shared plugin API
Dev and prod link outputs are written under:
electron/generated/dev/
electron/generated/prod/
Vite is responsible for the browser-facing renderer assets, while Scala.js owns the application logic.

