feat: initial public release 1.0.3

This commit is contained in:
ragetrip 2025-08-19 22:31:16 -07:00
commit 903e5cea07
7 changed files with 1073 additions and 0 deletions

6
CHANGELOG.md Normal file
View 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
View 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
View 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 miniplayer** with shuffle/repeat and optional fadeout 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 miniplayer** 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).
- **Fadeout 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 its in the Community store)
If you use the **BRAT** plugin:
1. In BRAT, add this repos URL.
2. Let BRAT install and enable the plugin.
### Community Store (once approved)
After the plugin is accepted into the Obsidian Community Plugin store, youll 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 miniplayer** 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 miniplayer**
- **Fade out on Stop**
- **Fade duration (ms)**
---
## 🔍 Known Limitations
- **External links & CORS**: Link playlists require **direct audio URLs**; many hosts disallow crossorigin 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 Obsidians 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 miniplayer, shuffle/repeat, fadeout 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 its 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
View 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
View 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
View 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
View 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"
}