foobar2000 播放器上基于 foo_uie_jsplitter 组件的媒体库树脚本

智享阁
智享阁
管理员
121
文章
22
粉丝
电脑应用评论3字数 4457阅读14分51秒阅读模式

foobar2000 播放器上基于 foo_uie_jsplitter 组件的轻量化媒体库树脚本,采用文件夹结构展示目录树,支持树层级、即时搜索、层级展开/折叠、发送到指定列表和定位到正在播放的项目、支持默认用户界面的字体系统和颜色系统、支持系统缩放。

文章源自《智享阁》智享阁-https://www.esnpc.com/foobar2000-jsplitter-media-library-tree-js/

使用方法: 添加一个 jsplitter 面板,导入 MLTree.js 文件即可。文章源自《智享阁》智享阁-https://www.esnpc.com/foobar2000-jsplitter-media-library-tree-js/

'use strict';
window.DefinePanel("media library tree", {author: "添加硬件"});
window.DlgCode = 0x0004;
window.DrawMode = 1;

// ========== Win32 绘制常量 ==========
const DT_LEFT = 0x00000000;
const DT_RIGHT = 0x00000002;
const DT_CENTER = 0x00000001;
const DT_VCENTER = 0x00000004;
const DT_SINGLELINE = 0x00000020;
const DT_NOPREFIX = 0x00000800;
const DT_END_ELLIPSIS = 0x00008000;

// ========== DUI 枚举 ==========
const ColourTypeDUI = {
	text: 0,
	background: 1,
	highlight: 2,
	selection: 3
};

const FontTypeDUI = {
	playlists: 3
};

// ========== DUI 运行时变量 ==========
let zdpi = 1;                        // 缩放因子
let g_fname = 'Microsoft YaHei UI';  // 基准字体名

// ========== 配置常量 ==========
const CONFIG = {
	// ---- 节点布局 ----
	itemHeight: 35,
	indentPerLevel: 14,
	sideMarkerWidth: 4,
	sideMarkerHeightRatio: 0.5,

	// ---- 箭头图标 ----
	treeArrowFontName: 'Segoe UI Symbol',
	treeArrowFontSize: 16,
	arrowIconSize: 14,
	arrowIconOffsetX: 5,

	// ---- 搜索栏 ----
	searchBarHeight: 35,
	searchBarPadding: 8,
	searchTextFontSize: 12,
	searchIconFontSize: 16,
	searchIconFontName: 'Guifx v2 Transports',
	clearBtnFontName: 'Wingdings 2',

	// ---- 曲目计数 ----
	trackCountPadding: 14,
	trackCountOffsetFromRight: 15,

	// ---- 滚动条 ----
	scrollbarWidth: 10,
	scrollbarNarrowWidth: 2,
	scrollbarButtonHeight: 12,
	scrollbarHideDelay: 1500,
	scrollbarCheckExtraWidth: 5,
	minThumbHeight: 20,
	scrollbarArrowFontName: 'Segoe UI Symbol',
	scrollbarArrowFontSize: 10,

	// ---- 行为 ----
	scrollStep: 3,
	loadDelay: 100,

	// ---- 文本 ----
	loadingText: '加载中...',
	noDataText: '没有数据可显示',

	// ---- 名称 ----
	rootNodeName: '媒体库',
	rootDirName: '(根目录)',
	playlistNameStandard: '媒体库查看',
	playlistNamePlaying: '媒体库查看(正在播放)',

	// ---- 排序与备份 ----
	sortFormat: '%path_sort% %album% %album artist%',
	maxUndoBackupCount: 5000
};

const collator = new Intl.Collator(undefined, {
	sensitivity: 'accent',
	numeric: true
});

// ========== 状态命名空间 ==========
const view = {
	w: 0,
	h: 0,
	visibleItems: 0
};

const tree = {
	data: [],
	selected: null,
	byId: new Map(),
	cachedVisible: null,
	cachedVisibleDirty: true
};

const scroll = {
	position: 0,
	hover: false,
	dragging: false,
	dragStartY: 0,
	dragStartScroll: 0,
	active: false,
	minimized: true,
	arrowHover: null,
	arrowDown: null
};

const search = {
	text: '',
	cursorPos: 0,
	cursorVisible: true,
	visibleIds: null,
	inputActive: false,
	start: 0,
	end: 0,
	offset: 0
};

const nowPlaying = {
	node: null,
	trackPath: '',
	nodePath: new Set()
};

const library = {
	root: '',
	handles: null,
	isEmpty: false
};

const ui = {
	isWallpaper: false,
	clearBtnRect: null,
	userSelectingNode: false
};

const cache = {
	countWidths: [],
	needRecalc: false
};

const timers = {
	loading: null,
	scrollbarHide: null,
	userSelecting: null,
	searchCursor: null
};

const theme = {
	textColor: 0,
	bgColor: 0,
	hlColor: 0,
	stColor: 0
};

const fonts = {
	main: null,
	large: null,
	search: null,
	treeArrow: null,
	scrollbarArrow: null,
	searchIcon: null,
	clearBtn: null
};

// ========== 数据结构 ==========
function TreeNode(id, name, type, parentId, data) {
	this.id = id;
	this.name = name;
	this.type = type;
	this.parentId = parentId;
	this.data = data || {};
	this.children = [];
	this.expanded = false;
	this.selected = false;
	this.level = 0;
	this.trackCount = 0;
	this.item = [];
	this.searchCount = undefined;
}

// ========== DUI 颜色 / 字体 ==========
function get_color() {
	theme.textColor = window.GetColourDUI(ColourTypeDUI.text);
	theme.bgColor   = window.GetColourDUI(ColourTypeDUI.background);
	theme.stColor   = window.GetColourDUI(ColourTypeDUI.selection);
	theme.hlColor   = window.GetColourDUI(ColourTypeDUI.highlight);
}

function get_font() {
	let fsize = 12;
	let fstyle = 0;
	try {
		const f = window.GetFontDUI(FontTypeDUI.playlists);
		g_fname = f.Name;
		fsize = f.Size;
		fstyle = f.Style;
	} catch (e) {
		console.log("字体警告:无法使用默认字体,改用 Microsoft YaHei UI");
		g_fname = 'Microsoft YaHei UI';
		fsize = 12;
		fstyle = 0;
	}
	zdpi = fsize / 12;
	fonts.main  = gdi.Font(g_fname, fsize, fstyle);
	fonts.large = gdi.Font(g_fname, fsize * 1.35, 2);
}

// ========== DPI 缩放 ==========
function applyDpiScaling() {
	const h = window.GetProperty('行高', 35);

	CONFIG.itemHeight                = Math.floor(((h && h > 0) ? h : 35) * zdpi);
	CONFIG.indentPerLevel            = Math.floor(zdpi * 14);
	CONFIG.treeArrowFontSize         = Math.floor(zdpi * 16);
	CONFIG.arrowIconSize             = Math.floor(zdpi * 14);
	CONFIG.arrowIconOffsetX          = Math.floor(zdpi * 5);
	CONFIG.sideMarkerWidth           = Math.floor(zdpi * 4);
	CONFIG.searchBarHeight           = Math.floor(zdpi * 35);
	CONFIG.searchBarPadding          = Math.floor(zdpi * 8);
	CONFIG.searchTextFontSize        = Math.floor(zdpi * 12);
	CONFIG.searchIconFontSize        = Math.floor(zdpi * 16);
	CONFIG.trackCountOffsetFromRight = Math.floor(zdpi * 15);
	CONFIG.trackCountPadding         = Math.floor(zdpi * 14);
	CONFIG.scrollbarWidth            = Math.floor(zdpi * 10);
	CONFIG.scrollbarNarrowWidth      = Math.floor(zdpi * 2);
	CONFIG.scrollbarButtonHeight     = Math.floor(zdpi * 12);
	CONFIG.scrollbarCheckExtraWidth  = Math.floor(zdpi * 5);
	CONFIG.minThumbHeight            = Math.floor(zdpi * 20);
	CONFIG.scrollbarArrowFontSize    = Math.floor(zdpi * 10);

	fonts.treeArrow      = gdi.Font(CONFIG.treeArrowFontName, CONFIG.treeArrowFontSize, 1);
	fonts.scrollbarArrow = gdi.Font(CONFIG.scrollbarArrowFontName, CONFIG.scrollbarArrowFontSize);
	fonts.searchIcon     = gdi.Font(CONFIG.searchIconFontName, CONFIG.searchIconFontSize);
	fonts.clearBtn       = gdi.Font(CONFIG.clearBtnFontName, CONFIG.searchIconFontSize);
}

function recalcCountWidths(gr) {
	let s = '';
	for (let d = 1; d <= 10; d++) {
		s += '9';
		cache.countWidths[d] = gr.CalcTextWidth(s, fonts.main);
	}
}

// ========== 数据加载与构建 ==========
function loadFolderTree() {
	tree.data = [];
	tree.byId.clear();
	tree.cachedVisible = null;
	tree.cachedVisibleDirty = true;
	library.isEmpty = false;

	const rootNode = new TreeNode('root', CONFIG.rootNodeName, 'root', null);
	rootNode.expanded = true;
	tree.data.push(rootNode);

	library.handles = fb.GetLibraryItems();
	if (library.handles.Count > 0) {
		const firstPath = library.handles[0].Path;
		if (firstPath) {
			const dir = firstPath.substring(0, firstPath.lastIndexOf('\\'));
			const parts = dir.split('\\');
			library.root = parts.slice(0, -1).join('\\') + '\\';
		}
	}
	const total = library.handles.Count;
	library.isEmpty = (total === 0);

	if (library.isEmpty) {
		tree.byId.set(rootNode.id, rootNode);
		updateScrollbarState();
		window.Repaint();
		return;
	}

	const relativePaths = library.handles.GetLibraryRelativePaths();
	const folderTree = {};

	for (let i = 0; i < total; i++) {
		const relPath = relativePaths[i] || '';
		if (!relPath) {
			if (!folderTree[CONFIG.rootDirName]) folderTree[CONFIG.rootDirName] = {};
			if (!folderTree[CONFIG.rootDirName].__tracks__) folderTree[CONFIG.rootDirName].__tracks__ = [];
			if (!folderTree[CONFIG.rootDirName].__direct_tracks__) folderTree[CONFIG.rootDirName].__direct_tracks__ = [];
			folderTree[CONFIG.rootDirName].__tracks__.push(i);
			folderTree[CONFIG.rootDirName].__direct_tracks__.push(i);
		} else {
			const parts = relPath.split('\\').filter(function(p) { return p; });
			if (parts.length <= 1) {
				if (!folderTree[CONFIG.rootDirName]) folderTree[CONFIG.rootDirName] = {};
				if (!folderTree[CONFIG.rootDirName].__tracks__) folderTree[CONFIG.rootDirName].__tracks__ = [];
				if (!folderTree[CONFIG.rootDirName].__direct_tracks__) folderTree[CONFIG.rootDirName].__direct_tracks__ = [];
				folderTree[CONFIG.rootDirName].__tracks__.push(i);
				folderTree[CONFIG.rootDirName].__direct_tracks__.push(i);
			} else {
				let current = folderTree;
				for (let j = 0; j < parts.length - 1; j++) {
					const part = parts[j];
					if (!current[part]) current[part] = {};
					if (!current[part].__tracks__) current[part].__tracks__ = [];
					current = current[part];
					current.__tracks__.push(i);
					if (j === parts.length - 2) {
						if (!current.__direct_tracks__) current.__direct_tracks__ = [];
						current.__direct_tracks__.push(i);
					}
				}
			}
		}
	}

	createFolderNodesFromTree(folderTree, rootNode, '');

	rootNode.trackCount = library.handles ? library.handles.Count : 0;
	rootNode.name = CONFIG.rootNodeName + ' (' + rootNode.children.length + '个文件夹)';

	for (let k = 0; k < tree.data.length; k++) {
		tree.byId.set(tree.data[k].id, tree.data[k]);
	}
}

function createFolderNodesFromTree(nodeTree, parentNode, currentPath) {
	const keys = Object.keys(nodeTree).filter(function(k) { return k !== '__tracks__' && k !== '__direct_tracks__'; });
	keys.sort(function(a, b) { return collator.compare(a, b); });

	for (let i = 0; i < keys.length; i++) {
		const folderName = keys[i];
		const fullPath = currentPath ? currentPath + '\\' + folderName : folderName;
		const node = new TreeNode('folder_' + fullPath, folderName, 'folder', parentNode.id);
		node.data.path = fullPath;
		node.data.fullPath = fullPath;
		parentNode.children.push(node);
		tree.data.push(node);
		createFolderNodesFromTree(nodeTree[folderName], node, fullPath);

		const tracks = nodeTree[folderName].__tracks__ || [];
		node.trackCount = tracks.length;

		if (tracks.length > 0) {
			const sortedTracks = tracks.slice().sort(function(a, b) { return a - b; });
			let start = sortedTracks[0];
			let end = sortedTracks[0];
			for (let ti = 1; ti < sortedTracks.length; ti++) {
				if (sortedTracks[ti] === end + 1) {
					end = sortedTracks[ti];
				} else {
					node.item.push({start: start, end: end});
					start = sortedTracks[ti];
					end = sortedTracks[ti];
				}
			}
			node.item.push({start: start, end: end});
		}

		const directTracks = nodeTree[folderName].__direct_tracks__ || [];

		if (directTracks.length > 0) {
			const decorated = directTracks.map(function(t) {
				const h = library.handles[t];
				const p = h.Path;
				return {
					idx: t,
					name: p.substring(p.lastIndexOf('\\') + 1),
					raw: h.RawPath || ''
				};
			});
			decorated.sort(function(a, b) {
				if (a.name !== b.name) return collator.compare(a.name, b.name);
				return a.raw.localeCompare(b.raw);
			});

			for (let j = 0; j < decorated.length; j++) {
				const trackIndex = decorated[j].idx;
				const handle = library.handles[trackIndex];
				const trackPath = handle.Path;
				const subSong = handle.SubSong || 0;
				const trackId = subSong > 0 ? 'track_' + trackPath + '_' + subSong : 'track_' + trackPath;
				let trackName = trackPath.substring(trackPath.lastIndexOf('\\') + 1);
				if (subSong > 0) {
					const tfo = fb.TitleFormat('%tracknumber% - %title%');
					trackName = tfo.EvalWithMetadb(handle);
				}
				const trackNode = new TreeNode(trackId, trackName, 'track', node.id);
				trackNode.data.path = trackPath;
				trackNode.data.handleIndex = trackIndex;
				trackNode.data.subSong = subSong;
				node.children.push(trackNode);
				tree.data.push(trackNode);
			}
		}
	}
}

// ========== 可见性与缓存 ==========
function recalcVisibleItems() {
	const treeAreaHeight = view.h - CONFIG.searchBarHeight;
	view.visibleItems = Math.max(1, Math.floor(treeAreaHeight / CONFIG.itemHeight));
}

function invalidateVisibleNodesCache() {
	tree.cachedVisibleDirty = true;
}

function getVisibleNodes() {
	if (search.text && search.visibleIds) {
		const result = [];
		function traverse(list, level) {
			for (const node of list) {
				if (search.visibleIds.has(node.id)) {
					node.level = level;
					result.push(node);
					if (node.expanded && node.children.length) {
						traverse(node.children, level + 1);
					}
				}
			}
		}
		if (tree.data.length) traverse([tree.data[0]], 0);
		return result;
	}
	if (tree.cachedVisibleDirty || !tree.cachedVisible) {
		tree.cachedVisible = [];
		function traverse(list, level) {
			for (const node of list) {
				node.level = level;
				tree.cachedVisible.push(node);
				if (node.expanded && node.children.length) traverse(node.children, level + 1);
			}
		}
		if (tree.data.length) traverse([tree.data[0]], 0);
		tree.cachedVisibleDirty = false;
	}
	return tree.cachedVisible;
}

// ========== 搜索 ==========
function performSearch() {
	if (!search.text) {
		for (let i = 0; i < tree.data.length; i++) {
			tree.data[i].searchCount = undefined;
		}
		search.visibleIds = null;
		tree.cachedVisibleDirty = true;
		return;
	}

	const searchLower = search.text.toLowerCase();
	search.visibleIds = new Set();

	function markVisible(node) {
		let matchCount = 0;
		const nameMatch = node.name.toLowerCase().indexOf(searchLower) !== -1;

		if (node.type === 'track') {
			matchCount = nameMatch ? 1 : 0;
		} else {
			for (let i = 0; i < node.children.length; i++) {
				matchCount += markVisible(node.children[i]);
			}
		}

		if (nameMatch || matchCount > 0) {
			search.visibleIds.add(node.id);
			node.searchCount = matchCount;
			return matchCount;
		}
		return 0;
	}

	if (tree.data.length > 0) {
		markVisible(tree.data[0]);
	}

	tree.cachedVisibleDirty = true;
}

function clearSearch() {
	search.text = '';
	search.cursorPos = 0;
	search.start = 0;
	search.end = 0;
	search.offset = 0;
	for (let i = 0; i < tree.data.length; i++) {
		tree.data[i].searchCount = undefined;
	}
	search.visibleIds = null;
	tree.cachedVisibleDirty = true;
	scroll.position = 0;
	window.Repaint();
}

function toggleSearchCursor() {
	search.cursorVisible = !search.cursorVisible;
	window.RepaintRect(0, 0, view.w, CONFIG.searchBarHeight, true);
}

function startSearchCursorBlink() {
	search.cursorVisible = true;
	if (timers.searchCursor) window.ClearTimeout(timers.searchCursor);
	timers.searchCursor = window.SetInterval(function() {
		toggleSearchCursor();
	}, 500);
}

function stopSearchCursorBlink() {
	if (timers.searchCursor) {
		window.ClearInterval(timers.searchCursor);
		timers.searchCursor = null;
	}
}

// ========== 绘制函数 ==========
function drawSearchbar(gr) {
	const searchBgColor = search.inputActive
		? ((theme.textColor & 0x00ffffff) | 0x20000000)
		: ((theme.textColor & 0x00ffffff) | 0x0C000000);
	gr.FillSolidRect(0, 0, view.w, CONFIG.searchBarHeight, searchBgColor);

	const sepColor = (theme.textColor & 0x00ffffff) | 0x10ffffff;
	gr.FillSolidRect(0, CONFIG.searchBarHeight - 1, view.w, 1, sepColor);

	const paddingX = CONFIG.searchBarPadding;

	const iconColor = search.inputActive ? theme.textColor : ((theme.textColor & 0x00ffffff) | 0x60000000);
	const iconWidth = gr.CalcTextWidth(')', fonts.searchIcon) + 4;
	gr.GdiDrawText(')', fonts.searchIcon, iconColor,
		paddingX + 2, 0, iconWidth, CONFIG.searchBarHeight,
		DT_LEFT | DT_VCENTER | DT_SINGLELINE | DT_NOPREFIX);

	let textStartX = paddingX + iconWidth + 4;
	let textRectW = view.w - textStartX - paddingX;

	if (search.text) {
		const clearBtnW = gr.CalcTextWidth(String.fromCharCode(0xF0CE), fonts.clearBtn) + 8;
		const clearBtnX = view.w - paddingX - clearBtnW;
		ui.clearBtnRect = { x: clearBtnX, y: 0, w: clearBtnW, h: CONFIG.searchBarHeight };
		textRectW = clearBtnX - textStartX - 4;
		gr.GdiDrawText(String.fromCharCode(0xF0CE), fonts.clearBtn, iconColor,
			clearBtnX, 0, clearBtnW, CONFIG.searchBarHeight,
			DT_LEFT | DT_VCENTER | DT_SINGLELINE | DT_NOPREFIX);
	} else {
		ui.clearBtnRect = null;
	}

	if (search.text) {
		const displayText = search.text.substring(search.offset);
		gr.GdiDrawText(displayText, fonts.main, theme.textColor,
			textStartX, 0, textRectW, CONFIG.searchBarHeight,
			DT_LEFT | DT_VCENTER | DT_SINGLELINE | DT_NOPREFIX);
	} else if (!search.inputActive) {
		const placeholderColor = (theme.textColor & 0x00ffffff) | 0x40000000;
		gr.GdiDrawText('搜索', fonts.main, placeholderColor,
			textStartX, 0, textRectW, CONFIG.searchBarHeight,
			DT_LEFT | DT_VCENTER | DT_SINGLELINE | DT_NOPREFIX);
	}

	if (search.inputActive && search.cursorVisible && search.start == search.end) {
		const cursorText = search.text.substring(search.offset, search.cursorPos);
		const cursorX = textStartX + gr.CalcTextWidth(cursorText, fonts.main);
		const cursorHeight = Math.min(CONFIG.searchBarHeight - 10, CONFIG.searchTextFontSize);
		const cursorY = Math.floor((CONFIG.searchBarHeight - cursorHeight) / 2);
		gr.DrawLine(cursorX, cursorY, cursorX, cursorY + cursorHeight, 1, theme.textColor);
	}
}

function drawTreeNode(gr, node, yPos, rowIndex) {
	if (!node) return;
	const effectiveLevel = node.level <= 1 ? 0 : node.level - 1;
	const isEvenRow = rowIndex % 2 === 0;
	const isNowPlaying = nowPlaying.nodePath.has(node.id);

	let rowBgColor = 0;
	let focusBorderColor = 0;

	if (node.selected) {
		rowBgColor = theme.stColor & 0x3cffffff;
		focusBorderColor = theme.stColor & 0x4bffffff;
	} else if (isNowPlaying) {
		rowBgColor = theme.stColor & 0x35ffffff;
	} else if (isEvenRow) {
		rowBgColor = theme.textColor & 0x05ffffff;
	}

	const rowTextColor = node.selected
		? (theme.hlColor & 0xbfffffff)
		: (isNowPlaying ? theme.hlColor : theme.textColor);

	if (rowBgColor !== 0) {
		gr.FillSolidRect(0, yPos, view.w, CONFIG.itemHeight, rowBgColor);
	}

	if (focusBorderColor !== 0) {
		gr.DrawRect(0, yPos, view.w - 1, CONFIG.itemHeight - 1, 1.0, focusBorderColor);
	}

	if (isNowPlaying) {
		const markerH = Math.floor(CONFIG.itemHeight * CONFIG.sideMarkerHeightRatio);
		const markerY = yPos + Math.floor((CONFIG.itemHeight - markerH) / 2);
		gr.FillSolidRect(0, markerY, CONFIG.sideMarkerWidth, markerH, theme.hlColor);
	}

	const arrowX = CONFIG.sideMarkerWidth + CONFIG.arrowIconOffsetX + effectiveLevel * CONFIG.indentPerLevel;
	let textX;

	if (node.type === 'folder' && node.children.length > 0) {
		const iconText = node.expanded ? 'v' : '>';
		gr.GdiDrawText(iconText, fonts.main, rowTextColor,
			arrowX, yPos, CONFIG.arrowIconSize, CONFIG.itemHeight,
			DT_LEFT | DT_VCENTER | DT_SINGLELINE);
		textX = arrowX + CONFIG.arrowIconSize;
	} else if (node.type === 'track') {
		gr.GdiDrawText('\u266A', fonts.main, rowTextColor,
			arrowX, yPos, CONFIG.arrowIconSize, CONFIG.itemHeight,
			DT_LEFT | DT_VCENTER | DT_SINGLELINE);
		textX = arrowX + CONFIG.arrowIconSize;
	} else {
		textX = arrowX;
	}

	let countText = "";
	let countWidth = 0;
	let countValue = node.trackCount;
	if (search.text && typeof node.searchCount === 'number') {
		countValue = node.searchCount;
	}
	if (countValue > 0) {
		countText = String(countValue);
		countWidth = cache.countWidths[countText.length] || 0;
		if (countWidth === 0) {
			countWidth = gr.CalcTextWidth(countText, fonts.main);
		}
	}

	const countRight = view.w - CONFIG.trackCountOffsetFromRight;
	const nameRight = (countWidth > 0)
		? (countRight - countWidth - CONFIG.trackCountPadding)
		: countRight;
	const textWidth = nameRight - textX;

	gr.GdiDrawText(node.name, fonts.main, rowTextColor, textX, yPos, textWidth, CONFIG.itemHeight,
		DT_LEFT | DT_VCENTER | DT_SINGLELINE | DT_END_ELLIPSIS | DT_NOPREFIX);

	if (countWidth > 0) {
		gr.GdiDrawText(countText, fonts.main, rowTextColor,
			countRight - countWidth, yPos,
			countWidth + 2, CONFIG.itemHeight,
			DT_LEFT | DT_VCENTER | DT_SINGLELINE);
	}
}

function getArrowColor(name) {
	if (scroll.arrowDown === name)  return theme.textColor;
	if (scroll.arrowHover === name) return theme.textColor & 0x99ffffff;
	return theme.textColor & 0x55ffffff;
}

function drawScrollbar(gr) {
	const m = getScrollbarMetrics();
	if (!m) return;

	let currentWidth = scroll.minimized ? CONFIG.scrollbarNarrowWidth : CONFIG.scrollbarWidth;
	const actualX = view.w - currentWidth;
	const trackTop = CONFIG.searchBarHeight;
	const trackH = view.h - trackTop;

	if (currentWidth > CONFIG.scrollbarNarrowWidth) {
		gr.FillSolidRect(actualX, trackTop, currentWidth, trackH, theme.textColor & 0x15ffffff);

		gr.GdiDrawText('▲', fonts.scrollbarArrow, getArrowColor('up'),
			actualX, trackTop, currentWidth, CONFIG.scrollbarButtonHeight,
			DT_CENTER | DT_VCENTER | DT_SINGLELINE);
		gr.GdiDrawText('▼', fonts.scrollbarArrow, getArrowColor('down'),
			actualX, view.h - CONFIG.scrollbarButtonHeight, currentWidth, CONFIG.scrollbarButtonHeight,
			DT_CENTER | DT_VCENTER | DT_SINGLELINE);
	}

	let thumbColor;
	if (scroll.dragging) thumbColor = theme.textColor & 0x99ffffff;
	else if (scroll.hover) thumbColor = theme.textColor & 0x55ffffff;
	else thumbColor = theme.textColor & 0x33ffffff;

	gr.FillSolidRect(actualX, m.thumbY, currentWidth, m.thumbHeight, thumbColor);
}

// ========== 滚动条逻辑 ==========
function updateScrollbarState() {
	const visibleNodes = getVisibleNodes();
	const maxScroll = Math.max(0, visibleNodes.length - view.visibleItems);
	scroll.active = maxScroll > 0;
}

function getScrollbarMetrics() {
	const visibleNodes = getVisibleNodes();
	const maxScroll = Math.max(0, visibleNodes.length - view.visibleItems);
	if (maxScroll <= 0) return null;

	const trackTop = CONFIG.searchBarHeight;
	const trackHeight = view.h - trackTop;
	const usable = trackHeight - CONFIG.scrollbarButtonHeight * 2;
	if (usable <= 0) return null;

	const scrollRatio = scroll.position / maxScroll;
	const visibleRatio = view.visibleItems / visibleNodes.length;
	const thumbHeight = Math.max(CONFIG.minThumbHeight, usable * visibleRatio);
	const thumbTravel = Math.max(0, usable - thumbHeight);
	const thumbY = trackTop + CONFIG.scrollbarButtonHeight + scrollRatio * thumbTravel;

	return { maxScroll, scrollRatio, thumbHeight, thumbTravel, thumbY, visibleNodes };
}

function getScrollbarThumbRect() {
	const m = getScrollbarMetrics();
	if (!m) return null;

	let currentWidth = scroll.minimized ? CONFIG.scrollbarNarrowWidth : CONFIG.scrollbarWidth;
	return { x: view.w - currentWidth, y: m.thumbY, w: currentWidth, h: m.thumbHeight };
}

function isOnScrollbarButton(y) {
	if (y < CONFIG.searchBarHeight) return null;
	if (y < CONFIG.searchBarHeight + CONFIG.scrollbarButtonHeight) return 'up';
	if (y > view.h - CONFIG.scrollbarButtonHeight) return 'down';
	return null;
}

function isMouseOnScrollbarArea(x, y) {
	if (!scroll.active) return false;
	if (y < CONFIG.searchBarHeight) return false;
	const currentWidth = scroll.minimized ? CONFIG.scrollbarNarrowWidth : CONFIG.scrollbarWidth;
	const checkWidth = currentWidth + CONFIG.scrollbarCheckExtraWidth;
	return x >= view.w - checkWidth && x < view.w && y < view.h;
}

function startScrollbarHideTimer() {
	if (timers.scrollbarHide) window.ClearTimeout(timers.scrollbarHide);
	timers.scrollbarHide = window.SetTimeout(function() {
		if (!scroll.dragging && !scroll.hover) {
			scroll.minimized = true;
			window.Repaint();
		}
		timers.scrollbarHide = null;
	}, CONFIG.scrollbarHideDelay);
}

function handleScrollbarClick(x, y) {
	const button = isOnScrollbarButton(y);
	if (button && scroll.active) {
		const visibleNodes = getVisibleNodes();
		const maxScroll = Math.max(0, visibleNodes.length - view.visibleItems);
		scroll.position = button === 'up'
			? Math.max(0, Math.floor(scroll.position) - 1)
			: Math.min(maxScroll, Math.floor(scroll.position) + 1);
		window.Repaint();
		return true;
	}

	const thumbRect = getScrollbarThumbRect();
	if (thumbRect && x >= thumbRect.x && x <= thumbRect.x + thumbRect.w &&
		y >= thumbRect.y && y <= thumbRect.y + thumbRect.h) {
		scroll.dragging = true;
		scroll.minimized = false;
		scroll.dragStartY = y;
		scroll.dragStartScroll = scroll.position;
		window.Repaint();
		return true;
	}

	return true;
}

// ========== 播放与选择操作 ==========
function collectTracksFromNode(node) {
	const handles = fb.CreateHandleList();
	if (!library.handles) return handles;

	if (node.type === 'track') {
		const idx = node.data.handleIndex;
		if (idx !== undefined && idx < library.handles.Count) {
			handles.Add(library.handles[idx]);
		}
		return handles;
	}

	if (node.type === 'folder') {
		for (let i = 0; i < node.item.length; i++) {
			const r = node.item[i];
			const s = r.start < r.end ? r.start : r.end;
			const e = r.start < r.end ? r.end : r.start;
			for (let j = s; j <= e; j++) {
				if (j < library.handles.Count) handles.Add(library.handles[j]);
			}
		}
	}
	return handles;
}

function playSingleTrack(node) {
	if (!plman || !fb || node.type !== 'track') return;
	const handle = collectTracksFromNode(node);
	if (handle.Count === 0) return;

	const pl_stnd_idx = plman.FindOrCreatePlaylist(CONFIG.playlistNameStandard, false);
	if (pl_stnd_idx === -1) return;

	if (plman.PlaylistItemCount(pl_stnd_idx) < CONFIG.maxUndoBackupCount) plman.UndoBackup(pl_stnd_idx);
	plman.ClearPlaylist(pl_stnd_idx);
	plman.InsertPlaylistItems(pl_stnd_idx, 0, handle, false);
	plman.SetPlaylistFocusItem(pl_stnd_idx, 0);
	plman.ClearPlaylistSelection(pl_stnd_idx);
	plman.SetPlaylistSelectionSingle(pl_stnd_idx, 0, true);
	plman.EnsurePlaylistItemVisible(pl_stnd_idx, 0);
	plman.ActivePlaylist = pl_stnd_idx;
	plman.StopAfterCurrent = false;
	plman.ExecutePlaylistDefaultAction(pl_stnd_idx, 0);
}

function isNowPlayingInNode(node) {
	if (!fb.IsPlaying) return false;
	const handle = fb.GetNowPlaying();
	if (!handle) return false;
	const path = handle.Path;
	if (!path) return false;
	const folderPath = node.data.path;
	let relative = '';
	if (path.indexOf(library.root) === 0) {
		relative = path.substring(library.root.length);
		if (relative.charAt(0) === '\\') relative = relative.substring(1);
		const lastSlash = relative.lastIndexOf('\\');
		if (lastSlash !== -1) relative = relative.substring(0, lastSlash);
		else relative = '';
	}
	return relative === folderPath || (relative.indexOf(folderPath + '\\') === 0);
}

function inRange(num, items) {
	let lo = 0, hi = items.length - 1;
	while (lo <= hi) {
		const mid = (lo + hi) >> 1;
		const it = items[mid];
		const s = it.start < it.end ? it.start : it.end;
		const e = it.start < it.end ? it.end : it.start;
		if (num < s) hi = mid - 1;
		else if (num > e) lo = mid + 1;
		else return true;
	}
	return false;
}

function findNodeByHandleIndex(npIndex) {
	const visibleNodes = getVisibleNodes();
	for (let i = visibleNodes.length - 1; i >= 0; i--) {
		const node = visibleNodes[i];
		if (node.type === 'folder' && node.item.length > 0 && inRange(npIndex, node.item)) {
			return node;
		}
	}
	return null;
}

function scrollToNode(targetNode, center) {
	if (!targetNode) return;

	const visibleNodes = getVisibleNodes();
	let targetIndex = -1;
	for (let i = 0; i < visibleNodes.length; i++) {
		if (visibleNodes[i] === targetNode) {
			targetIndex = i;
			break;
		}
	}
	if (targetIndex === -1) return;

	if (center) {
		const centerOffset = Math.round(view.visibleItems / 2 - 1);
		scroll.position = Math.max(0, targetIndex - centerOffset);
	} else {
		const topIndex = scroll.position;
		const bottomIndex = scroll.position + view.visibleItems - 1;
		if (targetIndex < topIndex) {
			scroll.position = targetIndex;
		} else if (targetIndex > bottomIndex) {
			scroll.position = targetIndex - view.visibleItems + 1;
		}
	}
	const maxScroll = Math.max(0, visibleNodes.length - view.visibleItems);
	scroll.position = Math.min(scroll.position, maxScroll);
}

function updateNowPlayingNode() {
	if (!fb.IsPlaying) {
		nowPlaying.nodePath.clear();
		nowPlaying.node = null;
		nowPlaying.trackPath = '';
		return;
	}
	const handle = fb.GetNowPlaying();
	if (!handle) return;
	const path = handle.Path;
	if (!path) return;
	if (!library.handles) return;
	const npIndex = library.handles.Find(handle);
	if (npIndex === -1) return;

	let foundNode = null;
	for (let i = tree.data.length - 1; i >= 0; i--) {
		const node = tree.data[i];
		if (node.type === 'folder' && !node.root && node.item.length > 0 && inRange(npIndex, node.item)) {
			foundNode = node;
			break;
		}
	}
	if (!foundNode) return;

	nowPlaying.nodePath.clear();
	nowPlaying.node = null;
	nowPlaying.trackPath = '';

	nowPlaying.trackPath = path;
	nowPlaying.node = foundNode;

	nowPlaying.nodePath.add(foundNode.id);

	for (let j = 0; j < foundNode.children.length; j++) {
		const child = foundNode.children[j];
		if (child.type === 'track' && child.data.handleIndex === npIndex) {
			nowPlaying.nodePath.add(child.id);
			break;
		}
	}

	let parentId = foundNode.parentId;
	while (parentId) {
		const parent = findNodeById(parentId);
		if (parent) {
			if (parent.type !== 'root') nowPlaying.nodePath.add(parent.id);
			parentId = parent.parentId;
		} else break;
	}
}

function sendTracksToPlaylist(node) {
	if (!plman) return;
	if (!fb || !fb.GetLibraryItems) return;

	const handles = collectTracksFromNode(node);
	if (handles.Count === 0) return;

	const tfo = fb.TitleFormat(CONFIG.sortFormat);
	handles.OrderByFormat(tfo, 1);

	const pl_stnd_idx = plman.FindOrCreatePlaylist(CONFIG.playlistNameStandard, true);
	const isPlayingHere = isNowPlayingInNode(node);

	if (fb.IsPlaying && !isPlayingHere) {
		const pl_playing_idx = plman.FindOrCreatePlaylist(CONFIG.playlistNamePlaying, false);

		if (plman.PlayingPlaylist == pl_stnd_idx) {
			plman.RenamePlaylist(pl_stnd_idx, CONFIG.playlistNamePlaying);
			plman.RenamePlaylist(pl_playing_idx, CONFIG.playlistNameStandard);

			const count = plman.PlaylistItemCount(pl_playing_idx);
			const indices = [];
			for (let i = 0; i < count; i++) indices.push(i);
			plman.SetPlaylistSelection(pl_playing_idx, indices, true);
			plman.RemovePlaylistSelection(pl_playing_idx, false);
			plman.InsertPlaylistItems(pl_playing_idx, 0, handles, false);

			plman.MovePlaylist(pl_playing_idx, pl_stnd_idx);
			plman.MovePlaylist(pl_stnd_idx + 1, pl_playing_idx);
		} else {
			const count = plman.PlaylistItemCount(pl_stnd_idx);
			const indices = [];
			for (let i = 0; i < count; i++) indices.push(i);
			plman.SetPlaylistSelection(pl_stnd_idx, indices, true);
			plman.RemovePlaylistSelection(pl_stnd_idx, false);
			plman.InsertPlaylistItems(pl_stnd_idx, 0, handles, false);
		}
		plman.ActivePlaylist = pl_stnd_idx;
	} else if (fb.IsPlaying && isPlayingHere) {
		const np = fb.GetNowPlaying();
		if (np) {
			const pos = handles.Find(np);
			if (pos !== -1) {
				resetPlaylist(pl_stnd_idx, handles, pos);
			} else {
				resetPlaylist(pl_stnd_idx, handles, 0);
			}
		}
	} else {
		resetPlaylist(pl_stnd_idx, handles, 0);
	}
}

function resetPlaylist(pl_idx, handles, focusPos) {
	if (plman.PlaylistItemCount(pl_idx) < CONFIG.maxUndoBackupCount) plman.UndoBackup(pl_idx);
	plman.ClearPlaylist(pl_idx);
	plman.InsertPlaylistItems(pl_idx, 0, handles, false);
	if (focusPos !== undefined) {
		plman.SetPlaylistFocusItem(pl_idx, focusPos);
		plman.ClearPlaylistSelection(pl_idx);
		plman.SetPlaylistSelectionSingle(pl_idx, focusPos, true);
		plman.EnsurePlaylistItemVisible(pl_idx, focusPos);
	} else {
		plman.SetPlaylistFocusItem(pl_idx, 0);
	}
	plman.ActivePlaylist = pl_idx;
}

function locateCurrentTrack() {
	if (!fb.IsPlaying) return;
	updateNowPlayingNode();
	if (!nowPlaying.node) return;

	const visibleTarget = getVisibleAncestor(nowPlaying.node);

	if (tree.selected) tree.selected.selected = false;
	if (visibleTarget) {
		visibleTarget.selected = true;
		tree.selected = visibleTarget;
	}

	scrollToNode(visibleTarget, true);
	window.Repaint();

	const pl_playing_idx = plman.FindOrCreatePlaylist(CONFIG.playlistNamePlaying, false);
	if (pl_playing_idx !== -1) {
		const np = fb.GetNowPlaying();
		if (np) {
			const handles = plman.GetPlaylistItems(pl_playing_idx);
			const pos = handles.Find(np);
			if (pos !== -1) {
				plman.ActivePlaylist = pl_playing_idx;
				plman.SetPlaylistFocusItem(pl_playing_idx, pos);
				plman.ClearPlaylistSelection(pl_playing_idx);
				plman.SetPlaylistSelectionSingle(pl_playing_idx, pos, true);
				plman.EnsurePlaylistItemVisible(pl_playing_idx, pos);
			}
		}
	}
}

function sendToCurrentPlaylist(node) {
	if (!plman) return;
	const handles = collectTracksFromNode(node);
	if (handles.Count === 0) return;

	const tfo = fb.TitleFormat(CONFIG.sortFormat);
	handles.OrderByFormat(tfo, 1);

	const activeIdx = plman.ActivePlaylist;
	if (plman.PlaylistItemCount(activeIdx) < CONFIG.maxUndoBackupCount) plman.UndoBackup(activeIdx);
	plman.ClearPlaylist(activeIdx);
	plman.InsertPlaylistItems(activeIdx, 0, handles, false);
	plman.SetPlaylistFocusItem(activeIdx, 0);
	plman.ClearPlaylistSelection(activeIdx);
	plman.SetPlaylistSelectionSingle(activeIdx, 0, true);
	plman.EnsurePlaylistItemVisible(activeIdx, 0);
	plman.StopAfterCurrent = false;
	fb.Play();
}

function addToCurrentPlaylist(node) {
	if (!plman) return;
	const handles = collectTracksFromNode(node);
	if (handles.Count === 0) return;

	const activeIdx = plman.ActivePlaylist;
	if (plman.PlaylistItemCount(activeIdx) < CONFIG.maxUndoBackupCount) plman.UndoBackup(activeIdx);
	plman.InsertPlaylistItems(activeIdx, plman.PlaylistItemCount(activeIdx), handles, false);
}

function sendToNewPlaylist(node) {
	if (!plman) return;
	const handles = collectTracksFromNode(node);
	if (handles.Count === 0) return;

	const tfo = fb.TitleFormat(CONFIG.sortFormat);
	handles.OrderByFormat(tfo, 1);

	const plIdx = plman.CreatePlaylist(plman.PlaylistCount, node.name);
	plman.InsertPlaylistItems(plIdx, 0, handles, false);
	plman.SetPlaylistFocusItem(plIdx, 0);
	plman.ClearPlaylistSelection(plIdx);
	plman.SetPlaylistSelectionSingle(plIdx, 0, true);
	plman.EnsurePlaylistItemVisible(plIdx, 0);
	plman.ActivePlaylist = plIdx;
	plman.StopAfterCurrent = false;
	fb.Play();
}

function collapseAll() {
	for (let i = 0; i < tree.data.length; i++) {
		if (tree.data[i].type === 'folder') {
			tree.data[i].expanded = false;
		}
	}
	invalidateVisibleNodesCache();
	scroll.position = 0;
	window.Repaint();
}

function expandAll() {
	function expandNode(node) {
		if (node.type === 'folder' || node.type === 'root') {
			node.expanded = true;
			for (let i = 0; i < node.children.length; i++) {
				expandNode(node.children[i]);
			}
		}
	}
	if (tree.data.length > 0) expandNode(tree.data[0]);
	invalidateVisibleNodesCache();
	window.Repaint();
}

// ========== 工具函数 ==========
function findNodeById(id) {
	return tree.byId.get(id) || null;
}

function getVisibleAncestor(node) {
	if (!node) return null;
	let visible = node;
	let p = node;
	while (p && p.type !== 'root') {
		if (!p.expanded) {
			visible = p;
		}
		p = findNodeById(p.parentId);
	}
	return visible;
}

// ========== 刷新 ==========
function scheduleTreeLoad() {
	if (timers.loading) window.ClearTimeout(timers.loading);
	timers.loading = window.SetTimeout(function() {
		loadFolderTree();
		if (search.text) performSearch();
		updateNowPlayingNode();
		updateScrollbarState();
		window.Repaint();
		timers.loading = null;
	}, CONFIG.loadDelay);
}

function refresh() {
	tree.selected = null;
	nowPlaying.node = null;
	nowPlaying.trackPath = '';
	nowPlaying.nodePath.clear();
	scroll.position = 0;
	library.isEmpty = false;
	scheduleTreeLoad();
}

// ========== 事件回调 ==========
function on_size() {
	view.w = window.Width;
	view.h = window.Height;
	recalcVisibleItems();
	updateScrollbarState();
}

function on_paint(gr) {
	if (!gr) return;
	gr.FillSolidRect(0, 0, view.w, view.h, theme.bgColor);

	drawSearchbar(gr);

	if (!tree.data || tree.data.length === 0) {
		gr.GdiDrawText(CONFIG.loadingText, fonts.main, theme.textColor,
			10, CONFIG.searchBarHeight + 10, view.w - 20, 30, 0);
		return;
	}
	if (library.isEmpty) {
		const hint1 = '媒体库为空';
		const hint2 = '点击此处添加媒体库';
		const y1 = CONFIG.searchBarHeight + (view.h - CONFIG.searchBarHeight) / 2 - 30;
		const y2 = CONFIG.searchBarHeight + (view.h - CONFIG.searchBarHeight) / 2 + 10;
		gr.GdiDrawText(hint1, fonts.large, theme.textColor, 0, y1, view.w, 30, DT_CENTER | DT_SINGLELINE);
		gr.GdiDrawText(hint2, fonts.large, theme.hlColor, 0, y2, view.w, 30, DT_CENTER | DT_SINGLELINE);
		return;
	}

	if (cache.needRecalc && fonts.main) {
		recalcCountWidths(gr);
		cache.needRecalc = false;
	}

	const visibleNodes = getVisibleNodes();
	const maxScroll = Math.max(0, visibleNodes.length - view.visibleItems);
	scroll.position = Math.min(scroll.position, maxScroll);
	const startIndex = Math.min(scroll.position, Math.max(0, visibleNodes.length - view.visibleItems));
	const nodesToDraw = Math.min(view.visibleItems, visibleNodes.length - startIndex);
	const nodesToShow = visibleNodes.slice(startIndex, startIndex + nodesToDraw);
	for (let i = 0; i < nodesToShow.length; i++) {
		drawTreeNode(gr, nodesToShow[i], CONFIG.searchBarHeight + i * CONFIG.itemHeight, startIndex + i);
	}
	drawScrollbar(gr);
	if (visibleNodes.length === 0) {
		gr.GdiDrawText(search.text ? '未找到匹配项' : CONFIG.noDataText, fonts.main, theme.textColor,
			10, CONFIG.searchBarHeight + 10, view.w - 20, 30, 0);
	}
}

function on_mouse_lbtn_down(x, y) {
	if (y < 0) return;

	if (y >= CONFIG.searchBarHeight) {
		const currScrollbarWidth = scroll.minimized ? CONFIG.scrollbarNarrowWidth : CONFIG.scrollbarWidth;
		if (x >= view.w - currScrollbarWidth - CONFIG.scrollbarCheckExtraWidth) {
			const btn = isOnScrollbarButton(y);
			if (btn) scroll.arrowDown = btn;
			handleScrollbarClick(x, y);
			return;
		}
	}

	if (y < CONFIG.searchBarHeight) {
		if (ui.clearBtnRect && x >= ui.clearBtnRect.x && x <= ui.clearBtnRect.x + ui.clearBtnRect.w) {
			clearSearch();
			search.inputActive = false;
			stopSearchCursorBlink();
			search.cursorVisible = false;
			ui.clearBtnRect = null;
			window.RepaintRect(0, 0, view.w, CONFIG.searchBarHeight, true);
			return;
		}
		search.inputActive = true;
		search.cursorPos = search.text.length;
		search.start = search.cursorPos;
		search.end = search.cursorPos;
		search.offset = 0;
		startSearchCursorBlink();
		window.Repaint();
		return;
	}

	search.inputActive = false;
	stopSearchCursorBlink();

	if (library.isEmpty) {
		if (fb && fb.RunMainMenuCommand) {
			fb.RunMainMenuCommand('媒体库/配置');
		}
		return;
	}

	const adjustedY = y - CONFIG.searchBarHeight;
	const itemIndex = Math.floor(adjustedY / CONFIG.itemHeight) + scroll.position;
	const visibleNodes = getVisibleNodes();
	if (itemIndex >= 0 && itemIndex < visibleNodes.length) {
		const node = visibleNodes[itemIndex];
		if (!node) return;
		if (node.type === 'root') return;

		const effectiveLevel = node.level <= 1 ? 0 : node.level - 1;
		const arrowX = CONFIG.sideMarkerWidth + CONFIG.arrowIconOffsetX + effectiveLevel * CONFIG.indentPerLevel;
		const isExpandable = node.type === 'folder' && node.children.length > 0;
		if (isExpandable && x >= arrowX - 4 && x <= arrowX + CONFIG.arrowIconSize + 4) {
			node.expanded = !node.expanded;
			invalidateVisibleNodesCache();
		} else {
			ui.userSelectingNode = true;
			if (tree.selected) tree.selected.selected = false;
			node.selected = true;
			tree.selected = node;
			if (node.type === 'folder') {
				sendTracksToPlaylist(node);
			}
			if (timers.userSelecting) window.ClearTimeout(timers.userSelecting);
			timers.userSelecting = window.SetTimeout(function() {
				ui.userSelectingNode = false;
				timers.userSelecting = null;
			}, 0);
		}
		window.Repaint();
	}
}

function on_mouse_lbtn_up(x, y) {
	if (scroll.dragging) {
		scroll.dragging = false;

		const onScrollbarArea = isMouseOnScrollbarArea(x, y);
		if (onScrollbarArea) {
			const thumbRect = getScrollbarThumbRect();
			const onThumb = thumbRect && x >= thumbRect.x && x <= thumbRect.x + thumbRect.w &&
				y >= thumbRect.y && y <= thumbRect.y + thumbRect.h;
			const onButton = isOnScrollbarButton(y);
			scroll.hover = onThumb || (onButton === null);
			scroll.arrowHover = onButton;
		} else {
			scroll.hover = false;
			scroll.arrowHover = null;
		}

		window.Repaint();
		return;
	}
	if (scroll.arrowDown) {
		scroll.arrowDown = null;
		window.Repaint();
	}
}

function on_mouse_lbtn_dblclk(x, y) {
	if (y >= CONFIG.searchBarHeight) {
		const currScrollbarWidth = scroll.minimized ? CONFIG.scrollbarNarrowWidth : CONFIG.scrollbarWidth;
		if (x >= view.w - currScrollbarWidth - CONFIG.scrollbarCheckExtraWidth) {
			handleScrollbarClick(x, y);
			return;
		}
	}

	if (y < CONFIG.searchBarHeight) {
		if (search.text.length > 0) {
			search.start = 0;
			search.end = search.text.length;
			search.cursorPos = search.text.length;
			search.inputActive = true;
			startSearchCursorBlink();
			window.RepaintRect(0, 0, view.w, CONFIG.searchBarHeight, true);
		}
		return;
	}

	const adjustedY = y - CONFIG.searchBarHeight;
	const itemIndex = Math.floor(adjustedY / CONFIG.itemHeight) + scroll.position;
	const visibleNodes = getVisibleNodes();
	if (itemIndex >= 0 && itemIndex < visibleNodes.length) {
		const node = visibleNodes[itemIndex];
		if (node.type === 'root') return;
		if (node.type === 'folder') {
			const pl_stnd_idx = plman.FindOrCreatePlaylist(CONFIG.playlistNameStandard, false);
			if (pl_stnd_idx !== -1 && plman.PlaylistItemCount(pl_stnd_idx) > 0) {
				plman.ActivePlaylist = pl_stnd_idx;
				plman.SetPlaylistFocusItem(pl_stnd_idx, 0);
				plman.ClearPlaylistSelection(pl_stnd_idx);
				plman.SetPlaylistSelectionSingle(pl_stnd_idx, 0, true);
				plman.EnsurePlaylistItemVisible(pl_stnd_idx, 0);
				plman.StopAfterCurrent = false;
				plman.ExecutePlaylistDefaultAction(pl_stnd_idx, 0);
			}
		} else if (node.type === 'track') {
			playSingleTrack(node);
		}
	}
}

function on_mouse_wheel(delta) {
	if (scroll.dragging) return;
	const visibleNodes = getVisibleNodes();
	const maxScroll = Math.max(0, visibleNodes.length - view.visibleItems);
	scroll.position = Math.round(Math.max(0, Math.min(maxScroll, scroll.position - delta * CONFIG.scrollStep)));
	window.Repaint();
}

function on_mouse_rbtn_up(x, y) {
	if (y < CONFIG.searchBarHeight) {
		if (search.text) {
			const menu = window.CreatePopupMenu();
			menu.AppendMenuItem(0, 1, '清除搜索');
			const result = menu.TrackPopupMenu(x, y);
			if (result === 1) clearSearch();
		}
		return;
	}

	const adjustedY = y - CONFIG.searchBarHeight;
	const itemIndex = Math.floor(adjustedY / CONFIG.itemHeight) + scroll.position;
	const visibleNodes = getVisibleNodes();
	if (itemIndex >= 0 && itemIndex < visibleNodes.length) {
		const node = visibleNodes[itemIndex];
		if (node.type === 'root') return;

		const menu = window.CreatePopupMenu();
		menu.AppendMenuItem(0, 1, '发送到当前列表');
		menu.AppendMenuItem(0, 2, '追加到当前列表');
		menu.AppendMenuItem(0, 3, '发送到新的列表');
		menu.AppendMenuSeparator();
		menu.AppendMenuItem(0, 4, '显示正在播放项目');
		menu.AppendMenuSeparator();
		menu.AppendMenuItem(0, 5, '折叠全部');
		menu.AppendMenuItem(0, 6, '展开全部');

		let Context = null;
		const handles = collectTracksFromNode(node);
		if (handles.Count > 0) {
			Context = fb.CreateContextMenuManager();
			Context.InitContext(handles);
			menu.AppendMenuSeparator();
			Context.BuildMenu(menu, 5000);
		}

		const result = menu.TrackPopupMenu(x, y);
		if (result === 1) sendToCurrentPlaylist(node);
		else if (result === 2) addToCurrentPlaylist(node);
		else if (result === 3) sendToNewPlaylist(node);
		else if (result === 4) locateCurrentTrack();
		else if (result === 5) collapseAll();
		else if (result === 6) expandAll();
		else if (Context && result >= 5000 && result <= 5800) Context.ExecuteByID(result - 5000);
	}

	return true;
}

function on_mouse_move(x, y) {
	const wasMinimized = scroll.minimized;
	const wasHover = scroll.hover;
	const wasArrowHover = scroll.arrowHover;

	if (scroll.dragging) {
		const deltaY = y - scroll.dragStartY;
		const visibleNodes = getVisibleNodes();
		const maxScroll = Math.max(0, visibleNodes.length - view.visibleItems);
		const visibleRatio = view.visibleItems / visibleNodes.length;
		const trackHeight = view.h - CONFIG.searchBarHeight;
		const usable = trackHeight - CONFIG.scrollbarButtonHeight * 2;
		const thumbHeight = Math.max(CONFIG.minThumbHeight, usable * visibleRatio);
		const thumbTravel = Math.max(0, usable - thumbHeight);

		if (thumbTravel > 0) {
			const scrollDelta = (deltaY / thumbTravel) * maxScroll;
			scroll.position = Math.round(Math.max(0, Math.min(maxScroll, scroll.dragStartScroll + scrollDelta)));
		}
		window.Repaint();
		return;
	}

	const onScrollbarArea = isMouseOnScrollbarArea(x, y);

	if (onScrollbarArea) {
		scroll.minimized = false;

		const thumbRect = getScrollbarThumbRect();
		const onThumb = thumbRect && x >= thumbRect.x && x <= thumbRect.x + thumbRect.w &&
			y >= thumbRect.y && y <= thumbRect.y + thumbRect.h;
		const onButton = isOnScrollbarButton(y);

		scroll.hover = onThumb || (onButton === null);
		scroll.arrowHover = onButton;

		if (timers.scrollbarHide) {
			window.ClearTimeout(timers.scrollbarHide);
			timers.scrollbarHide = null;
		}
	} else {
		scroll.hover = false;
		scroll.arrowHover = null;
		scroll.arrowDown = null;
		if (scroll.active) startScrollbarHideTimer();
	}

	if (y < CONFIG.searchBarHeight) {
		if (wasMinimized !== scroll.minimized || wasHover !== scroll.hover || wasArrowHover !== scroll.arrowHover) {
			window.Repaint();
		}
		return;
	}

	if (wasMinimized !== scroll.minimized || wasHover !== scroll.hover || wasArrowHover !== scroll.arrowHover) {
		window.Repaint();
	}
}

function on_char(code) {
	if (code >= 32) {
		const text = String.fromCharCode(code);
		if (search.start != search.end) {
			const start = Math.min(search.start, search.end);
			const end = Math.max(search.start, search.end);
			search.text = search.text.substring(0, start) + text + search.text.substring(end);
			search.cursorPos = start + 1;
		} else {
			search.text = search.text.substring(0, search.cursorPos) + text + search.text.substring(search.cursorPos);
			search.cursorPos++;
		}
		search.start = search.cursorPos;
		search.end = search.cursorPos;
		search.inputActive = true;
		startSearchCursorBlink();
		performSearch();
		scroll.position = 0;
		window.Repaint();
	} else if (code == 8) { // Backspace
		if (search.start != search.end) {
			const start = Math.min(search.start, search.end);
			const end = Math.max(search.start, search.end);
			search.text = search.text.substring(0, start) + search.text.substring(end);
			search.cursorPos = start;
		} else if (search.cursorPos > 0) {
			search.text = search.text.substring(0, search.cursorPos - 1) + search.text.substring(search.cursorPos);
			search.cursorPos--;
		}
		search.start = search.cursorPos;
		search.end = search.cursorPos;
		search.inputActive = true;
		startSearchCursorBlink();
		performSearch();
		scroll.position = 0;
		window.Repaint();
	} else if (code == 27) { // Escape
		clearSearch();
		search.inputActive = false;
		stopSearchCursorBlink();
	} else if (code == 13) { // Enter
		if (search.text) {
			performSearch();
			scroll.position = 0;
			window.Repaint();
		}
	}
}

function on_key_down(vkey) {
	switch (vkey) {
		case 37: // Left
			if (search.cursorPos > 0) {
				search.cursorPos--;
				search.start = search.cursorPos;
				search.end = search.cursorPos;
				window.Repaint();
			}
			break;
		case 39: // Right
			if (search.cursorPos < search.text.length) {
				search.cursorPos++;
				search.start = search.cursorPos;
				search.end = search.cursorPos;
				window.Repaint();
			}
			break;
		case 36: // Home
			search.cursorPos = 0;
			search.start = 0;
			search.end = 0;
			window.Repaint();
			break;
		case 35: // End
			search.cursorPos = search.text.length;
			search.start = search.text.length;
			search.end = search.text.length;
			window.Repaint();
			break;
		case 46: // Delete
			if (search.start != search.end) {
				const start = Math.min(search.start, search.end);
				const end = Math.max(search.start, search.end);
				search.text = search.text.substring(0, start) + search.text.substring(end);
				search.cursorPos = start;
			} else if (search.cursorPos < search.text.length) {
				search.text = search.text.substring(0, search.cursorPos) + search.text.substring(search.cursorPos + 1);
			}
			search.start = search.cursorPos;
			search.end = search.cursorPos;
			performSearch();
			scroll.position = 0;
			window.Repaint();
			break;
	}
}

function on_focus(is_focused) {
	if (!is_focused) {
		if (search.inputActive) {
			search.inputActive = false;
			stopSearchCursorBlink();
			search.cursorVisible = false;
			window.RepaintRect(0, 0, view.w, CONFIG.searchBarHeight, true);
		}
	}
}

function on_mouse_leave() {
	scroll.hover = false;
	scroll.arrowHover = null;
	scroll.arrowDown = null;
	startScrollbarHideTimer();
}

function on_playback_new_track() {
	updateNowPlayingNode();
	window.Repaint();
}

function on_playback_stop(reason) {
	if (reason === 2) return;
	nowPlaying.nodePath.clear();
	nowPlaying.node = null;
	nowPlaying.trackPath = '';
	window.Repaint();
}

function on_item_focus_change() {
	if (ui.userSelectingNode) return;
	if (!fb || !fb.GetFocusItem) return;
	const handle = fb.GetFocusItem();
	if (!handle) return;

	if (!library.handles) return;
	const itemIndex = library.handles.Find(handle);
	if (itemIndex === -1) return;

	const targetNode = findNodeByHandleIndex(itemIndex);
	if (!targetNode) return;

	if (tree.selected && tree.selected.id === targetNode.id) return;

	if (tree.selected) tree.selected.selected = false;
	targetNode.selected = true;
	tree.selected = targetNode;

	scrollToNode(targetNode, false);
	window.Repaint();
}

function on_playlist_switch() {
	if (ui.userSelectingNode) return;
	if (!fb || !fb.GetNowPlaying) return;
	const np = fb.GetNowPlaying();
	if (!np) return;

	if (!library.handles) return;
	const npIndex = library.handles.Find(np);
	if (npIndex === -1) return;

	const foundNode = findNodeByHandleIndex(npIndex);
	if (!foundNode) return;

	if (tree.selected) {
		tree.selected.selected = false;
	}

	foundNode.selected = true;
	tree.selected = foundNode;

	scrollToNode(foundNode, true);
	window.Repaint();
}

function on_library_items_added() { refresh(); }
function on_library_items_removed() { refresh(); }
function on_library_items_changed() { refresh(); }

function on_script_unload() {
	if (timers.loading)       { window.ClearTimeout(timers.loading);       timers.loading = null; }
	if (timers.scrollbarHide) { window.ClearTimeout(timers.scrollbarHide); timers.scrollbarHide = null; }
	if (timers.userSelecting) { window.ClearTimeout(timers.userSelecting); timers.userSelecting = null; }
	if (timers.searchCursor)  { window.ClearInterval(timers.searchCursor); timers.searchCursor = null; }

	tree.data = [];
	tree.byId.clear();
	tree.cachedVisible = null;
	tree.selected = null;
	nowPlaying.node = null;
	nowPlaying.trackPath = '';
	nowPlaying.nodePath.clear();
	search.text = '';
	search.visibleIds = null;
}

function on_font_changed() {
	get_font();
	applyDpiScaling();
	cache.needRecalc = true;
	window.Repaint();
}

function on_colours_changed() {
	get_color();
	window.Repaint();
}

// ========== 初始化 ==========
function init() {
	get_color();
	get_font();
	applyDpiScaling();
	recalcVisibleItems();
	cache.needRecalc = true;
	scheduleTreeLoad();
}

init();
文章源自《智享阁》智享阁-https://www.esnpc.com/foobar2000-jsplitter-media-library-tree-js/文章源自《智享阁》智享阁-https://www.esnpc.com/foobar2000-jsplitter-media-library-tree-js/

声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布、售卖本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理。

智享阁
  • 本文由 智享阁 发表于2026年9月15日 15:20:36
  • 转载请保留本文链接:https://www.esnpc.com/foobar2000-jsplitter-media-library-tree-js/
匿名

发表评论

匿名网友
确定

拖动滑块以完成验证