commit 903e5cea0766a0ddaf216122d2c643451ee9e21c Author: ragetrip Date: Tue Aug 19 22:31:16 2025 -0700 feat: initial public release 1.0.3 diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..e5a8660 --- /dev/null +++ b/CHANGELOG.md @@ -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. + diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..f6a64f9 --- /dev/null +++ b/LICENSE @@ -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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..221665a --- /dev/null +++ b/README.md @@ -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). \ No newline at end of file diff --git a/main.js b/main.js new file mode 100644 index 0000000..7e5042d --- /dev/null +++ b/main.js @@ -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(nb)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{ + 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.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[]); + 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=0 && this.currentPlaylist=0 && this.index=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(' — '); +} + diff --git a/manifest.json b/manifest.json new file mode 100644 index 0000000..191522c --- /dev/null +++ b/manifest.json @@ -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" +} \ No newline at end of file diff --git a/styles.css b/styles.css new file mode 100644 index 0000000..76c0268 --- /dev/null +++ b/styles.css @@ -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; +} diff --git a/versions.json b/versions.json new file mode 100644 index 0000000..7cfa09d --- /dev/null +++ b/versions.json @@ -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" +} \ No newline at end of file