Appearance
OS Plugin Example — Music Player
A complete example of a Music Player plugin for the Operating System (OS) platform. Unlike EA/SA, OS plugins run as standalone desktop windows using Electron, with direct file system access.
File Structure
music/
├── 1.0.0/
│ ├── setting.json
│ ├── script.js
│ ├── index.vue ← main app view (replaces widget.vue)
│ ├── setting.vue
│ ├── README-AR.md
│ ├── README-EN.md
│ └── images/
└── database/
└── music/
└── data.jsonOS vs EA/SA: OS plugins use
index.vueas the main view (notwidget.vue), since they open in a dedicated window rather than embedding in a dashboard.
setting.json
The plugin manifest for an OS plugin. Includes an icon field for the window/app icon. No init flag.
json
{
"versionNumber": "0",
"version": "1.0.0",
"name": "music",
"icon": "music.svg",
"type": "OS",
"script": "script.js",
"id": "_87847854244",
"packages": []
}script.js
The main registration script. OS plugins have a distinct API for defining the window frame, toolbar, and pages.
OS-specific APIs used:
setFrame()— sets the desktop window title and iconsetToolbar()— defines toolbar action buttons (e.g. a settings gear)addLanguage()— registers i18n translation keys (including form-level keys)addPage()— registers pages with paths relative to the plugin root (""= home,"/setting"= settings)
js
plugins["music"] = function () {
this.setFrame({
title: "Motanamy OS | Music",
icon: {
type: 'icon',
name: 'mdi mdi-book-music'
}
});
this.addLanguage("ar", "music", {
name: "الموسيقى",
playlist: "قائمة التشغيل"
});
this.addLanguage("en", "music", {
name: "Music",
playlist: "Playlist"
});
this.addLanguage("ar", "form", {
name: "الاسم",
url: "الرابط"
});
this.addLanguage("en", "form", {
name: "Name",
url: "URL"
});
this.setToolbar([
{ icon: "pi pi-cog", to: "/setting" }
]);
this.addPage({
name: "/[music]index.vue",
path: "",
file: ["index.vue"]
});
this.addPage({
name: "/[music]setting.vue",
path: "/setting",
file: ["setting.vue"]
});
}index.vue
The main music player view that opens when the OS app launches. Fills the full window height.
vue
<template>
<div id="box" class="h-full flex align-items-center justify-content-center overflow-hidden">
<div class="audioPlayer w-full" dir="ltr">
<a class="nav-icon" @click="isPlaylistActive = !isPlaylistActive"
:class="{ 'isActive': isPlaylistActive }">
<span></span><span></span><span></span>
</a>
<div class="audioPlayerList" :class="{ 'isActive': isPlaylistActive }">
<div class="item"
v-for="(item, index) in musicPlaylist"
:class="{ 'isActive': currentSong == index }"
@click="changeSong(index), isPlaylistActive = !isPlaylistActive"
:key="index">
<p class="title">{{ item.name }}</p>
</div>
</div>
<div class="audioPlayerUI" :class="[{ 'isDisabled': isPlaylistActive }]">
<div class="albumImage">
<transition name="fade" mode="out-in" appear>
<div :class="['disc-back', currentlyPlaying ? '' : 'paused']" :key="currentSong">
<img :src="img()" class="disc">
<img v-if="posterLoad" :src="musicPlaylist[currentSong].image" class="poster">
</div>
</transition>
</div>
<div class="albumDetails">
<transition name="slide-fade" mode="out-in" appear>
<p class="title" :key="currentSong">{{ musicPlaylist[currentSong]?.name }}</p>
</transition>
<div :class="['wave-container', currentlyPlaying ? '' : 'paused']">
<div v-for="index in 20" :key="index" class="wave-bar"></div>
</div>
</div>
<div class="playerButtons w-full">
<a class="button" @click="prevSong()">
<i class="mdi mdi-skip-previous icon text-4xl" />
</a>
<a class="button play" @click="playPauseAudio()">
<i :class="currentlyPlaying ? 'mdi mdi-pause' : 'mdi mdi-play'" class="icon text-4xl" />
</a>
<a class="button" @click="nextSong()">
<i class="icon mdi mdi-skip-next text-4xl" />
</a>
</div>
<div class="timeAndProgress">
<div class="currentTimeContainer">
<span class="currentTime">{{ currentTimeShow }}</span>
<span class="totalTime">{{ trackDurationShow }}</span>
</div>
<div class="currentProgressBar" ref="progress" @click="clickProgress">
<div class="currentProgress" :style="{ width: currentProgressBar + '%' }"></div>
</div>
</div>
</div>
</div>
</div>
</template>
<script>
import api from 'api'
import { join } from "node:path";
export default {
// ... component logic
}
</script>setting.vue
The settings page. OS uses ipcRenderer (Electron) for native file picker access, and api.database() for reading/writing JSON data from the database/ folder.
vue
<template>
<div class="m-2">
<div class="card flex align-items-center justify-content-between">
<Button icon="pi pi-arrow-left" class="p-button-text" @click="$router.push('/')" />
<h5 class="m-0">{{ $t("music.name") }}</h5>
<div>
<Button icon="pi pi-save" class="p-button-text" @click="save()" />
<Button icon="pi pi-plus" class="p-button-text"
@click="products.playlist.push([])" />
<Button icon="pi pi-refresh" class="p-button-text" @click="init()" />
</div>
</div>
<Accordion :activeIndex="0">
<AccordionTab v-for="(item, index) in products.playlist" :key="index">
<template #header>
<RadioButton v-model="products.select" :value="index" />
<span>{{ $t("music.playlist") }} {{ index + 1 }}</span>
</template>
<div>
<Button icon="pi pi-plus" class="p-button-text"
@click="openToGetFile(index)" />
<Button icon="pi pi-trash" class="p-button-text p-button-danger"
@click="products.playlist.splice(index, 1)" />
</div>
<DataTable :value="item" editMode="row" @row-edit-save="onRowEditSave($event, index)"
v-model:editingRows="editingRows">
<Column field="name" :header="$t('form.name')" :sortable="true">
<template #editor="{ data, field }">
<InputText v-model="data[field]" class="w-full" />
</template>
</Column>
<Column field="path" :header="$t('form.url')" :sortable="true">
<template #editor="{ data, field }">
<InputText v-model="data[field]" readonly class="w-full" />
</template>
</Column>
<Column headerStyle="width:0px;" bodyStyle="text-align:end">
<template #body="{ index }">
<Button icon="pi pi-trash" class="p-button-text p-button-danger"
@click="item.splice(index, 1)" />
</template>
</Column>
<Column :rowEditor="true" headerStyle="width: 110px;" bodyStyle="text-align:end" />
</DataTable>
</AccordionTab>
</Accordion>
</div>
</template>
<script>
import api from 'api'
import { ipcRenderer } from "electron";
import { basename } from 'node:path';
export default {
methods: {
init() {
this.products = api.database("music").readJson(["data.json"]);
},
save() {
api.database("music").writeJson(["data.json"], this.products);
},
async openToGetFile(playlistIndex) {
// Uses Electron's native file picker
const files = await ipcRenderer.invoke('open-file-dialog', {
filters: [{ name: 'Audio', extensions: ['mp3', 'wav', 'ogg'] }],
properties: ['openFile', 'multiSelections']
});
if (files) {
files.forEach(filePath => {
this.products.playlist[playlistIndex].push({
name: basename(filePath),
path: filePath
});
});
}
}
}
}
</script>OS-specific:
ipcRendererfrom Electron is used to open native system dialogs (file picker).api.database()reads and writes data from the plugin'sdatabase/folder. The back button (pi-arrow-left) navigates to"/"since OS pages are routed within the app window.
database/music/data.json
Initial data structure for the plugin's database.
json
{
"playlist": [],
"select": 0
}