mirror of
https://github.com/ragetrip/audio-pluglist.git
synced 2026-07-22 06:44:43 +00:00
1325 lines
No EOL
55 KiB
JavaScript
1325 lines
No EOL
55 KiB
JavaScript
/* == 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, normalizePath } = require('obsidian');
|
||
const VIEW_TYPE = 'audio-pluglist-view';
|
||
const AUDIO_EXTS = { mp3:1, wav:1, m4a:1, flac:1, ogg:1, aac:1 };
|
||
|
||
function isDirectAudioUrl(u){ return /\.(mp3|m4a|wav|ogg|flac|aac)(\?|#|$)/i.test(u); }
|
||
function getYouTubePlaylistId(input){
|
||
try { const u = new URL(input); const id = u.searchParams.get("list"); if (id) return id; } catch {}
|
||
const m = input.match(/[?&]list=([a-zA-Z0-9_-]+)/); return m ? m[1] : null;
|
||
}
|
||
function ytPlaylistEmbedSrc(listId){
|
||
return `https://www.youtube-nocookie.com/embed/videoseries?list=${encodeURIComponent(listId)}&rel=0&modestbranding=1&enablejsapi=1`;
|
||
}
|
||
function isSoundCloudUrl(u){ return /(^|\.)soundcloud\.com\/.+/i.test(u) || /(^|\.)on\.soundcloud\.com\//i.test(u); }
|
||
function isSoundCloudSet(u){ return /soundcloud\.com\/[^/]+\/sets\/[^/]+/i.test(u); }
|
||
function scEmbedSrc(u, visual=false){
|
||
const clean = u.split('#')[0].split('?')[0];
|
||
const enc = encodeURIComponent(clean);
|
||
const base = `https://w.soundcloud.com/player/?url=${enc}&color=%23ff5500&auto_play=false&show_teaser=true&visual=true`;
|
||
return base;
|
||
}
|
||
|
||
function fetchSoundCloudOEmbed(url, maxheight){
|
||
try {
|
||
const clean = url.split('#')[0].split('?')[0];
|
||
const endpoint = `https://soundcloud.com/oembed?format=json&url=${encodeURIComponent(clean)}${maxheight?`&maxheight=${maxheight}`:''}`;
|
||
return fetch(endpoint).then(r=>r.ok?r.json():null).then(j=>{
|
||
if (j && j.html) return j.html;
|
||
return null;
|
||
}).catch(()=>null);
|
||
} catch(e){ return Promise.resolve(null); }
|
||
}
|
||
function getSpotifyPlaylistId(input){
|
||
try { const u = new URL(input); const parts = u.pathname.split("/").filter(Boolean);
|
||
const idx = parts.indexOf("playlist"); if (idx >= 0 && parts[idx+1]) return parts[idx+1];
|
||
} catch {}
|
||
return null;
|
||
}
|
||
function spotifyEmbedSrc(id){ return `https://open.spotify.com/embed/playlist/${id}`; }
|
||
|
||
function getDeezerPlaylistId(input){
|
||
try{
|
||
const u = new URL(input);
|
||
const m = u.pathname.match(/\/playlist\/(\d+)/i);
|
||
if (m) return m[1];
|
||
}catch(e){}
|
||
const rx = /deezer\.com\/(?:[a-z]{2}\/)?playlist\/(\d+)/i;
|
||
const m2 = (input||'').match(rx);
|
||
return m2 ? m2[1] : null;
|
||
}
|
||
|
||
|
||
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(self, sel){
|
||
while (sel.firstChild) sel.removeChild(sel.firstChild);
|
||
var pls = self.settings.playlists;
|
||
var opt0 = document.createElement('option'); opt0.value='-1'; opt0.textContent='(no playlist selected)'; sel.appendChild(opt0);
|
||
for (var i=0;i<pls.length;i++){
|
||
var cfg = pls[i]||{};
|
||
var name = (cfg.name||('Playlist '+(i+1)));
|
||
if (cfg.type==='folder') name = '📂 ' + name;
|
||
else if (cfg.type==='link') name = '🔗 ' + name;
|
||
var opt = document.createElement('option'); opt.value=String(i); opt.textContent=name; 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','apl-modal-overlay');
|
||
var modal = mk('div','apl-modal');
|
||
overlay.appendChild(modal);
|
||
|
||
var head = mk('div','apl-modal-head');
|
||
head.appendChild(mk('b', '', 'Select a folder'));
|
||
var search = mk('input','apl-modal-search'); search.type='search'; search.placeholder='Filter folders...'; head.appendChild(search);
|
||
|
||
var body = mk('div','apl-modal-body');
|
||
var list = mk('div');
|
||
|
||
var footer = mk('div','apl-modal-footer');
|
||
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;';
|
||
(function(folderPath){
|
||
row.onclick = function(){ self.onPick(folderPath); self.close(); };
|
||
})(f);
|
||
if (list) 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();
|
||
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([]);
|
||
if (!self.plugin.embedByPlaylist) self.plugin.embedByPlaylist = [];
|
||
self.plugin.embedByPlaylist.push(null);
|
||
await self.plugin.saveSettings();
|
||
self.display();
|
||
});
|
||
});
|
||
|
||
// Link button //
|
||
new Setting(addWrap)
|
||
.setName('From link')
|
||
.setDesc('Create a playlist from a link: YouTube playlist, SoundCloud track/set, Spotify playlist, or a direct audio URL (.mp3/.m4a/.wav/.ogg/.flac/.aac)')
|
||
.addButton(b=>{
|
||
b.setButtonText('Add playlist from link').onClick(async ()=>{
|
||
self.plugin.settings.playlists.push({ type:'link', name:'', link:'' });
|
||
self.plugin.tracksByPlaylist.push([]);
|
||
if (!self.plugin.embedByPlaylist) self.plugin.embedByPlaylist = [];
|
||
self.plugin.embedByPlaylist.push(null);
|
||
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 = normalizePath((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.embedByPlaylist) self.plugin.embedByPlaylist.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);
|
||
|
||
|
||
|
||
// ====== Equalizer Settings ====== //
|
||
(function(){
|
||
try{
|
||
var eqCard = containerEl.createEl('div', { cls: 'apl-eq-card' });
|
||
eqCard.createEl('div', { text: 'Custom Equalizer Settings', cls: 'apl-eq-title' });
|
||
|
||
// Active preset dropdown (inside card at top) //
|
||
new Setting(eqCard)
|
||
.setName('Active Preset')
|
||
.setDesc('Select which preset is applied to local audio playback (folder tracks / direct audio URLs). Embedded players cannot be EQ’d.')
|
||
.addDropdown(function(drop){
|
||
drop.addOption('none','None');
|
||
drop.addOption('0','Preset 0 — Default (Flat)');
|
||
drop.addOption('1','Preset 1 — Warm');
|
||
drop.addOption('2','Preset 2 — Bright');
|
||
drop.addOption('3','Preset 3 — V-Curve');
|
||
drop.setValue(String(self.plugin.settings.activeEQ ?? 'none'));
|
||
drop.onChange(async function(v){
|
||
self.plugin.settings.activeEQ = (v === 'none') ? 'none' : Number(v);
|
||
await self.plugin.saveSettings();
|
||
try{ self.plugin._aplApplyEQ(); }catch(e){}
|
||
});
|
||
});
|
||
|
||
eqCard.createEl('div', { text: 'Enter 10 comma-separated numbers (dB) for bands: 32, 62, 128, 250, 500, 1k, 2k, 4k, 8k, 16k Hz.', cls: 'apl-eq-sub' });
|
||
|
||
function parsePreset(str){
|
||
var nums = (str||'').split(',').map(function(s){ return Number(String(s).trim()); });
|
||
if (nums.length !== 10) return null;
|
||
for (var i=0;i<nums.length;i++){ if (!isFinite(nums[i])) return null; }
|
||
return nums;
|
||
}
|
||
|
||
function makePresetRow(p){
|
||
var row = eqCard.createEl('div', { cls:'apl-eq-row' });
|
||
row.createEl('div', { text: 'EQ-' + p + ' gains (dB)', cls:'apl-eq-label' });
|
||
|
||
var canvas = row.createEl('canvas', { cls:'apl-eq-graph' });
|
||
canvas.width = 200; canvas.height = 50;
|
||
|
||
var presetArr = (self.plugin.settings.eqPresets && self.plugin.settings.eqPresets[p]) ? self.plugin.settings.eqPresets[p] : [0,0,0,0,0,0,0,0,0,0];
|
||
try{ self.plugin._aplDrawEQGraph(canvas, presetArr); }catch(e){}
|
||
|
||
var input = row.createEl('input', { cls:'apl-eq-input', attr:{ value: presetArr.join(', ') } });
|
||
|
||
input.addEventListener('change', async function(){
|
||
var parsed = parsePreset(input.value);
|
||
if (!parsed){
|
||
new Notice('Invalid EQ preset. Must be 10 numbers, comma-separated.');
|
||
input.value = presetArr.join(', ');
|
||
return;
|
||
}
|
||
self.plugin.settings.eqPresets[p] = parsed;
|
||
await self.plugin.saveSettings();
|
||
presetArr = parsed;
|
||
try{ self.plugin._aplDrawEQGraph(canvas, presetArr); }catch(e){}
|
||
try{ self.plugin._aplApplyEQ(); }catch(e){}
|
||
});
|
||
|
||
var resetBtn = row.createEl('button', { cls:'apl-eq-reset' });
|
||
resetBtn.textContent = 'Reset';
|
||
resetBtn.onclick = async function(){
|
||
// Reset Preset 0 to flat; others to built-in defaults //
|
||
var defaults = [
|
||
[0,0,0,0,0,0,0,0,0,0],
|
||
[3,2.5,1.5,0.5,0,-0.5,0,0.5,1,1.5],
|
||
[0,0.5,1,1.5,3,3.5,4,3.5,2,1.5],
|
||
[3,2.5,2,0.5,-1.5,-2,-0.5,1.5,2.5,2]
|
||
];
|
||
self.plugin.settings.eqPresets[p] = defaults[p].slice();
|
||
await self.plugin.saveSettings();
|
||
presetArr = self.plugin.settings.eqPresets[p];
|
||
input.value = presetArr.join(', ');
|
||
try{ self.plugin._aplDrawEQGraph(canvas, presetArr); }catch(e){}
|
||
try{ self.plugin._aplApplyEQ(); }catch(e){}
|
||
};
|
||
}
|
||
|
||
// Render Presets 0-3 //
|
||
for (var p=0;p<4;p++) makePresetRow(p);
|
||
|
||
}catch(e){ try{ console.error('APL EQ settings UI failed', e); }catch(_){ } }
|
||
})();
|
||
|
||
// Break line above "Re-scan all playlists" //
|
||
containerEl.createEl('div', { cls: 'apl-section-break' });
|
||
|
||
// Keep remaining settings area as-is for better grouping. Looks better in on smaller devices like iPad and mobile devices //
|
||
new Setting(containerEl)
|
||
.setName('Re-scan all playlists')
|
||
.addButton(function(btn){ btn.setButtonText('Scan All').onClick(() => self.plugin.indexAll());
|
||
|
||
|
||
new Setting(containerEl)
|
||
.setName('YouTube privacy (youtube-nocookie.com)')
|
||
.setDesc('Use the privacy-enhanced domain for YouTube embeds. Helps avoid Error 153.')
|
||
.addToggle(t=>{
|
||
t.setValue(self.plugin.settings.youtubePrivacy === true)
|
||
.onChange(async v=>{ self.plugin.settings.youtubePrivacy = !!v; await self.plugin.saveSettings(); });
|
||
});
|
||
});
|
||
|
||
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 'cassette-tape'; };
|
||
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);
|
||
// Add refresh button next to playlist dropdown //
|
||
var refreshBtn = mk('button','ap-refresh-btn','⟳');
|
||
refreshBtn.title = 'Re-scan playlists';
|
||
refreshBtn.onclick = function(){ try{ this.plugin.indexAll(); new Notice('Audio PlugList: Re-scanning playlists…'); }catch(e){} }.bind(this);
|
||
rowTop.appendChild(refreshBtn);
|
||
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);
|
||
|
||
// Embedded player for link-based playlists //
|
||
var cp = this.plugin.currentPlaylist;
|
||
var cfgp = (cp>=0) ? (this.plugin.settings.playlists[cp]||{}) : {};
|
||
var activeEmbed = (cfgp && cfgp.type==='link') ? (this.plugin.embedByPlaylist && this.plugin.embedByPlaylist[cp] || null) : null;
|
||
if (activeEmbed && activeEmbed.src){
|
||
|
||
// APL_FORCE_SC_IFRAME - SoundCloud injection (track height fix) - was sensitive //
|
||
try{
|
||
if (activeEmbed && activeEmbed.provider === 'soundcloud'){
|
||
var _raw = (activeEmbed.original || activeEmbed.src || (cfgp && cfgp.link) || '');
|
||
var _clean = (_raw||'').split('#')[0].split('?')[0];
|
||
var _isSet = _clean.indexOf('/sets/') !== -1;
|
||
var targetHeight = _isSet ? 600 : 420; // ensures large UI for tracks //
|
||
|
||
// Dedicated container and iframe //
|
||
var _wrap = mk('div','ap-embedbox');
|
||
var _ifr = document.createElement('iframe');
|
||
_ifr.width = '100%';
|
||
_ifr.height = String(targetHeight);
|
||
_ifr.frameBorder = '0';
|
||
_ifr.scrolling = 'no';
|
||
_ifr.allow = 'autoplay; clipboard-write; encrypted-media; picture-in-picture';
|
||
_ifr.allowFullscreen = true;
|
||
_ifr.src = 'https://w.soundcloud.com/player/?visual=true&url=' + encodeURIComponent(_clean) + '&show_artwork=true';
|
||
|
||
// Insert at top of column and stop further renders //
|
||
col.appendChild(_wrap);
|
||
_wrap.appendChild(_ifr);
|
||
return;
|
||
}
|
||
}catch(e){}
|
||
var wrap = mk('div','ap-embedbox');
|
||
var iframe = wrap.createEl('iframe', { attr: {
|
||
src: activeEmbed.src,
|
||
width: '100%',
|
||
height: String(activeEmbed.height||380),
|
||
frameborder: '0',
|
||
allow: 'autoplay; clipboard-write; encrypted-media; picture-in-picture; web-share',
|
||
allowfullscreen: 'true',
|
||
scrolling: 'no'
|
||
}});
|
||
col.appendChild(wrap);
|
||
// Try oEmbed for SoundCloud - fallback to iframe
|
||
try {
|
||
if (activeEmbed && activeEmbed.provider==='soundcloud'){
|
||
var _src0 = (activeEmbed && (activeEmbed.original||activeEmbed.src)) || (cfgp && cfgp.link) || '';
|
||
fetchSoundCloudOEmbed(_src0, activeEmbed.height||450).then(function(html){
|
||
if (html){
|
||
while (wrap.firstChild) wrap.removeChild(wrap.firstChild);
|
||
try {
|
||
var doc = new DOMParser().parseFromString(html, 'text/html');
|
||
var body = doc && doc.body;
|
||
if (body){
|
||
Array.from(body.childNodes).forEach(function(n){ wrap.appendChild(n); });
|
||
}
|
||
} catch(_e){
|
||
}
|
||
}
|
||
});
|
||
}
|
||
} catch(e){}
|
||
|
||
try {
|
||
var _openSrc = (activeEmbed && (activeEmbed.original || activeEmbed.src)) || (cfgp && cfgp.link) || '';
|
||
var btnRow = mk('div','ap-row ap-embed-actions');
|
||
var btn = mk('button','ap-open-ext ap-open-ext--mini','Optional: Open In Browser');
|
||
btn.onclick = function(){ try{ window.open(_openSrc, '_blank'); }catch(e){} };
|
||
btnRow.appendChild(btn);
|
||
// Add single-playlist re-scan button (mini)
|
||
var resBtn = mk('button','ap-open-ext ap-open-ext--mini','Re-scan playlist');
|
||
resBtn.onclick = function(){ try{ var _cp = cp; this.plugin.indexPlaylist(_cp).then(()=>{ new Notice('Audio PlugList: re-scanned this playlist.'); if (this.plugin.onUiRefresh) this.plugin.onUiRefresh(); }); }catch(e){} }.bind(this);
|
||
btnRow.appendChild(resBtn);
|
||
|
||
col.appendChild(btnRow);
|
||
} catch(e){}
|
||
|
||
}
|
||
|
||
// Controls (hidden when an external embed is active) //
|
||
var controls = null;
|
||
if (!(activeEmbed && activeEmbed.src)) { controls = mk('div', 'ap-row ap-controls'); col.appendChild(controls); }
|
||
var prev = mk('button', '', 'Prev ◀︎◀︎'); prev.title='Previous track';
|
||
var play = mk('button', '', (this.plugin.audio.paused ? 'Play ▶︎' : 'Pause Ⅱ')); play.title='Play/pause';
|
||
var next = mk('button', '', 'Skip ▶︎▶︎'); next.title='Next track';
|
||
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';
|
||
var eqBtn = mk('button', '', 'EQ'); eqBtn.title='Equalizer preset (click to cycle)';
|
||
if (controls) { controls.appendChild(prev); controls.appendChild(play); controls.appendChild(next); controls.appendChild(stop); controls.appendChild(shuffle); controls.appendChild(repeatBtn); controls.appendChild(eqBtn); }
|
||
|
||
if (controls) prev.onclick = function(){ this.plugin.prev(); }.bind(this);
|
||
if (controls) play.onclick = function(){ this.plugin.togglePlay(); play.textContent = (this.plugin.audio.paused ? 'Play ▶︎' : 'Pause Ⅱ'); }.bind(this);
|
||
if (controls) next.onclick = function(){ this.plugin.next(); }.bind(this);
|
||
if (controls) stop.onclick = function(){ if (this.plugin.settings.fadeOnStop) this.plugin.stopFade(this.plugin.settings.fadeMs||3000); else this.plugin.stop(); }.bind(this);
|
||
if (controls && this.plugin.shuffle) shuffle.classList.add('is-active');
|
||
if (controls) shuffle.onclick = function(){ this.plugin.toggleShuffle(); shuffle.textContent = this.plugin.shuffle ? 'Shuffle (ON)' : 'Shuffle'; shuffle.classList.toggle('is-active', this.plugin.shuffle); }.bind(this);
|
||
if (controls) repeatBtn.classList.toggle('is-active', this.plugin.repeatMode !== 'off');
|
||
if (controls) 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);
|
||
|
||
// EQ preset cycle button //
|
||
if (controls) {
|
||
try{ this.plugin._aplSyncEqButtonState(eqBtn); }catch(_){}
|
||
eqBtn.onclick = function(){
|
||
try{ this.plugin._aplCycleEqPreset(); }catch(_){}
|
||
try{ this.plugin._aplSyncEqButtonState(eqBtn); }catch(_){}
|
||
}.bind(this);
|
||
}
|
||
|
||
// Seek //
|
||
var seekRow = null;
|
||
if (!(activeEmbed && activeEmbed.src)) { 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');
|
||
if (seekRow) { seekRow.appendChild(cur); seekRow.appendChild(range); seekRow.appendChild(dur); }
|
||
if (seekRow) range.oninput = function(){ this.plugin.seekToFraction(Number(range.value)/1000); }.bind(this);
|
||
|
||
// Volume (centered, wide) //
|
||
var volRow = null;
|
||
if (!(activeEmbed && activeEmbed.src)) { volRow = mk('div', 'ap-row ap-vol-wide'); col.appendChild(volRow); if (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));
|
||
if (volRow) volRow.appendChild(vol);
|
||
if (volRow) vol.oninput = function(){ this.plugin.setVolume(Number(vol.value)/100); }.bind(this);
|
||
|
||
// Track list (row click to play, Title - Artist, duration right, current with 🔊 icon) //
|
||
var list = null;
|
||
if (!(activeEmbed && activeEmbed.src)) { 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 = list ? mk('div','audio-header') : null;
|
||
if (header) header.style.gridTemplateColumns = gridCols();
|
||
if (header) header.appendChild(mk('div','cell-icon',''));
|
||
if (header) header.appendChild(mk('div','cell-title','Title'));
|
||
if (header && showArtist) header.appendChild(mk('div','cell-artist','Artist'));
|
||
if (header && showAlbum) header.appendChild(mk('div','cell-album','Album'));
|
||
if (header) { header.appendChild(mk('div','cell-time','Time')); if (list) list.appendChild(header); }
|
||
if (list) list.appendChild(header);
|
||
|
||
var tracks = this.plugin.getTracks();
|
||
if (list && !tracks.length) list.appendChild(mk('div','', 'No audio files found for this playlist.'));
|
||
for (var i=0;i<tracks.length;i++){ if (!list) break;
|
||
(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); };
|
||
if (list) 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,
|
||
youtubePrivacy: true,
|
||
eqBands: [32,62,128,250,500,1000,2000,4000,8000,16000],
|
||
eqPresets: [
|
||
[0,0,0,0,0,0,0,0,0,0], // Preset 0 — Default (Flat)
|
||
[3,2.5,1.5,0.5,0,-0.5,0,0.5,1,1.5], // Preset 1 — Warm
|
||
[0,0.5,1,1.5,3,3.5,4,3.5,2,1.5], // Preset 2 — Bright
|
||
[3,2.5,2,0.5,-1.5,-2,-0.5,1.5,2.5,2] // Preset 3 — V-Curve
|
||
],
|
||
activeEQ: "none",
|
||
}, await this.loadData());
|
||
|
||
this.tracksByPlaylist = Array.from({length: this.settings.playlists.length}, ()=>[]);
|
||
this.embedByPlaylist = Array.from({length: this.settings.playlists.length}, ()=>null);
|
||
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();
|
||
// Equalizer (menu-only; applies to local audio playback only) //
|
||
this._aplEq = { ctx: null, source: null, filters: null };
|
||
try { this._aplInitEQ(this.audio); } catch(e) { try{console.error('APL EQ init failed', e);}catch(_){} }
|
||
this.shuffle = true; // default 'true' by user request //
|
||
this.repeatMode = 'all'; // default 'all' by user request //
|
||
this.onUiRefresh = null;
|
||
|
||
this.audio.addEventListener('ended', this._onEnded.bind(this));
|
||
|
||
// Ribbon icon //
|
||
this.addRibbonIcon('cassette-tape', '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:'Rescan all playlists', callback: () => this.indexAll() });
|
||
|
||
this.addSettingTab(new SettingsTab(this.app, this));
|
||
|
||
// Register per-playlist hotkeys (one command per playlist) //
|
||
this._registerPlaylistHotkeys();
|
||
|
||
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() {
|
||
try{ this._aplDestroyEQ(); }catch(e){}
|
||
this._detachFooter();
|
||
this.audio.pause(); this.audio.src = '';
|
||
}
|
||
|
||
async saveSettings(){ await this.saveData(this.settings); try{ this._registerPlaylistHotkeys(); }catch(e){} }
|
||
|
||
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 ⏮ | ⏯ | ⏭ | ⏹ | 🔀 | 🔁 - some of these had to be done the way that are. Switched from 1.0.3 due to mobile and device view issues so below ones are different but working. //
|
||
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'); };
|
||
|
||
|
||
// EQ preset cycle button (0-3) //
|
||
var bEq = controls.createEl('button', { cls:'ap-footer-seg', text:'EQ', title:'Equalizer preset (click to cycle)' });
|
||
this._aplEqFooterBtn = bEq;
|
||
try{ this._aplSyncEqButtonState(bEq); }catch(_){}
|
||
bEq.onclick = async () => {
|
||
try{ this._aplCycleEqPreset(); }catch(_){}
|
||
try{ this._aplSyncEqButtonState(bEq); }catch(_){}
|
||
};
|
||
|
||
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;
|
||
|
||
// 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; } this._aplEqFooterBtn = 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 (typeof this.onUiRefresh === 'function') this.onUiRefresh();
|
||
return;
|
||
}
|
||
var next = clamp(i,0,this.settings.playlists.length-1);
|
||
if (this.currentPlaylist !== next){
|
||
try{ this._stopAllPlayback(); }catch(_){ }
|
||
}
|
||
this.currentPlaylist = next;
|
||
this.settings.currentPlaylist = this.currentPlaylist;
|
||
this.saveSettings();
|
||
this._applyPlaylistQueue();
|
||
if (typeof this.onUiRefresh === 'function') this.onUiRefresh();
|
||
}
|
||
|
||
|
||
|
||
// Per-playlist Hotkeys //
|
||
togglePlaylistByIndex(i){
|
||
try{
|
||
i = Number(i);
|
||
if (!isFinite(i)) return;
|
||
if (i<0 || i >= (this.settings.playlists ? this.settings.playlists.length : 0)) return;
|
||
if (this.currentPlaylist !== i) this.setCurrentPlaylist(i);
|
||
this.togglePlay();
|
||
}catch(e){ try{console.error('APL togglePlaylistByIndex error', e);}catch(_){} }
|
||
}
|
||
|
||
_registerPlaylistHotkeys(){
|
||
try{
|
||
const cmdApi = (this.app && this.app.commands) ? this.app.commands : null;
|
||
const cmds = cmdApi && cmdApi.commands ? cmdApi.commands : {};
|
||
const pls = (this.settings && Array.isArray(this.settings.playlists)) ? this.settings.playlists : [];
|
||
for (let i=0;i<pls.length;i++){
|
||
const cfg = pls[i] || {};
|
||
const id = `apl-toggle-playlist-${i}`;
|
||
const label = `Toggle play/pause — ${cfg.name || ('Playlist ' + (i+1))}`;
|
||
if (!(cmds && cmds[id])){
|
||
this.addCommand({ id, name: label, callback: ()=> this.togglePlaylistByIndex(i) });
|
||
} else {
|
||
// Update name & callback in place so bindings persist //
|
||
try { cmds[id].name = label; } catch(_){}
|
||
try { cmds[id].callback = ()=> this.togglePlaylistByIndex(i); } catch(_){}
|
||
}
|
||
}
|
||
}catch(e){ try{console.error('APL _registerPlaylistHotkeys error', e);}catch(_){} }
|
||
}
|
||
|
||
|
||
// Indexing //
|
||
|
||
// Stop any active playback (internal audio + external embeds) //
|
||
_stopAllPlayback(){
|
||
try{
|
||
if (this.audio){ try{ this.audio.pause(); }catch(_){ } try{ this.audio.currentTime = 0; }catch(_){ } }
|
||
try{
|
||
var frames = document.querySelectorAll('.ap-embedbox iframe');
|
||
frames.forEach(function(fr){ try{ fr.src = 'about:blank'; }catch(_){ } });
|
||
}catch(_){ }
|
||
}catch(_){ }
|
||
try{ if (this.onUiRefresh) this.onUiRefresh(); }catch(_){ }
|
||
}
|
||
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]=[]; if (this.embedByPlaylist) this.embedByPlaylist[i]=null; new Notice('Audio PlugList: link missing for \"'+name+'\".'); return; }
|
||
|
||
if (!this.embedByPlaylist) this.embedByPlaylist = Array.from({length: this.settings.playlists.length}, ()=>null);
|
||
|
||
// YouTube playlist (iframe embed) //
|
||
var ytid = getYouTubePlaylistId(link);
|
||
if (ytid){
|
||
this.tracksByPlaylist[i] = [];
|
||
this.embedByPlaylist[i] = { provider:'youtube', src: (self?self.plugin.settings.youtubePrivacy:this.settings.youtubePrivacy) ? `https://www.youtube-nocookie.com/embed/videoseries?list=${encodeURIComponent(ytid)}&rel=0&modestbranding=1&enablejsapi=1` : `https://www.youtube.com/embed/videoseries?list=${encodeURIComponent(ytid)}&rel=0&modestbranding=1&enablejsapi=1`, height: 480, original: link };
|
||
new Notice('Audio PlugList: embedded YouTube playlist.');
|
||
return;
|
||
}
|
||
|
||
// SoundCloud (track or set) //
|
||
if (isSoundCloudUrl(link)){
|
||
this.tracksByPlaylist[i] = [];
|
||
var _isSet = isSoundCloudSet(link);
|
||
this.embedByPlaylist[i] = { provider:'soundcloud', src: scEmbedSrc(link, _isSet), height: (_isSet?600:166) };
|
||
new Notice('Audio PlugList: embedded SoundCloud.');
|
||
return;
|
||
}
|
||
|
||
// Spotify playlist (embed; playback may be short previews without prem) //
|
||
var spid = getSpotifyPlaylistId(link);
|
||
if (spid){
|
||
this.tracksByPlaylist[i] = [];
|
||
this.embedByPlaylist[i] = { provider:'spotify', src: spotifyEmbedSrc(spid), height: 380, original: link };
|
||
new Notice('Audio PlugList: embedded Spotify playlist.');
|
||
return;
|
||
// Deezer playlist (no iframe embed; open externally due to login overlay) //
|
||
var dzid = getDeezerPlaylistId(link);
|
||
if (dzid){
|
||
this.tracksByPlaylist[i] = []; // avoid trying to parse tracks here //
|
||
this.embedByPlaylist[i] = null; // no embed; rely on action row buttons //
|
||
try { new Notice('Deezer playlist detected. Use "Open In Browser" to play.'); } catch(e) {}
|
||
return;
|
||
}
|
||
|
||
}
|
||
|
||
// Direct audio URL //
|
||
if (isDirectAudioUrl(link)){
|
||
var t = { id: link, path: link, title: name, meta: { artist:'', title:name, album:'' }, duration: null };
|
||
this.tracksByPlaylist[i] = [t];
|
||
this.embedByPlaylist[i] = null;
|
||
this._preloadDurations(i);
|
||
return;
|
||
}
|
||
|
||
// Fallback: not embeddable //
|
||
this.tracksByPlaylist[i] = [];
|
||
this.embedByPlaylist[i] = { provider:'external', src: link, height: 0, openExternally:true };
|
||
new Notice('Audio PlugList: link is not embeddable or a direct audio file.');
|
||
return;
|
||
}
|
||
// FOLDER-based (default) //
|
||
var cfg = this.settings.playlists[i] || { folderPath:'' };
|
||
var fp = normalizePath((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 = /^https?:\/\//i.test(t.path) ? t.path : 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(); }
|
||
|
||
_activeEmbed(){
|
||
try {
|
||
var cp = this.currentPlaylist;
|
||
var cfg = (cp>=0) ? (this.settings.playlists[cp]||{}) : null;
|
||
if (!cfg || cfg.type!=='link') return null;
|
||
return (this.embedByPlaylist && this.embedByPlaylist[cp]) || null;
|
||
} catch(e){ return null; }
|
||
}
|
||
togglePlay(){
|
||
var __em = this._activeEmbed && this._activeEmbed();
|
||
if (__em && __em.src){ new Notice('This playlist uses an embedded player. Use the player in the tab.'); if (this.activateView) this.activateView(); return; }
|
||
|
||
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(){
|
||
var __em = this._activeEmbed && this._activeEmbed();
|
||
if (__em && __em.src){ new Notice('This playlist uses an embedded player. Use the player in the tab.'); if (this.activateView) this.activateView(); return; }
|
||
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(){
|
||
var __em = this._activeEmbed && this._activeEmbed();
|
||
if (__em && __em.src){ new Notice('This playlist uses an embedded player. Use the player in the tab.'); if (this.activateView) this.activateView(); return; }
|
||
|
||
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(){
|
||
var __em = this._activeEmbed && this._activeEmbed();
|
||
if (__em && __em.src){ new Notice('This playlist uses an embedded player. Use the player in the tab.'); if (this.activateView) this.activateView(); return; }
|
||
|
||
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); }
|
||
|
||
|
||
// ===== Equalizer Engine (menu-only) ===== //
|
||
_aplHasWebAudio(){
|
||
try{ return !!(window.AudioContext || window.webkitAudioContext); }catch(e){ return false; }
|
||
}
|
||
_aplInitEQ(audioEl){
|
||
if (!audioEl || !this._aplHasWebAudio()) return;
|
||
if (this._aplEq && this._aplEq.ctx && this._aplEq.source) return; // already init //
|
||
|
||
const AC = window.AudioContext || window.webkitAudioContext;
|
||
const ctx = new AC();
|
||
let source = null;
|
||
try {
|
||
source = ctx.createMediaElementSource(audioEl);
|
||
} catch(e){
|
||
// If this fails (rare), just skip EQ rather than breaking the plugin. //
|
||
try{ console.warn('APL EQ: createMediaElementSource failed, bypassing EQ.', e); }catch(_){}
|
||
try{ ctx.close(); }catch(_){}
|
||
return;
|
||
}
|
||
|
||
const freqs = (this.settings && Array.isArray(this.settings.eqBands) && this.settings.eqBands.length===10)
|
||
? this.settings.eqBands
|
||
: [32,62,128,250,500,1000,2000,4000,8000,16000];
|
||
|
||
const filters = freqs.map((freq) => {
|
||
const f = ctx.createBiquadFilter();
|
||
f.type = 'peaking';
|
||
f.frequency.value = freq;
|
||
f.Q.value = 1.1;
|
||
f.gain.value = 0;
|
||
return f;
|
||
});
|
||
|
||
// Chain: source -> f0 -> ... -> f9 -> destination //
|
||
let chain = source;
|
||
for (let i=0;i<filters.length;i++){ chain.connect(filters[i]); chain = filters[i]; }
|
||
chain.connect(ctx.destination);
|
||
|
||
this._aplEq = { ctx, source, filters };
|
||
|
||
// Resume on user gesture/play //
|
||
try{
|
||
audioEl.addEventListener('play', () => {
|
||
try{ if (ctx && ctx.state === 'suspended') ctx.resume().catch(()=>{}); }catch(_){}
|
||
});
|
||
}catch(_){}
|
||
|
||
// Apply current preset //
|
||
this._aplApplyEQ();
|
||
}
|
||
|
||
_aplEqPresetName(i){
|
||
try{
|
||
const n = Number(i);
|
||
if (n === 0) return "Preset 0 — Default";
|
||
if (n === 1) return "Preset 1 — Warm";
|
||
if (n === 2) return "Preset 2 — Bright";
|
||
if (n === 3) return "Preset 3 — V-Curve";
|
||
}catch(_){}
|
||
return "Preset";
|
||
}
|
||
|
||
_aplSyncEqButtonState(btn){
|
||
try{
|
||
if (!btn) return;
|
||
const active = (this.settings && (this.settings.activeEQ !== undefined)) ? this.settings.activeEQ : "none";
|
||
let idx = (active === "none" || active === null || active === undefined || active === "") ? null : Number(active);
|
||
if (idx === null || !isFinite(idx)) idx = null;
|
||
|
||
const label = (idx === null) ? "EQ" : ("EQ" + idx);
|
||
btn.textContent = label;
|
||
|
||
if (idx === null){
|
||
btn.title = "Equalizer: None (click to cycle presets 0–3)";
|
||
} else {
|
||
btn.title = "Equalizer: " + this._aplEqPresetName(idx) + " (click to cycle)";
|
||
}
|
||
}catch(_){}
|
||
}
|
||
|
||
_aplCycleEqPreset(){
|
||
try{
|
||
const active = (this.settings && (this.settings.activeEQ !== undefined)) ? this.settings.activeEQ : "none";
|
||
let idx = (active === "none" || active === null || active === undefined || active === "") ? -1 : Number(active);
|
||
if (!isFinite(idx)) idx = -1;
|
||
|
||
// cycle: none -> 0 -> 1 -> 2 -> 3 -> 0 ... //
|
||
idx = (idx < 0) ? 0 : ((idx + 1) % 4);
|
||
|
||
this.settings.activeEQ = idx;
|
||
|
||
// persist + apply //
|
||
try{ this.saveSettings(); }catch(_){}
|
||
try{ this._aplApplyEQ(); }catch(_){}
|
||
|
||
// keep footer button in sync //
|
||
try{ if (this._aplEqFooterBtn) this._aplSyncEqButtonState(this._aplEqFooterBtn); }catch(_){}
|
||
}catch(e){
|
||
try{ console.error("APL EQ cycle error", e); }catch(_){}
|
||
}
|
||
}
|
||
|
||
|
||
_aplDestroyEQ(){
|
||
try{
|
||
if (this._aplEq && this._aplEq.filters){
|
||
try{ this._aplEq.filters.forEach(f=>{ try{ f.disconnect(); }catch(_){}}); }catch(_){}
|
||
}
|
||
if (this._aplEq && this._aplEq.source){ try{ this._aplEq.source.disconnect(); }catch(_){ } }
|
||
if (this._aplEq && this._aplEq.ctx){ try{ this._aplEq.ctx.close(); }catch(_){ } }
|
||
}catch(_){}
|
||
this._aplEq = { ctx:null, source:null, filters:null };
|
||
}
|
||
|
||
_aplApplyEQ(){
|
||
try{
|
||
if (!this._aplEq || !this._aplEq.filters) return;
|
||
|
||
const filters = this._aplEq.filters;
|
||
const active = (this.settings && (this.settings.activeEQ !== undefined)) ? this.settings.activeEQ : "none";
|
||
|
||
// none => bypass (all gains 0) //
|
||
if (active === "none" || active === null || active === undefined){
|
||
for (let i=0;i<filters.length;i++) filters[i].gain.value = 0;
|
||
return;
|
||
}
|
||
|
||
let idx = Number(active);
|
||
if (!isFinite(idx)) idx = 0;
|
||
idx = Math.max(0, Math.min(3, idx));
|
||
|
||
const presets = (this.settings && Array.isArray(this.settings.eqPresets)) ? this.settings.eqPresets : null;
|
||
const preset = (presets && presets[idx] && presets[idx].length===10) ? presets[idx] : [0,0,0,0,0,0,0,0,0,0];
|
||
|
||
for (let i=0;i<filters.length;i++){
|
||
const v = Number(preset[i] || 0);
|
||
filters[i].gain.value = isFinite(v) ? v : 0;
|
||
}
|
||
}catch(e){ try{ console.warn('APL EQ apply failed', e); }catch(_){ } }
|
||
}
|
||
|
||
_aplDrawEQGraph(canvas, values){
|
||
try{
|
||
const ctx = canvas.getContext('2d');
|
||
const w = canvas.width, h = canvas.height;
|
||
ctx.clearRect(0,0,w,h);
|
||
|
||
// background grid //
|
||
ctx.fillStyle = getComputedStyle(canvas).getPropertyValue('--background-secondary') || '#222';
|
||
ctx.fillRect(0,0,w,h);
|
||
|
||
ctx.strokeStyle = 'rgba(255,255,255,0.10)';
|
||
ctx.lineWidth = 1;
|
||
for (let i=1;i<5;i++){
|
||
const y = (h/5)*i;
|
||
ctx.beginPath(); ctx.moveTo(0,y); ctx.lineTo(w,y); ctx.stroke();
|
||
}
|
||
|
||
ctx.strokeStyle = '#a78bfa'; // purple-ish //
|
||
ctx.lineWidth = 2;
|
||
ctx.beginPath();
|
||
|
||
const arr = Array.isArray(values) ? values : [];
|
||
const step = w / (10 - 1);
|
||
for (let i=0;i<10;i++){
|
||
const v = Number(arr[i] || 0);
|
||
const y = (h/2) - (isFinite(v)?v:0) * 5.5;
|
||
const x = i * step;
|
||
if (i===0) ctx.moveTo(x,y);
|
||
else ctx.lineTo(x,y);
|
||
}
|
||
ctx.stroke();
|
||
}catch(_){}
|
||
}
|
||
|
||
_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(' — ');
|
||
}
|
||
|
||
// APL SoundCloud guard: force widget URL + height //
|
||
(function(){
|
||
var fixedFlag = 'data-apl-sc-fixed';
|
||
function toWidgetUrl(pageUrl){
|
||
try {
|
||
var clean = String(pageUrl||'').split('#')[0].split('?')[0];
|
||
return 'https://w.soundcloud.com/player/?visual=true&url=' + encodeURIComponent(clean) + '&show_artwork=true';
|
||
} catch(e){ return pageUrl; }
|
||
}
|
||
function fixIn(root){
|
||
if (!root) return;
|
||
var frames = root.querySelectorAll('iframe[src*="://soundcloud.com/"]:not(['+fixedFlag+'])');
|
||
frames.forEach(function(ifr){
|
||
try {
|
||
var w = toWidgetUrl(ifr.src);
|
||
if (w && w.indexOf('w.soundcloud.com/player') !== -1) {
|
||
ifr.setAttribute('src', w);
|
||
}
|
||
var h = parseInt(ifr.getAttribute('height') || '0', 10);
|
||
if (!h || h < 200) ifr.setAttribute('height', '420');
|
||
ifr.setAttribute('allow', 'autoplay; clipboard-write; encrypted-media; picture-in-picture');
|
||
ifr.setAttribute(fixedFlag, '1');
|
||
} catch(_){}
|
||
});
|
||
}
|
||
function activeViewContent(){
|
||
return document.querySelector('.workspace-leaf.mod-active .view-content');
|
||
}
|
||
function kick(){
|
||
var vc = activeViewContent();
|
||
if (vc) fixIn(vc);
|
||
}
|
||
// initial nudges //
|
||
var count = 0;
|
||
var timer = setInterval(function(){
|
||
kick();
|
||
if (++count > 15) clearInterval(timer); // ~12s //
|
||
}, 800);
|
||
|
||
// observe future DOM changes (lightweight filter) //
|
||
var obs = new MutationObserver(function(muts){
|
||
for (var i=0;i<muts.length;i++){
|
||
var n = muts[i].target;
|
||
if (!n) continue;
|
||
// only re-scan iframes are being added or attributes change //
|
||
if (muts[i].type === 'childList' || muts[i].type === 'attributes') {
|
||
kick();
|
||
break;
|
||
}
|
||
}
|
||
});
|
||
try {
|
||
obs.observe(document.body, { childList:true, subtree:true, attributes:true, attributeFilter:['src','height'] });
|
||
} catch(_){}
|
||
})();
|
||
|
||
// I know i don't have to end with "//" but i'm slightly color blind so it helps me when my eyes are tired. //
|