mirror of
https://github.com/ragetrip/audio-pluglist.git
synced 2026-07-22 06:44:43 +00:00
feat: initial public release 1.0.3
This commit is contained in:
commit
903e5cea07
7 changed files with 1073 additions and 0 deletions
6
CHANGELOG.md
Normal file
6
CHANGELOG.md
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
# Changelog
|
||||
|
||||
## 1.0.3 — Initial public release
|
||||
- First GitHub release of **Audio PlugList**.
|
||||
- Features: Folder/Link playlists, footer mini-player, shuffle/repeat, fade-out stop, artist/album columns, and scan tools.
|
||||
|
||||
21
LICENSE
Normal file
21
LICENSE
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
MIT License
|
||||
|
||||
Copyright (c) 2025 ragetrip
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
133
README.md
Normal file
133
README.md
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
# Audio PlugList
|
||||
|
||||
Playlist-based audio player for Obsidian. Point a playlist at a vault **folder** (auto-index audio files) or a single external **link**, then control playback from a clean **tab view** and a compact **footer mini‑player** with shuffle/repeat and optional fade‑out stop.
|
||||
|
||||
> **Version:** 1.0.3 · **Obsidian:** 1.8.0+ · **Platforms:** Desktop & Mobile
|
||||
|
||||
---
|
||||
|
||||
## ✨ Features
|
||||
|
||||
- **Playlists**
|
||||
- Create from a **Folder** (recursively indexes supported audio files).
|
||||
- Create from a **Link** (direct audio URL; subject to browser/CORS).
|
||||
- **Footer mini‑player** with segmented controls (⏮ ⏯ ⏭ ⏹ 🔀 🔁), seek, volume, and a marquee **Now Playing** title.
|
||||
- **Tab view** showing current playlist, track list, and playback controls.
|
||||
- **Shuffle / Repeat** (Off · All · One).
|
||||
- **Fade‑out on Stop** with adjustable duration.
|
||||
- **Optional columns**: show Artist and/or Album in the track list.
|
||||
- **Quick re-scan**: index one playlist or **Scan All** from settings.
|
||||
- **File name parser**: `Artist — Title` (em dash or hyphen) convenience.
|
||||
|
||||
**Supported formats:** `mp3`, `wav`, `m4a`, `flac`, `ogg`, `aac`.
|
||||
|
||||
---
|
||||
|
||||
## 📦 Installation
|
||||
|
||||
### Option A: Manual (first GitHub upload)
|
||||
1. Download the latest release assets (`main.js`, `manifest.json`, `styles.css`) from this repository.
|
||||
2. Create a folder inside your vault at:
|
||||
`.obsidian/plugins/audio-pluglist/`
|
||||
3. Copy the three files into that folder.
|
||||
4. In Obsidian: **Settings → Community plugins → Installed plugins → Enable “Audio PlugList”.**
|
||||
|
||||
### Option B: BRAT (before it’s in the Community store)
|
||||
If you use the **BRAT** plugin:
|
||||
1. In BRAT, add this repo’s URL.
|
||||
2. Let BRAT install and enable the plugin.
|
||||
|
||||
### Community Store (once approved)
|
||||
After the plugin is accepted into the Obsidian Community Plugin store, you’ll be able to install and update it directly from **Settings → Community plugins → Browse**.
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Usage
|
||||
|
||||
1. Open **Settings → Audio PlugList**.
|
||||
2. Under **Add New Playlist**, choose:
|
||||
- **From Folder** → pick a vault folder to index audio.
|
||||
- **From Link** → paste a direct audio URL (e.g. `https://…/song.mp3`).
|
||||
*Note:* External links must be direct audio files. Many services block playback via CORS.
|
||||
3. Click **Scan** on a playlist (or **Scan All**).
|
||||
4. Open the view via **Ribbon button** (music icon) or the command **“Open Audio PlugList.”**
|
||||
5. Use the **tab controls** or the **footer mini‑player** to play, pause, skip, seek, adjust volume, toggle shuffle, and cycle repeat.
|
||||
|
||||
**Tips**
|
||||
- File names like `Artist - Title.mp3` or `Artist — Title.flac` are parsed into Artist/Title automatically.
|
||||
- Turn on **Show Artist** / **Show Album** columns if you prefer richer lists.
|
||||
- Enable **Fade out on Stop** and set a custom **Fade duration (ms)** if you like gentler stops.
|
||||
|
||||
---
|
||||
|
||||
## ⚙️ Settings Overview
|
||||
|
||||
- **Playlists (dynamic list)**
|
||||
- **Name** (optional label used in menus/views)
|
||||
- **Folder** (for folder playlists) + **Browse** + **Scan now**
|
||||
- **Link** (for link playlists; one direct URL)
|
||||
- **Delete playlist**
|
||||
- **Re-scan all playlists**
|
||||
- **Show Album in titles**
|
||||
- **Show Artist in titles**
|
||||
- **Show Footer mini‑player**
|
||||
- **Fade out on Stop**
|
||||
- **Fade duration (ms)**
|
||||
|
||||
---
|
||||
|
||||
## 🔍 Known Limitations
|
||||
|
||||
- **External links & CORS**: Link playlists require **direct audio URLs**; many hosts disallow cross‑origin playback.
|
||||
- **No embedded players** (YouTube/SoundCloud) in this version.
|
||||
- Mobile platform behavior depends on OS media policies (e.g., background playback limits).
|
||||
|
||||
---
|
||||
|
||||
## 🧰 Development
|
||||
|
||||
- **Folder structure** (what Obsidian expects):
|
||||
```text
|
||||
audio-pluglist/
|
||||
├─ main.js
|
||||
├─ manifest.json
|
||||
├─ styles.css
|
||||
└─ versions.json # optional, used by the Community listing
|
||||
```
|
||||
|
||||
- **Manifest**: `id` must be `audio-pluglist` and the version in `manifest.json` **must match** the latest entry in `versions.json` (for Community releases). This repo sets both to **1.0.3**.
|
||||
|
||||
- **Building**: This project currently ships a single `main.js`. If you adopt a toolchain later (TypeScript, bundler), make sure the output files match Obsidian’s expectations.
|
||||
|
||||
- **Commands** exposed (palette): Open, Play/Pause, Next, Previous, Stop (Fade Out), Re-scan All Playlists.
|
||||
|
||||
---
|
||||
|
||||
## 📝 Changelog
|
||||
|
||||
### 1.0.3 — Initial public release
|
||||
- First GitHub release of **Audio PlugList**.
|
||||
- Dynamic playlists (Folder/Link), footer mini‑player, shuffle/repeat, fade‑out stop, artist/album columns, and scan tools.
|
||||
|
||||
---
|
||||
|
||||
## 🤝 Contributing
|
||||
|
||||
Bug reports and PRs are welcome. Please include:
|
||||
- Environment (OS, Obsidian version), repro steps, expected vs. actual behavior.
|
||||
- If it’s a feature request, a short mockup or user story helps.
|
||||
|
||||
---
|
||||
|
||||
## 📄 License
|
||||
|
||||
This project is licensed under the **MIT License**. See [`LICENSE`](./LICENSE) for details.
|
||||
|
||||
|
||||
---
|
||||
|
||||
## ☕ Support & More Plugins
|
||||
|
||||
If you like this plugin and want to support development, you can [**Buy Me a Coffee**](https://buymeacoffee.com/ragetrip).
|
||||
|
||||
Check out my other Obsidian plugins and projects here: [**My GitHub Repositories**](https://github.com/ragetrip?tab=repositories).
|
||||
715
main.js
Normal file
715
main.js
Normal file
|
|
@ -0,0 +1,715 @@
|
|||
/* == Yes it's a train wreck, but it works and nobody else was making this.
|
||||
TO DO LIST:
|
||||
1. Additional features
|
||||
2. Discover bugs and resolve
|
||||
3. Clean this mess ==
|
||||
NOTE: If this was helpful to you, please feel free to visit https://buymeacoffee.com/ragetrip to show support.
|
||||
Anything is appreciated and motivates to continue creating more useful plugins for the community.
|
||||
Thank you - Enjoy */
|
||||
|
||||
const { Plugin, PluginSettingTab, Setting, ItemView, TFile, TFolder, Notice } = require('obsidian');
|
||||
|
||||
const VIEW_TYPE = 'audio-pluglist-view';
|
||||
const AUDIO_EXTS = { mp3:1, wav:1, m4a:1, flac:1, ogg:1, aac:1 };
|
||||
|
||||
function clamp(n, a, b){ if(n<a)return a; if(n>b)return b; return n; }
|
||||
function fmtTime(s) {
|
||||
s = Math.floor(s || 0);
|
||||
var m = Math.floor(s/60);
|
||||
var r = s % 60;
|
||||
return m + ':' + (r<10?'0':'') + r;
|
||||
}
|
||||
function clearEl(el){ while(el.firstChild) el.removeChild(el.firstChild); }
|
||||
function mk(tag, cls, text){
|
||||
var el = document.createElement(tag);
|
||||
if (cls) el.className = cls;
|
||||
if (text != null) el.textContent = text;
|
||||
return el;
|
||||
}
|
||||
function parseArtistTitle(basename){
|
||||
var parts = basename.split(/\s[-–]\s/);
|
||||
if (parts.length >= 2) return { artist: parts[0], title: parts.slice(1).join(' - ') };
|
||||
return { artist: '', title: basename };
|
||||
}
|
||||
function fillPlaylistOptions(plugin, sel){
|
||||
clearEl(sel);
|
||||
if (!plugin.settings.playlists.length){
|
||||
var opt = mk('option', '', 'No playlists');
|
||||
opt.value = '-1';
|
||||
sel.appendChild(opt);
|
||||
sel.disabled = true;
|
||||
return;
|
||||
}
|
||||
sel.disabled = false;
|
||||
for (var i=0;i<plugin.settings.playlists.length;i++){
|
||||
var p = plugin.settings.playlists[i] || {};
|
||||
var label = (p.name && p.name.length) ? p.name : ('Playlist ' + (i+1));
|
||||
var opt = mk('option', '', label);
|
||||
opt.value = String(i);
|
||||
sel.appendChild(opt);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/* Simple modal folder picker */
|
||||
function FolderPicker(app, onPick){
|
||||
this.app = app; this.onPick = onPick; this.overlay = null;
|
||||
}
|
||||
FolderPicker.prototype.open = function(){
|
||||
var overlay = mk('div');
|
||||
overlay.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,.4);z-index:10000;display:flex;align-items:center;justify-content:center;';
|
||||
var modal = mk('div');
|
||||
modal.style.cssText = 'background:var(--background-primary);border:1px solid var(--background-modifier-border);border-radius:8px;min-width:420px;max-height:70vh;display:flex;flex-direction:column;';
|
||||
overlay.appendChild(modal);
|
||||
|
||||
var head = mk('div');
|
||||
head.style.cssText='padding:10px 12px;border-bottom:1px solid var(--background-modifier-border);display:flex;gap:8px;align-items:center;';
|
||||
head.appendChild(mk('b', '', 'Select a folder'));
|
||||
var search = mk('input'); search.type='search'; search.placeholder='Filter folders...'; search.style.cssText='flex:1;margin-left:8px;';
|
||||
head.appendChild(search);
|
||||
|
||||
var body = mk('div'); body.style.cssText='padding:8px 12px;overflow:auto;display:flex;flex-direction:column;gap:6px;';
|
||||
var list = mk('div');
|
||||
|
||||
var footer = mk('div'); footer.style.cssText='padding:10px 12px;border-top:1px solid var(--background-modifier-border);display:flex;gap:8px;justify-content:flex-end;';
|
||||
var cancel = mk('button', '', 'Cancel'); cancel.onclick = this.close.bind(this);
|
||||
footer.appendChild(cancel);
|
||||
|
||||
modal.appendChild(head); modal.appendChild(body); body.appendChild(list); modal.appendChild(footer);
|
||||
document.body.appendChild(overlay);
|
||||
this.overlay = overlay;
|
||||
|
||||
var folders = [];
|
||||
function walk(node){
|
||||
if (node instanceof TFolder){
|
||||
folders.push(node.path || '');
|
||||
for (var i=0;i<node.children.length;i++) walk(node.children[i]);
|
||||
}
|
||||
}
|
||||
walk(this.app.vault.getRoot());
|
||||
|
||||
var self = this;
|
||||
function render(){
|
||||
clearEl(list);
|
||||
var q = (search.value||'').toLowerCase();
|
||||
for (var i=0;i<folders.length;i++){
|
||||
var f = folders[i];
|
||||
if (f.toLowerCase().indexOf(q) === -1) continue;
|
||||
var row = mk('div','', f || '/');
|
||||
row.style.cssText = 'padding:6px 8px;border-radius:6px;cursor:pointer;';
|
||||
row.onmouseenter = function(){ this.style.background = 'var(--background-modifier-hover)'; };
|
||||
row.onmouseleave = function(){ this.style.background = 'transparent'; };
|
||||
(function(folderPath){
|
||||
row.onclick = function(){ self.onPick(folderPath); self.close(); };
|
||||
})(f);
|
||||
list.appendChild(row);
|
||||
}
|
||||
}
|
||||
render();
|
||||
search.oninput = render;
|
||||
};
|
||||
FolderPicker.prototype.close = function(){
|
||||
if (this.overlay){ this.overlay.remove(); this.overlay = null; }
|
||||
};
|
||||
|
||||
/* Settings tab */
|
||||
function SettingsTab(app, plugin){ PluginSettingTab.call(this, app, plugin); this.plugin = plugin; }
|
||||
SettingsTab.prototype = Object.create(PluginSettingTab.prototype);
|
||||
|
||||
SettingsTab.prototype.display = function(){
|
||||
var containerEl = this.containerEl;
|
||||
containerEl.empty();
|
||||
containerEl.createEl('h2', { text: 'Audio PlugList' });
|
||||
|
||||
var self = this;
|
||||
|
||||
// Add New Playlist panel
|
||||
var addWrap = containerEl.createEl('div', { cls: 'apl-settings-addwrap' });
|
||||
addWrap.createEl('div', { text: 'Add New Playlist', cls: 'apl-addlabel' });
|
||||
|
||||
// Folder button
|
||||
new Setting(addWrap)
|
||||
.setName('From Folder')
|
||||
.setDesc('Create a playlist that scans a vault folder')
|
||||
.addButton(b=>{
|
||||
b.setButtonText('Add Playlist From Folder').onClick(async ()=>{
|
||||
self.plugin.settings.playlists.push({ type:'folder', name:'', folderPath:'' });
|
||||
self.plugin.tracksByPlaylist.push([]);
|
||||
await self.plugin.saveSettings();
|
||||
self.display();
|
||||
});
|
||||
});
|
||||
|
||||
// Link button
|
||||
new Setting(addWrap)
|
||||
.setName('From Link')
|
||||
.setDesc('Create a playlist that plays a single external link')
|
||||
.addButton(b=>{
|
||||
b.setButtonText('Add Playlist From Link').onClick(async ()=>{
|
||||
self.plugin.settings.playlists.push({ type:'link', name:'', link:'' });
|
||||
self.plugin.tracksByPlaylist.push([]);
|
||||
await self.plugin.saveSettings();
|
||||
self.display();
|
||||
});
|
||||
});
|
||||
|
||||
function makePlaylistSection(idx){
|
||||
var card = containerEl.createEl('div', { cls: 'apl-card' });
|
||||
var heading = card.createEl('div', { cls: 'setting-item setting-item-heading' });
|
||||
heading.createEl('div', { text: 'Playlist ' + (idx+1) });
|
||||
|
||||
// Name (always)
|
||||
new Setting(card)
|
||||
.setName('Name')
|
||||
.addText(function(t){
|
||||
var cfg = self.plugin.settings.playlists[idx] || {};
|
||||
t.setPlaceholder((idx===0 && !cfg.name) ? 'Main' : ('Playlist ' + (idx+1)))
|
||||
.setValue((cfg.name||''))
|
||||
.onChange(async (v) => { self.plugin.settings.playlists[idx].name = v; await self.plugin.saveSettings(); });
|
||||
});
|
||||
|
||||
var cfg = self.plugin.settings.playlists[idx] || {};
|
||||
if (cfg.type === 'link'){
|
||||
new Setting(card)
|
||||
.setName('Link')
|
||||
.setDesc('Paste an external audio link (e.g., direct MP3 URL). Browser/CORS limits may apply.')
|
||||
.addText(function(t){
|
||||
t.setPlaceholder('https://...')
|
||||
.setValue((cfg.link||''))
|
||||
.onChange(async (v) => { self.plugin.settings.playlists[idx].link = (v||'').trim(); await self.plugin.saveSettings(); });
|
||||
});
|
||||
} else {
|
||||
// Default to folder type
|
||||
self.plugin.settings.playlists[idx].type = 'folder';
|
||||
|
||||
new Setting(card)
|
||||
.setName('Folder')
|
||||
.setDesc('Folder to scan')
|
||||
.addText(function(t){
|
||||
t.setPlaceholder('Browse to select...')
|
||||
.setValue((cfg.folderPath||''))
|
||||
.onChange(async (v) => { self.plugin.settings.playlists[idx].folderPath = (v||'').trim(); await self.plugin.saveSettings(); });
|
||||
})
|
||||
.addButton(function(btn){
|
||||
btn.setButtonText('Browse').onClick(async () => {
|
||||
var picker = new FolderPicker(self.app, async function(folderPath){
|
||||
self.plugin.settings.playlists[idx].folderPath = folderPath;
|
||||
await self.plugin.saveSettings();
|
||||
self.display();
|
||||
});
|
||||
picker.open();
|
||||
});
|
||||
});
|
||||
|
||||
new Setting(card)
|
||||
.setName('Scan now')
|
||||
.setDesc('Index this playlist folder immediately')
|
||||
.addButton(function(btn){ btn.setButtonText('Scan').onClick(() => self.plugin.indexPlaylist(idx)); });
|
||||
}
|
||||
|
||||
new Setting(card)
|
||||
.setName('Remove this playlist')
|
||||
.addButton(btn=>{
|
||||
btn.setButtonText('Delete').onClick(async ()=>{
|
||||
self.plugin.settings.playlists.splice(idx,1);
|
||||
self.plugin.tracksByPlaylist.splice(idx,1);
|
||||
if (self.plugin.currentPlaylist >= self.plugin.settings.playlists.length)
|
||||
self.plugin.currentPlaylist = self.plugin.settings.playlists.length - 1;
|
||||
await self.plugin.saveSettings();
|
||||
self.display();
|
||||
});
|
||||
});
|
||||
|
||||
/* divider moved inside card (not needed) */
|
||||
|
||||
}
|
||||
|
||||
for (var i=0;i<self.plugin.settings.playlists.length;i++) makePlaylistSection(i);
|
||||
|
||||
// Break line above "Re-scan all playlists"
|
||||
containerEl.createEl('div', { cls: 'apl-section-break' });
|
||||
|
||||
// Keep remaining settings area as-is; rebuild minimal subset
|
||||
new Setting(containerEl)
|
||||
.setName('Re-scan all playlists')
|
||||
.addButton(function(btn){ btn.setButtonText('Scan All').onClick(() => self.plugin.indexAll()); });
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName('Show Album in titles')
|
||||
.addToggle(t=>{
|
||||
t.setValue(self.plugin.settings.showAlbum)
|
||||
.onChange(async v=>{ self.plugin.settings.showAlbum = v; await self.plugin.saveSettings(); if (self.plugin.onUiRefresh) self.plugin.onUiRefresh(); });
|
||||
});
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName('Show Artist in titles')
|
||||
.addToggle(t=>{
|
||||
t.setValue(self.plugin.settings.showArtist)
|
||||
.onChange(async v=>{ self.plugin.settings.showArtist = v; await self.plugin.saveSettings(); if (self.plugin.onUiRefresh) self.plugin.onUiRefresh(); });
|
||||
});
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName('Show Footer mini-player')
|
||||
.addToggle(t=>{
|
||||
t.setValue(self.plugin.settings.showFooter)
|
||||
.onChange(async v=>{ self.plugin.settings.showFooter = v; await self.plugin.saveSettings(); if (self.plugin._applyFooterVisibility) self.plugin._applyFooterVisibility(); });
|
||||
});
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName('Fade out on Stop')
|
||||
.addToggle(t=>{
|
||||
t.setValue(self.plugin.settings.fadeOnStop)
|
||||
.onChange(async v=>{ self.plugin.settings.fadeOnStop = v; await self.plugin.saveSettings(); });
|
||||
});
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName('Fade duration (ms)')
|
||||
.addText(t=>{
|
||||
t.setPlaceholder('3000')
|
||||
.setValue(String(self.plugin.settings.fadeMs||3000))
|
||||
.onChange(async v=>{
|
||||
var n = parseInt(v,10);
|
||||
if (!isFinite(n) || n<=0) n = 3000;
|
||||
self.plugin.settings.fadeMs = n;
|
||||
await self.plugin.saveSettings();
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
/* Tab View */
|
||||
function View(leaf, plugin){ ItemView.call(this, leaf); this.plugin = plugin; }
|
||||
View.prototype = Object.create(ItemView.prototype);
|
||||
View.prototype.getViewType = function(){ return VIEW_TYPE; };
|
||||
View.prototype.getDisplayText = function(){ return 'Audio PlugList'; };
|
||||
View.prototype.getIcon = function(){ return 'music'; };
|
||||
View.prototype.onOpen = async function(){ this.render(); };
|
||||
View.prototype.onClose = async function(){};
|
||||
View.prototype.render = function(){
|
||||
var container = this.containerEl.children[1];
|
||||
clearEl(container);
|
||||
|
||||
var col = mk('div', 'ap-col');
|
||||
container.appendChild(col);
|
||||
|
||||
// Top: playlist dropdown
|
||||
var rowTop = mk('div', 'ap-row');
|
||||
col.appendChild(rowTop);
|
||||
var sel = mk('select');
|
||||
rowTop.appendChild(sel);
|
||||
fillPlaylistOptions(this.plugin, sel); if (this.plugin.currentPlaylist>=0) sel.value = String(this.plugin.currentPlaylist);
|
||||
sel.onchange = function(){ if (sel.value!=='-1') this.plugin.setCurrentPlaylist(Number(sel.value)); }.bind(this);
|
||||
|
||||
// Now playing title
|
||||
var title = mk('div', 'ap-title-large', this.plugin.currentTrackLabel());
|
||||
col.appendChild(title);
|
||||
|
||||
// Controls (with appended icons)
|
||||
var controls = mk('div', 'ap-row ap-controls');
|
||||
col.appendChild(controls);
|
||||
var prev = mk('button', '', 'Prev ◀︎◀︎'); prev.title='Previous';
|
||||
var play = mk('button', '', (this.plugin.audio.paused ? 'Play ▶︎' : 'Pause Ⅱ')); play.title='Play/Pause';
|
||||
var next = mk('button', '', 'Skip ▶︎▶︎'); next.title='Next';
|
||||
var stop = mk('button', '', 'Stop ■'); stop.title='Stop (fade)';
|
||||
var shuffle = mk('button', '', this.plugin.shuffle ? 'Shuffle (ON)' : 'Shuffle'); shuffle.title='Shuffle';
|
||||
var rptLabel = this.plugin.repeatMode === 'all' ? 'Repeat (All)' : (this.plugin.repeatMode === 'one' ? 'Repeat (One)' : 'Repeat');
|
||||
var repeatBtn = mk('button', '', rptLabel); repeatBtn.title='Repeat mode';
|
||||
controls.appendChild(prev); controls.appendChild(play); controls.appendChild(next); controls.appendChild(stop);
|
||||
controls.appendChild(shuffle); controls.appendChild(repeatBtn);
|
||||
|
||||
prev.onclick = function(){ this.plugin.prev(); }.bind(this);
|
||||
play.onclick = function(){ this.plugin.togglePlay(); play.textContent = (this.plugin.audio.paused ? 'Play ▶︎' : 'Pause Ⅱ'); }.bind(this);
|
||||
next.onclick = function(){ this.plugin.next(); }.bind(this);
|
||||
stop.onclick = function(){ if (this.plugin.settings.fadeOnStop) this.plugin.stopFade(this.plugin.settings.fadeMs||3000); else this.plugin.stop(); }.bind(this);
|
||||
if (this.plugin.shuffle) shuffle.classList.add('is-active');
|
||||
shuffle.onclick = function(){ this.plugin.toggleShuffle(); shuffle.textContent = this.plugin.shuffle ? 'Shuffle (ON)' : 'Shuffle'; shuffle.classList.toggle('is-active', this.plugin.shuffle); }.bind(this);
|
||||
repeatBtn.classList.toggle('is-active', this.plugin.repeatMode !== 'off');
|
||||
repeatBtn.onclick = function(){ this.plugin.cycleRepeat(); var lbl = this.plugin.repeatMode === 'all' ? 'Repeat (All)' : (this.plugin.repeatMode === 'one' ? 'Repeat (One)' : 'Repeat'); repeatBtn.textContent = lbl; repeatBtn.classList.toggle('is-active', this.plugin.repeatMode !== 'off'); }.bind(this);
|
||||
|
||||
// Seek
|
||||
var seekRow = mk('div', 'ap-row ap-seek');
|
||||
col.appendChild(seekRow);
|
||||
var cur = mk('span', '', '0:00');
|
||||
var range = mk('input'); range.type='range'; range.min='0'; range.max='1000'; range.value='0';
|
||||
var dur = mk('span', '', '0:00');
|
||||
seekRow.appendChild(cur); seekRow.appendChild(range); seekRow.appendChild(dur);
|
||||
range.oninput = function(){ this.plugin.seekToFraction(Number(range.value)/1000); }.bind(this);
|
||||
|
||||
// Volume (centered, wide)
|
||||
var volRow = mk('div', 'ap-row ap-vol-wide');
|
||||
col.appendChild(volRow);
|
||||
volRow.appendChild(mk('span','', 'Vol'));
|
||||
var vol = mk('input'); vol.type='range'; vol.min='0'; vol.max='100'; vol.value = String(Math.round(this.plugin.audio.volume*100));
|
||||
volRow.appendChild(vol);
|
||||
vol.oninput = function(){ this.plugin.setVolume(Number(vol.value)/100); }.bind(this);
|
||||
|
||||
// Track list (row click to play, Title — Artist, duration right, current with 🔊)
|
||||
var list = mk('div', 'audio-list');
|
||||
col.appendChild(list);
|
||||
|
||||
var showArtist = this.plugin.settings.showArtist;
|
||||
var showAlbum = this.plugin.settings.showAlbum;
|
||||
function gridCols(){
|
||||
var cols = ['16px','1fr'];
|
||||
if (showArtist) cols.push('minmax(120px,0.8fr)');
|
||||
if (showAlbum) cols.push('minmax(120px,0.8fr)');
|
||||
cols.push('56px');
|
||||
return cols.join(' ');
|
||||
}
|
||||
function gridColsFor(playing){
|
||||
var cols = [playing ? '16px' : '0px','1fr'];
|
||||
if (showArtist) cols.push('minmax(120px,0.8fr)');
|
||||
if (showAlbum) cols.push('minmax(120px,0.8fr)');
|
||||
cols.push('56px');
|
||||
return cols.join(' ');
|
||||
}
|
||||
|
||||
// Header
|
||||
var header = mk('div','audio-header');
|
||||
header.style.gridTemplateColumns = gridCols();
|
||||
header.appendChild(mk('div','cell-icon',''));
|
||||
header.appendChild(mk('div','cell-title','Title'));
|
||||
if (showArtist) header.appendChild(mk('div','cell-artist','Artist'));
|
||||
if (showAlbum) header.appendChild(mk('div','cell-album','Album'));
|
||||
header.appendChild(mk('div','cell-time','Time'));
|
||||
list.appendChild(header);
|
||||
|
||||
var tracks = this.plugin.getTracks();
|
||||
if (!tracks.length) list.appendChild(mk('div','', 'No audio files found for this playlist.'));
|
||||
for (var i=0;i<tracks.length;i++){
|
||||
(function(i, t, self){
|
||||
var row = mk('div', 'audio-row');
|
||||
row.style.gridTemplateColumns = gridColsFor(self.plugin.index===i);
|
||||
var meta = t.meta || {};
|
||||
var icon = mk('div','cell-icon', self.plugin.index===i ? '🔊' : '');
|
||||
if (self.plugin.index===i) row.classList.add('is-playing');
|
||||
var titleCell = mk('div','cell-title', t.title);
|
||||
row.appendChild(icon);
|
||||
row.appendChild(titleCell);
|
||||
if (showArtist) row.appendChild(mk('div','cell-artist', meta.artist || ''));
|
||||
if (showAlbum) row.appendChild(mk('div','cell-album', meta.album || ''));
|
||||
var right = mk('div','cell-time', t.duration!=null ? fmtTime(t.duration) : '--:--');
|
||||
row.appendChild(right);
|
||||
row.onclick = function(){ self.plugin.playIndex(i); };
|
||||
list.appendChild(row);
|
||||
})(i, tracks[i], this);
|
||||
}
|
||||
|
||||
var self = this;
|
||||
function updateTime(){
|
||||
var ct = self.plugin.audio.currentTime || 0;
|
||||
var dd = self.plugin.audio.duration || 0;
|
||||
var frac = dd ? clamp(ct/dd, 0, 1) : 0;
|
||||
range.value = String(Math.round(frac*1000));
|
||||
cur.textContent = fmtTime(ct);
|
||||
dur.textContent = fmtTime(dd);
|
||||
title.textContent = self.plugin.currentTrackLabel();
|
||||
}
|
||||
this.plugin.audio.addEventListener('timeupdate', updateTime);
|
||||
this.plugin.audio.addEventListener('loadedmetadata', updateTime);
|
||||
this.plugin.onUiRefresh = function(){ self.render(); };
|
||||
};
|
||||
|
||||
/* Main plugin */
|
||||
module.exports = class AudioPlugList extends Plugin {
|
||||
async onload() {
|
||||
this.settings = Object.assign({
|
||||
playlists: [],
|
||||
autoIndexOnLoad: true,
|
||||
currentPlaylist: 0,
|
||||
showAlbum: false,
|
||||
showArtist: false,
|
||||
showFooter: true,
|
||||
fadeOnStop: true,
|
||||
fadeMs: 3000
|
||||
}, await this.loadData());
|
||||
|
||||
this.tracksByPlaylist = Array.from({length: this.settings.playlists.length}, ()=>[]);
|
||||
this.currentPlaylist = (this.settings.playlists.length ? clamp(this.settings.currentPlaylist, 0, this.settings.playlists.length-1) : -1);
|
||||
this.queue = [];
|
||||
this.index = -1;
|
||||
this.audio = new Audio();
|
||||
this.shuffle = true;
|
||||
this.repeatMode = 'all'; // default 'all'
|
||||
this.onUiRefresh = null;
|
||||
|
||||
this.audio.addEventListener('ended', this._onEnded.bind(this));
|
||||
|
||||
// Ribbon icon
|
||||
this.addRibbonIcon('music', 'Audio PlugList', () => this.activateView());
|
||||
|
||||
this.registerView(VIEW_TYPE, (leaf) => new View(leaf, this));
|
||||
|
||||
this.addCommand({ id:'apl-open', name:'Open Audio PlugList', callback: () => this.activateView() });
|
||||
this.addCommand({ id:'apl-play-pause', name:'Play/Pause', callback: () => this.togglePlay() });
|
||||
this.addCommand({ id:'apl-next', name:'Next Track', callback: () => this.next() });
|
||||
this.addCommand({ id:'apl-prev', name:'Previous Track', callback: () => this.prev() });
|
||||
this.addCommand({ id:'apl-stop', name:'Stop (Fade Out)', callback: () => this.stopFade(3000) });
|
||||
this.addCommand({ id:'apl-rescan-all', name:'Re-scan All Playlists', callback: () => this.indexAll() });
|
||||
|
||||
this.addSettingTab(new SettingsTab(this.app, this));
|
||||
|
||||
if (this.settings.autoIndexOnLoad){
|
||||
const run = async ()=>{ try{ await this.indexAll(); }catch(e){} };
|
||||
if (this.app.workspace && this.app.workspace.onLayoutReady){ this.app.workspace.onLayoutReady(run); }
|
||||
else setTimeout(run, 800);
|
||||
}
|
||||
this._applyFooterVisibility = function(){ if (this.settings.showFooter){ this._attachFooter(); } else { this._detachFooter(); } };
|
||||
this._applyFooterVisibility();
|
||||
}
|
||||
|
||||
onunload() {
|
||||
this._detachFooter();
|
||||
this.app.workspace.detachLeavesOfType(VIEW_TYPE);
|
||||
this.audio.pause(); this.audio.src = '';
|
||||
}
|
||||
|
||||
async saveSettings(){ await this.saveData(this.settings); }
|
||||
|
||||
async activateView(){
|
||||
var leaves = this.app.workspace.getLeavesOfType(VIEW_TYPE);
|
||||
if (leaves.length){
|
||||
this.app.workspace.revealLeaf(leaves[0]);
|
||||
} else {
|
||||
var leaf = this.app.workspace.getRightLeaf(false);
|
||||
await leaf.setViewState({ type: VIEW_TYPE, active: true });
|
||||
this.app.workspace.revealLeaf(leaf);
|
||||
}
|
||||
}
|
||||
|
||||
/* Footer */
|
||||
_attachFooter(){
|
||||
if (this.footerEl) return;
|
||||
var root = document.body.querySelector('.app-container, .mod-root') || document.body;
|
||||
var el = mk('div', 'ap-footer-player');
|
||||
|
||||
var sel = mk('select');
|
||||
fillPlaylistOptions(this, sel); if (this.currentPlaylist>=0) sel.value = String(this.currentPlaylist);
|
||||
sel.onchange = function(){ if (sel.value!=='-1') this.setCurrentPlaylist(Number(sel.value)); }.bind(this);
|
||||
|
||||
// Segmented controls ⏮ | ⏯ | ⏭ | ⏹ | 🔀 | 🔁
|
||||
var controls = el.createEl('div', { cls:'ap-footer-group' });
|
||||
var bPrev = controls.createEl('button', { cls:'ap-footer-seg no-active plain', text:'◀︎◀︎', title:'Previous' }); bPrev.onclick = ()=>this.prev();
|
||||
var bPlay = controls.createEl('button', { cls:'ap-footer-seg', text:(this.audio.paused?'▶':'Ⅱ'), title:'Play/Pause' }); bPlay.onclick = ()=>{ this.togglePlay(); bPlay.textContent = this.audio.paused ? '▶' : 'Ⅱ'; };
|
||||
var bNext = controls.createEl('button', { cls:'ap-footer-seg no-active plain', text:'▶︎▶︎', title:'Skip' }); bNext.onclick = ()=>this.next();
|
||||
var bStop = controls.createEl('button', { cls:'ap-footer-seg no-active plain', text:'■', title:'Stop' }); bStop.onclick = ()=>{ if (this.settings.fadeOnStop) this.stopFade(this.settings.fadeMs||3000); else this.stop(); };
|
||||
var bShuf = controls.createEl('button', { cls:'ap-footer-seg', text:'🔀', title:'Shuffle' }); bShuf.onclick = ()=>{ this.toggleShuffle(); bShuf.classList.toggle('is-active', this.shuffle); };
|
||||
var bRpt = controls.createEl('button', { cls:'ap-footer-seg', text:(this.repeatMode==='one'?'🔂':'🔁'), title:'Repeat' }); bRpt.onclick = ()=>{ this.cycleRepeat(); bRpt.textContent = (this.repeatMode==='one'?'🔂':'🔁'); bRpt.classList.toggle('is-active', this.repeatMode!=='off'); };
|
||||
|
||||
var seekWrap = mk('div', 'ap-seek');
|
||||
var cur = mk('span','', '0:00');
|
||||
var seek = mk('input'); seek.type='range'; seek.min='0'; seek.max='1000'; seek.value='0';
|
||||
var dur = mk('span','', '0:00');
|
||||
seek.oninput = function(){ this.seekToFraction(Number(seek.value)/1000); }.bind(this);
|
||||
seekWrap.appendChild(cur); seekWrap.appendChild(seek); seekWrap.appendChild(dur);
|
||||
|
||||
var volWrap = mk('div', 'ap-vol');
|
||||
var volLabel = mk('span','', 'Vol');
|
||||
var vol = mk('input'); vol.type='range'; vol.min='0'; vol.max='100'; vol.value = String(Math.round(this.audio.volume*100));
|
||||
vol.oninput = function(){ this.setVolume(Number(vol.value)/100); }.bind(this);
|
||||
volWrap.appendChild(volLabel); volWrap.appendChild(vol);
|
||||
|
||||
var title = mk('div', 'ap-title');
|
||||
this._setFooterTitle(title, this.currentTrackLabel());
|
||||
|
||||
el.appendChild(sel);
|
||||
el.appendChild(controls);
|
||||
el.appendChild(seekWrap);
|
||||
el.appendChild(volWrap);
|
||||
el.appendChild(title);
|
||||
root.appendChild(el);
|
||||
this.footerEl = el;
|
||||
|
||||
// initialize active states
|
||||
// init active states only for shuffle/repeat
|
||||
if (this.shuffle) bShuf.classList.add('is-active');
|
||||
if (this.repeatMode !== 'off') bRpt.classList.add('is-active');
|
||||
|
||||
var self = this;
|
||||
function updateTime(){
|
||||
var ct = self.audio.currentTime || 0;
|
||||
var dd = self.audio.duration || 0;
|
||||
var frac = dd ? clamp(ct/dd, 0, 1) : 0;
|
||||
seek.value = String(Math.round(frac*1000));
|
||||
cur.textContent = fmtTime(ct);
|
||||
dur.textContent = fmtTime(dd);
|
||||
self._setFooterTitle(title, self.currentTrackLabel());
|
||||
}
|
||||
this.audio.addEventListener('timeupdate', updateTime);
|
||||
this.audio.addEventListener('loadedmetadata', updateTime);
|
||||
}
|
||||
_detachFooter(){ if (this.footerEl){ this.footerEl.remove(); this.footerEl = null; } }
|
||||
|
||||
_setFooterTitle(container, text){
|
||||
if (this._footerTitleText === text && container._marqueeInitialized) return;
|
||||
this._footerTitleText = text;
|
||||
while (container.firstChild) container.removeChild(container.firstChild);
|
||||
var span = mk('span','', text || '—');
|
||||
container.appendChild(span);
|
||||
container._marqueeInitialized = false;
|
||||
var self = this;
|
||||
requestAnimationFrame(function(){
|
||||
try{
|
||||
if (container.scrollWidth > container.clientWidth){
|
||||
while (container.firstChild) container.removeChild(container.firstChild);
|
||||
var wrap = mk('div','ap-marquee');
|
||||
var t1 = mk('span','', text + ' • ');
|
||||
var t2 = mk('span','', text + ' • ');
|
||||
wrap.appendChild(t1); wrap.appendChild(t2);
|
||||
var dur = Math.max(12, Math.ceil((text||'').length * 0.45));
|
||||
wrap.style.setProperty('--apl-marquee-duration', dur + 's');
|
||||
container.appendChild(wrap);
|
||||
container._marqueeInitialized = true;
|
||||
}
|
||||
}catch(e){}
|
||||
});
|
||||
}
|
||||
|
||||
setCurrentPlaylist(i){ if (!this.settings.playlists.length){ this.currentPlaylist=-1; this._applyPlaylistQueue(); if(this.onUiRefresh) this.onUiRefresh(); return; } this.currentPlaylist = clamp(i,0,this.settings.playlists.length-1);
|
||||
this.settings.currentPlaylist = this.currentPlaylist;
|
||||
this.saveSettings();
|
||||
this._applyPlaylistQueue();
|
||||
if (typeof this.onUiRefresh === 'function') this.onUiRefresh();
|
||||
}
|
||||
|
||||
/* Indexing */
|
||||
async indexAll(){ for (var i=0;i<this.settings.playlists.length;i++) await this.indexPlaylist(i); this._applyPlaylistQueue(); }
|
||||
async indexPlaylist(i){
|
||||
var cfg = this.settings.playlists[i] || {};
|
||||
// LINK-based playlist support
|
||||
if (cfg.type === 'link'){
|
||||
var link = (cfg.link||'').trim();
|
||||
var name = (cfg.name||link||('Playlist '+(i+1)));
|
||||
if (!link){ this.tracksByPlaylist[i]=[]; new Notice('Audio PlugList: link missing for "'+name+'".'); return; }
|
||||
var t = { id: link, path: link, title: name, meta: { artist:'', title:name, album:'' }, duration: null };
|
||||
this.tracksByPlaylist[i] = [t];
|
||||
this._preloadDurations(i);
|
||||
return;
|
||||
}
|
||||
// FOLDER-based (default)
|
||||
var cfg = this.settings.playlists[i] || { folderPath:'' };
|
||||
var fp = (cfg.folderPath||'').trim();
|
||||
var root = fp ? this.app.vault.getAbstractFileByPath(fp) : this.app.vault.getRoot();
|
||||
if (!root){ new Notice('Audio PlugList: folder not found for playlist ' + (i+1)); this.tracksByPlaylist[i] = []; return; }
|
||||
var tracks = [];
|
||||
var self = this;
|
||||
async function walk(node){
|
||||
if (node instanceof TFile){
|
||||
var ext = (node.extension||'').toLowerCase();
|
||||
if (AUDIO_EXTS[ext]) {
|
||||
var parsed = parseArtistTitle(node.basename);
|
||||
var album = (node.parent && node.parent.name) ? node.parent.name : '';
|
||||
tracks.push({ id: node.path, path: node.path, title: node.basename, meta: { artist: parsed.artist, title: parsed.title, album: album }, duration: null });
|
||||
}
|
||||
} else if (node instanceof TFolder){
|
||||
for (var j=0;j<node.children.length;j++) await walk(node.children[j]);
|
||||
}
|
||||
}
|
||||
await walk(root);
|
||||
this.tracksByPlaylist[i] = tracks;
|
||||
this._preloadDurations(i);
|
||||
new Notice('Audio PlugList: Playlist ' + (i+1) + ' indexed ' + tracks.length + ' tracks.');
|
||||
}
|
||||
|
||||
_preloadDurations(i){
|
||||
var list = this.tracksByPlaylist[i] || [];
|
||||
var self = this;
|
||||
list.forEach(function(t){
|
||||
if (t.duration != null) return;
|
||||
var a = new Audio();
|
||||
a.preload = 'metadata';
|
||||
a.src = self.app.vault.adapter.getResourcePath(t.path);
|
||||
a.addEventListener('loadedmetadata', function(){
|
||||
t.duration = a.duration || 0;
|
||||
if (typeof self.onUiRefresh === 'function') self.onUiRefresh();
|
||||
}, { once:true });
|
||||
});
|
||||
}
|
||||
|
||||
_applyPlaylistQueue(){ var list = this.getTracks(); this.queue = list.slice(); this.index = -1; }
|
||||
getTracks(){ return (this.currentPlaylist>=0 && this.currentPlaylist<this.tracksByPlaylist.length) ? (this.tracksByPlaylist[this.currentPlaylist]||[]) : []; }
|
||||
current(){ return (this.index>=0 && this.index<this.queue.length) ? this.queue[this.index] : null; }
|
||||
currentTrackLabel(){ var t = this.current(); if (!t) return '—'; return buildLabel(this, t); }
|
||||
loadCurrent(){
|
||||
var t = this.current(); if (!t) return false;
|
||||
this.audio.src = (/^https?:\/\//i.test(t.path) ? t.path : this.app.vault.adapter.getResourcePath(t.path));
|
||||
return true;
|
||||
}
|
||||
playIndex(i){ if (i<0 || i>=this.queue.length) return; this.index = i; if (this.loadCurrent()) this.audio.play(); this._notifyUi(); }
|
||||
togglePlay(){
|
||||
if (!this.current() && this.queue.length){ this.index = 0; if (!this.loadCurrent()) return; }
|
||||
if (this.audio.paused) this.audio.play(); else this.audio.pause();
|
||||
this._notifyUi();
|
||||
}
|
||||
stop(){ this.audio.pause(); this.audio.currentTime = 0; this._notifyUi(); }
|
||||
stopFade(durationMs){
|
||||
durationMs = durationMs || 3000;
|
||||
var a = this.audio;
|
||||
var startVol = a.volume;
|
||||
var steps = 30;
|
||||
var step = 0;
|
||||
var self = this;
|
||||
var iv = setInterval(function(){
|
||||
step++;
|
||||
var frac = 1 - (step/steps);
|
||||
a.volume = Math.max(0, startVol * frac);
|
||||
if (step >= steps){
|
||||
clearInterval(iv);
|
||||
a.pause();
|
||||
a.currentTime = 0;
|
||||
a.volume = startVol;
|
||||
self._notifyUi();
|
||||
}
|
||||
}, Math.max(10, Math.floor(durationMs/steps)));
|
||||
}
|
||||
next(){
|
||||
if (this.repeatMode === 'one' && this.current()){ this.audio.currentTime = 0; this.audio.play(); this._notifyUi(); return; }
|
||||
this.index = this._pickNextIndex(this.index);
|
||||
if (this.index === -1){
|
||||
if (this.repeatMode === 'all' && this.queue.length) this.index = 0;
|
||||
}
|
||||
if (this.index === -1){ this.stop(); return; }
|
||||
if (this.loadCurrent()) this.audio.play();
|
||||
this._notifyUi();
|
||||
}
|
||||
prev(){
|
||||
if (this.index > 0) this.index--; else this.audio.currentTime = 0;
|
||||
if (this.loadCurrent()) this.audio.play();
|
||||
this._notifyUi();
|
||||
}
|
||||
_onEnded(){ this.next(); }
|
||||
|
||||
toggleShuffle(){ this.shuffle = !this.shuffle; }
|
||||
cycleRepeat(){
|
||||
if (this.repeatMode === 'off') this.repeatMode = 'all';
|
||||
else if (this.repeatMode === 'all') this.repeatMode = 'one';
|
||||
else this.repeatMode = 'off';
|
||||
}
|
||||
|
||||
_pickNextIndex(cur){
|
||||
var n = this.queue.length;
|
||||
if (!n) return -1;
|
||||
if (this.shuffle){
|
||||
if (n === 1) return 0;
|
||||
var r2 = Math.floor(Math.random()*n);
|
||||
if (r2 === cur) r2 = (r2+1)%n;
|
||||
return r2;
|
||||
}
|
||||
return (cur+1 < n) ? (cur+1) : -1;
|
||||
}
|
||||
|
||||
seekToFraction(p){ var d = this.audio.duration || 0; if (d) this.audio.currentTime = clamp(p,0,1) * d; }
|
||||
setVolume(v){ this.audio.volume = clamp(v,0,1); }
|
||||
|
||||
_notifyUi(){ if (typeof this.onUiRefresh === 'function') this.onUiRefresh(); }
|
||||
};
|
||||
function buildLabel(plugin, t){
|
||||
var meta = t.meta || {};
|
||||
var parts = [t.title]; // always start from the file basename
|
||||
if (plugin && plugin.settings){
|
||||
if (plugin.settings.showArtist && meta.artist) parts.push(meta.artist);
|
||||
if (plugin.settings.showAlbum && meta.album) parts.push(meta.album);
|
||||
}
|
||||
return parts.join(' — ');
|
||||
}
|
||||
|
||||
11
manifest.json
Normal file
11
manifest.json
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
{
|
||||
"id": "audio-pluglist",
|
||||
"name": "Audio PlugList",
|
||||
"version": "1.0.3",
|
||||
"minAppVersion": "1.8.0",
|
||||
"description": "Playlist-based audio player with segmented footer controls, marquee title, shuffle/repeat, fade-out stop, and tab labels with icons.",
|
||||
"author": "ragetrip",
|
||||
"isDesktopOnly": false,
|
||||
"main": "main.js",
|
||||
"license": "MIT"
|
||||
}
|
||||
178
styles.css
Normal file
178
styles.css
Normal file
|
|
@ -0,0 +1,178 @@
|
|||
|
||||
/* Footer: half width, bottom-right */
|
||||
.ap-footer-player{
|
||||
position:fixed;
|
||||
right:0;
|
||||
bottom:0;
|
||||
padding:4px 8px;
|
||||
display:flex;
|
||||
gap:8px;
|
||||
align-items:center;
|
||||
border-top:1px solid var(--background-modifier-border);
|
||||
background:var(--background-primary);
|
||||
z-index:1000;
|
||||
width:50vw;
|
||||
}
|
||||
.ap-footer-player select{ font-size:12px }
|
||||
.ap-footer-player .ap-title{ position:relative; overflow:hidden; white-space:nowrap; width:26ch; max-width:26ch; }
|
||||
.ap-footer-player input[type="range"]{ margin:0 4px }
|
||||
|
||||
/* Footer title marquee */
|
||||
.ap-title .ap-marquee{ display:inline-block; white-space:nowrap; will-change:transform; animation: apl-marquee linear infinite; animation-duration: var(--apl-marquee-duration, 14s); }
|
||||
.ap-title .ap-marquee:hover{ animation-play-state: paused; }
|
||||
@keyframes apl-marquee{
|
||||
0% { transform: translateX(0); }
|
||||
10% { transform: translateX(0); } /* pause before scroll */
|
||||
90% { transform: translateX(-50%); }
|
||||
100% { transform: translateX(-50%); }
|
||||
}
|
||||
|
||||
/* Footer segmented pill control */
|
||||
.ap-footer-group{
|
||||
display:inline-flex;
|
||||
border:1px solid var(--background-modifier-border);
|
||||
border-radius:999px;
|
||||
overflow:hidden;
|
||||
background: var(--background-secondary);
|
||||
}
|
||||
.ap-footer-seg{
|
||||
appearance:none;
|
||||
border:0;
|
||||
padding:6px 10px;
|
||||
font-size:16px;
|
||||
line-height:1;
|
||||
background:transparent;
|
||||
color:var(--text-normal);
|
||||
cursor:pointer;
|
||||
}
|
||||
.ap-footer-seg + .ap-footer-seg{ border-left:1px solid var(--background-modifier-border); }
|
||||
.ap-footer-seg:hover{ background: rgba(59,130,246,.12); } /* hover light blue */
|
||||
.ap-footer-seg.is-active{
|
||||
background: #3b82f6;
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* Tab layout */
|
||||
.ap-row{display:flex;gap:8px;align-items:center;margin:6px 0}
|
||||
.ap-col{display:flex;flex-direction:column;gap:8px}
|
||||
.ap-title-large{font-weight:600}
|
||||
|
||||
/* Tab controls: subtle border; blue only when active */
|
||||
.ap-controls button{ border:1px solid var(--background-modifier-border); border-radius:8px; padding:2px 8px; }
|
||||
.ap-controls button + button{ margin-left:6px; }
|
||||
.ap-controls button.is-active{ color:#3b82f6; font-weight:600; border-color: var(--interactive-accent); }
|
||||
|
||||
/* Seek & Volume */
|
||||
.ap-seek{display:flex;align-items:center;gap:6px}
|
||||
.ap-seek input[type="range"]{ width:100% }
|
||||
.ap-vol-wide{display:flex;justify-content:center;align-items:center;margin:6px 0}
|
||||
.ap-vol-wide input[type="range"]{ width:60%; max-width:720px }
|
||||
|
||||
/* Track list */
|
||||
.audio-list{display:flex;flex-direction:column;gap:4px;margin-top:8px}
|
||||
.audio-row{display:flex;gap:8px;align-items:center;padding:4px 6px;border-radius:6px;cursor:pointer}
|
||||
.audio-row:hover{background:var(--background-modifier-hover)}
|
||||
.audio-row .apl-right{margin-left:auto;opacity:0.8}
|
||||
.ap-playing-icon{font-size:12px;line-height:1;color:#3b82f6} /* blue */
|
||||
|
||||
/* Ensure Prev/Skip/Stop never show active blue background */
|
||||
.ap-footer-seg.no-active.is-active{ background: transparent; color: var(--text-normal); }
|
||||
|
||||
/* Force plain white icons for Prev/Skip/Stop (never blue) */
|
||||
.ap-footer-seg.plain{
|
||||
color: var(--text-normal) !important;
|
||||
background: transparent !important;
|
||||
}
|
||||
.ap-footer-seg.plain.is-active{
|
||||
color: var(--text-normal) !important;
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
/* Strong override to keep Prev/Skip/Stop as plain icons, never blue */
|
||||
.ap-footer-seg.plain,
|
||||
.ap-footer-seg.plain:hover,
|
||||
.ap-footer-seg.plain.is-active{
|
||||
background: transparent !important;
|
||||
color: var(--text-normal) !important;
|
||||
}
|
||||
|
||||
/* --- Footer plain icons (Prev/Skip/Stop) stronger rules --- */
|
||||
.ap-footer-player .ap-footer-group .ap-footer-seg.plain,
|
||||
.ap-footer-player .ap-footer-group .ap-footer-seg.plain:hover,
|
||||
.ap-footer-player .ap-footer-group .ap-footer-seg.plain.is-active{
|
||||
background: transparent !important;
|
||||
color: var(--text-normal) !important;
|
||||
}
|
||||
/* Remove hover tint specifically for plain icons */
|
||||
.ap-footer-player .ap-footer-group .ap-footer-seg.plain:hover{
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
/* === Tab "table" layout (1.0.13b) === */
|
||||
.audio-header, .audio-row {
|
||||
display: grid;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.audio-header {
|
||||
font-weight: 600;
|
||||
opacity: .85;
|
||||
padding: 4px 6px;
|
||||
border-bottom: 1px solid var(--background-modifier-border);
|
||||
margin-top: 8px;
|
||||
}
|
||||
.audio-row .cell-title,
|
||||
.audio-row .cell-artist,
|
||||
.audio-row .cell-album {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.audio-row .cell-time { text-align: right; opacity: .8; }
|
||||
.audio-row .cell-icon { width: 16px; text-align: center; }
|
||||
|
||||
/* === 1.0.13d: subtler zebra + stronger left alignment === */
|
||||
/* Collapse icon column for non-playing rows so they sit further left - pita to get this right */
|
||||
.audio-row .cell-icon { width: 0; }
|
||||
.audio-row.is-playing .cell-icon { width: 16px; } /* keep space only for the playing row */
|
||||
|
||||
/* Nudge left padding: tighter on non-playing, slight pad on playing */
|
||||
.audio-row { padding-left: 0; }
|
||||
.audio-row.is-playing { padding-left: 2px; }
|
||||
|
||||
/* Very faint zebra striping to barely differentiate rows - will need to review on mobile platforms */
|
||||
.audio-list .audio-row:nth-child(even){ background: rgba(127,127,127,0.04); }
|
||||
.audio-list .audio-row:nth-child(odd){ background: transparent; }
|
||||
|
||||
|
||||
/* === Audio PlugList Settings spacing (dynamic playlists) === */
|
||||
.apl-settings-addwrap{
|
||||
margin-bottom: 12px;
|
||||
padding: 10px;
|
||||
border: 1px dashed var(--background-modifier-border);
|
||||
border-radius:8px;
|
||||
}
|
||||
.apl-addlabel{ font-weight:600; margin-bottom:6px; }
|
||||
.apl-playlist-divider{
|
||||
margin: 14px 0;
|
||||
border-bottom: 1px solid var(--background-modifier-border);
|
||||
opacity: .7;
|
||||
}
|
||||
.apl-section-break{
|
||||
margin: 18px 0 8px 0;
|
||||
border-top: 2px solid var(--background-modifier-border);
|
||||
}
|
||||
|
||||
|
||||
/* === Settings: Playlist Card - box shadow was glitchy === */
|
||||
.apl-card{
|
||||
border: 1px solid var(--background-modifier-border);
|
||||
border-radius: 10px;
|
||||
padding: 12px;
|
||||
margin: 10px 0;
|
||||
background: var(--background-primary);
|
||||
box-shadow: 0 1px 2px rgba(0,0,0,.04);
|
||||
}
|
||||
.apl-card .setting-item-heading{
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
9
versions.json
Normal file
9
versions.json
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
{
|
||||
"2.0.13": "1.8.0",
|
||||
"2.0.13b": "1.8.0",
|
||||
"2.0.13d": "1.8.0",
|
||||
"2.0.13d-align2": "1.8.0",
|
||||
"2.3.1": "1.8.0",
|
||||
"2.3.2": "1.8.0",
|
||||
"1.0.3": "1.8.0"
|
||||
}
|
||||
Loading…
Reference in a new issue