Skip to content

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.json

OS vs EA/SA: OS plugins use index.vue as the main view (not widget.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 icon
  • setToolbar() — 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: ipcRenderer from Electron is used to open native system dialogs (file picker). api.database() reads and writes data from the plugin's database/ 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
}