///////////////////////////////////////////////////////////////////////////////
// FixedPlaylist.js – isolated playlist view + up/down arrows + scrollbar
// Works with foobar2000 64-bit v2.24.5 + JSplitter 4.0.3
///////////////////////////////////////////////////////////////////////////////
// ==PREPROCESSOR==
// @name "FixedPlaylist (Isolated)"
// @version "1.8" // island-mode
// @author "Original Author + Gemini"
// ==/PREPROCESSOR==
// ────────────────────────────────────────────────────────────────────────────
// 1. CONSTANTS (unchanged except LEN_W = 90 from v1.7)
// ────────────────────────────────────────────────────────────────────────────
const BAR_HEIGHT = 24, LINE_HEIGHT = 60, IMAGE_SIZE = 50;
const ICON_SIZE = 16, ICON_PADDING = 8, PADDING = 8;
const FONT_NAME = "Segoe UI", FONT_SIZE = 16;
const TEXT_COLOR = 0xFFFFFFFF, BG_COLOR = 0xFF1E1E1E, HILITE_COLOR = 0xFF333333;
const SC_W = 12, SC_BG = 0x33000000, SC_FG = 0xFF666666, SC_FG_DRAG = 0xFFAAAAAA,
SC_MIN_THUMB = 24;
const LEN_W = 90; // width reserved for track length column
const DT_LEFT = 0, DT_CENTER = 1, DT_RIGHT = 2, DT_VCENTER = 4,
DT_SINGLELINE = 32, DT_NOPREFIX = 0x800, DT_END_ELLIPSIS = 0x4000;
// ────────────────────────────────────────────────────────────────────────────
// 2. GLOBAL STATE (selIndex is *never* overwritten by fb2k events)
// ────────────────────────────────────────────────────────────────────────────
let selIndex = null; // locked playlist index
let itemCount = 0, visibleCount = 0, scrollOffset = 0;
let txtFont, iconFont, tfAlbum, tfArtist, tfTitle;
let artCache = {};
let needScroll = false, thumbRect = {x:0,y:0,w:0,h:0};
let dragging = false, dragStartY = 0, dragStartOff = 0;
// ────────────────────────────────────────────────────────────────────────────
// 3. INITIALISERS
// ────────────────────────────────────────────────────────────────────────────
function ensureFonts(){
if(!txtFont) txtFont = gdi.Font(FONT_NAME, FONT_SIZE, 0);
if(!iconFont) iconFont = gdi.Font(FONT_NAME, FONT_SIZE, 0);
}
function ensureTF(){
if(!tfAlbum) tfAlbum = fb.TitleFormat("%album%");
if(!tfArtist) tfArtist = fb.TitleFormat("%artist%");
if(!tfTitle) tfTitle = fb.TitleFormat("%title%");
}
// ────────────────────────────────────────────────────────────────────────────
// 4. PLAYLIST-METRICS & SCROLLBAR GEOMETRY
// ────────────────────────────────────────────────────────────────────────────
function ensureSelValid(){
if(plman.PlaylistCount===0){ selIndex = -1; return; }
if(selIndex===null || selIndex<0) selIndex = 0;
else if(selIndex >= plman.PlaylistCount) selIndex = plman.PlaylistCount-1;
}
function updateMetrics(){
ensureSelValid();
if(selIndex === -1){ itemCount=0; visibleCount=0; ensureFonts(); return; }
itemCount = plman.PlaylistItemCount(selIndex);
visibleCount = Math.max(0, Math.floor((window.Height-BAR_HEIGHT)/LINE_HEIGHT));
scrollOffset = Math.min(scrollOffset, Math.max(0, itemCount-visibleCount));
needScroll = itemCount > visibleCount;
ensureFonts(); ensureTF();
computeThumb();
}
function computeThumb(){
if(!needScroll){ thumbRect={x:0,y:0,w:0,h:0}; return; }
const railX=window.Width-SC_W, railY=BAR_HEIGHT, railH=window.Height-BAR_HEIGHT;
const ratio=visibleCount/itemCount, h=Math.max(SC_MIN_THUMB,Math.floor(railH*ratio));
const maxY=railH-h, y=railY+Math.round(maxY*(scrollOffset/(itemCount-visibleCount)));
thumbRect={x:railX,y,w:SC_W,h};
}
// ────────────────────────────────────────────────────────────────────────────
// 5. HELPERS (unchanged)
// ────────────────────────────────────────────────────────────────────────────
const frontPat = [/^f.*\.(jpe?g)$/i,/.*front.*\.(jpe?g|png|bmp|gif)$/i];
const genericPat = [/^cover\.(jpe?g|png|bmp|gif)$/i,/.*\.(jpe?g|png|bmp|gif)$/i];
function findArt(folder){
try{
const fso=new ActiveXObject("Scripting.FileSystemObject");
if(!fso.FolderExists(folder))return null;
const dir=fso.GetFolder(folder);
for(let p of frontPat) for(let f=new Enumerator(dir.Files);!f.atEnd();f.moveNext())
if(p.test(f.item().Name)) return f.item().Path;
for(let p of genericPat)for(let f=new Enumerator(dir.Files);!f.atEnd();f.moveNext())
if(p.test(f.item().Name)) return f.item().Path;
for(let s=new Enumerator(dir.SubFolders);!s.atEnd();s.moveNext()){
const r=findArt(s.item().Path); if(r) return r; }
}catch(_){}
return null;
}
function fmtTime(t){ const m=Math.floor(t/60), s=Math.floor(t%60);
return m+":"+(s<10?"0":"")+s; }
function clamp(v){ return Math.max(0,Math.min(v,itemCount-visibleCount)); }
// ────────────────────────────────────────────────────────────────────────────
// 6. INPUT EVENTS
// ────────────────────────────────────────────────────────────────────────────
function on_mouse_wheel(d){ if(!needScroll)return;
scrollOffset=clamp(scrollOffset-(d>0?3:-3)); computeThumb(); window.Repaint(); }
function on_mouse_lbtn_down(x,y){
// 1. header → cycle inside panel only
if(y<BAR_HEIGHT){
if(plman.PlaylistCount>0){
selIndex = (selIndex+1)%plman.PlaylistCount;
scrollOffset = 0; artCache={}; updateMetrics(); window.Repaint();
}
return;
}
// 2. scrollbar drag / page
if(needScroll && x>=window.Width-SC_W){
if(y>=thumbRect.y && y<=thumbRect.y+thumbRect.h){
dragging=true; dragStartY=y; dragStartOff=scrollOffset;
}else{
scrollOffset = clamp(y<thumbRect.y? scrollOffset-visibleCount
: scrollOffset+visibleCount);
}
computeThumb(); window.Repaint(); return;
}
// 3. playlist interaction
if(selIndex<0||itemCount===0) return;
const row=Math.floor((y-BAR_HEIGHT)/LINE_HEIGHT), idx=scrollOffset+row;
if(idx<0||idx>=itemCount) return;
const xUp=PADDING, xUpE=xUp+ICON_SIZE,
xDn=xUpE+ICON_PADDING, xDnE=xDn+ICON_SIZE;
if(x>=xUp&&x<xUpE) { moveItem(idx,idx-1); return; }
if(x>=xDn&&x<xDnE) { moveItem(idx,idx+1); return; }
plman.ExecutePlaylistDefaultAction(selIndex,idx);
}
function on_mouse_move(x,y){
if(!dragging) return;
const railH=window.Height-BAR_HEIGHT, maxPix=railH-thumbRect.h,
maxOff=itemCount-visibleCount, off=dragStartOff + ((y-dragStartY)/maxPix)*maxOff;
scrollOffset=clamp(Math.round(off)); computeThumb(); window.Repaint();
}
function on_mouse_lbtn_up(){ dragging=false; }
function on_mouse_leave(){ if(!utils.IsKeyPressed(1)) dragging=false; }
function on_mouse_rbtn_down(){ fb.RunMainMenuCommand("File/Add files..."); return true; }
// ────────────────────────────────────────────────────────────────────────────
// 7. MOVE ITEM (unchanged)
// ────────────────────────────────────────────────────────────────────────────
function moveItem(o,n){
if(selIndex<0) return;
n=Math.max(0,Math.min(n,itemCount-1)); if(o===n)return;
plman.ClearPlaylistSelection(selIndex);
plman.SetPlaylistSelectionSingle(selIndex,o,true);
plman.MovePlaylistSelection(selIndex,n-o);
plman.ClearPlaylistSelection(selIndex); window.Repaint();
}
// ────────────────────────────────────────────────────────────────────────────
// 8. PAINT (unchanged from v1.7)
// ────────────────────────────────────────────────────────────────────────────
function on_paint(gr){
ensureFonts(); computeThumb();
gr.FillSolidRect(0,0,window.Width,window.Height,BG_COLOR);
gr.FillSolidRect(0,0,window.Width,BAR_HEIGHT,0xFF2A2A2A);
const plName = (selIndex!==-1 && plman.PlaylistCount>0)?
(plman.GetPlaylistName(selIndex)||"(Unnamed)") : "(No playlist)";
gr.DrawString(`Playlist: ${plName} (${itemCount} items) | L-click cycle, R-click add`,
txtFont,TEXT_COLOR,PADDING,0,window.Width-2*PADDING,BAR_HEIGHT,
DT_VCENTER|DT_LEFT|DT_SINGLELINE|DT_NOPREFIX|DT_END_ELLIPSIS);
if(selIndex<0||itemCount===0){
gr.DrawString("Playlist is empty or not selected.",
txtFont,TEXT_COLOR,PADDING,BAR_HEIGHT+PADDING,
window.Width-2*PADDING,LINE_HEIGHT,
DT_LEFT|DT_VCENTER|DT_SINGLELINE); return;
}
const list = plman.GetPlaylistItems(selIndex);
if(!list||list.Count===0) return;
const rows = Math.min(visibleCount,itemCount-scrollOffset),
availW = window.Width - (needScroll?SC_W:0);
const xUp=PADDING, xDn=xUp+ICON_SIZE+ICON_PADDING, xArt=xDn+ICON_SIZE+ICON_PADDING,
xTxt0=xArt+IMAGE_SIZE+PADDING;
const txtTot = availW - xTxt0 - LEN_W - PADDING,
albumW = Math.floor(txtTot*0.35), artistW=Math.floor(txtTot*0.30),
titleW = txtTot - albumW - artistW;
const xAlbum=xTxt0, xArtist=xAlbum+albumW+PADDING,
xTitle=xArtist+artistW+PADDING, xLen=availW-PADDING-LEN_W;
const flagI = DT_VCENTER|DT_LEFT|DT_SINGLELINE|DT_NOPREFIX|DT_END_ELLIPSIS,
flagG = DT_CENTER|DT_VCENTER|DT_SINGLELINE;
for(let r=0;r<rows;r++){
const idx=scrollOffset+r, h=list[idx], y=BAR_HEIGHT+r*LINE_HEIGHT;
if(plman.PlayingPlaylist===selIndex && idx===plman.PlayingItemIndex)
gr.FillSolidRect(0,y,availW,LINE_HEIGHT,HILITE_COLOR);
const icoY=y+(LINE_HEIGHT-ICON_SIZE)/2;
gr.DrawString("▲",iconFont,TEXT_COLOR,xUp,icoY,ICON_SIZE,ICON_SIZE,flagG);
gr.DrawString("▼",iconFont,TEXT_COLOR,xDn,icoY,ICON_SIZE,ICON_SIZE,flagG);
const folder=utils.SplitFilePath(h.Path)[0];
let art=artCache[folder];
if(art===undefined){ const p=findArt(folder); art=p?gdi.Image(p):null; artCache[folder]=art; }
const artY=y+(LINE_HEIGHT-IMAGE_SIZE)/2;
if(art) gr.DrawImage(art,xArt,artY,IMAGE_SIZE,IMAGE_SIZE,0,0,art.Width,art.Height);
else {
gr.DrawRect(xArt,artY,IMAGE_SIZE,IMAGE_SIZE,1,0xFF505050);
gr.DrawString("N/A",txtFont,0xFF808080,
xArt,artY,IMAGE_SIZE,IMAGE_SIZE,
DT_CENTER|DT_VCENTER|DT_SINGLELINE); }
gr.DrawString(tfAlbum .EvalWithMetadb(h)||"-",txtFont,TEXT_COLOR,
xAlbum ,y,albumW ,LINE_HEIGHT,flagI);
gr.DrawString(tfArtist.EvalWithMetadb(h)||"-",txtFont,TEXT_COLOR,
xArtist,y,artistW,LINE_HEIGHT,flagI);
gr.DrawString(tfTitle .EvalWithMetadb(h)||"-",txtFont,TEXT_COLOR,
xTitle ,y,titleW ,LINE_HEIGHT,flagI);
gr.DrawString(h.Length?fmtTime(h.Length):"-",txtFont,TEXT_COLOR,
xLen ,y,LEN_W ,LINE_HEIGHT,
DT_RIGHT|DT_VCENTER|DT_SINGLELINE|DT_NOPREFIX);
}
if(needScroll){
gr.FillSolidRect(window.Width-SC_W,BAR_HEIGHT,SC_W,window.Height-BAR_HEIGHT,SC_BG);
gr.FillSolidRect(thumbRect.x,thumbRect.y,thumbRect.w,thumbRect.h,
dragging?SC_FG_DRAG:SC_FG); }
}
// ────────────────────────────────────────────────────────────────────────────
// 9. FB2K CALLBACKS (only those that must keep the view consistent)
// ────────────────────────────────────────────────────────────────────────────
function on_init() { updateMetrics(); }
function on_size() { updateMetrics(); window.Repaint(); }
/* IMPORTANT: unlike earlier versions, the panel deliberately IGNORES the user
switching playlists elsewhere. We only refresh sizes. */
function on_playlist_switch() { updateMetrics(); window.Repaint(); }
/* Keep track of deletions / additions to maintain validity, but never overwrite
selIndex unless it becomes out-of-range (handled in ensureSelValid()). */
function on_playlists_changed() { updateMetrics(); window.Repaint(); }
function on_playlist_items_added(p){ if(p===selIndex){updateMetrics(); window.Repaint();} }
function on_playlist_items_removed(p){ if(p===selIndex){updateMetrics(); window.Repaint();} }
function on_playlist_items_reordered(p){if(p===selIndex) window.Repaint(); }
function on_playback_new_track(){ window.Repaint(); }
function on_playback_stop(){ window.Repaint(); }
///////////////////////////////////////////////////////////////////////////////