NOTICE: By continued use of this site you understand and agree to the binding Terms of Service and Privacy Policy.
// ==UserScript==
// @name Last.fm: Toolbox
// @namespace https://github.com/deathrashed/lastfm-userscript
// @description A context-aware popup with over 60 curated services across 8 categories, injected into every artist, album, and track link on Last.fm. Includes an AI prompt generator and comprehensive user controls.
// @icon https://raw.githubusercontent.com/deathrashed/gupload/main/uploads/images/lastfm-toolkit.png
// @match https://www.last.fm/*
// @match https://www.lastfm.*/*
// @match https://cn.last.fm/*
// @version 6
// @license MIT
// @grant GM_addStyle
// @grant GM_registerMenuCommand
// @author deathrashed
// @require https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/js/all.min.js
// @downloadURL https://update.greasyfork.org/scripts/563609/Lastfm%3A%20Toolbox.user.js
// @updateURL https://update.greasyfork.org/scripts/563609/Lastfm%3A%20Toolbox.meta.js
// ==/UserScript==
(function () {
"use strict";
const faLink = document.createElement("link");
faLink.rel = "stylesheet";
faLink.href = "https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css";
document.head.appendChild(faLink);
GM_addStyle(`
:root {
--bg-primary: #282828;
--bg-secondary: #3a3a3a;
--bg-hover: #3a3a3a;
--text-primary: #fff;
--text-secondary: #999;
--text-disabled: #666;
--border-color: #444;
--input-bg: #3a3a3a;
--input-border: #555;
/* scrollbar: hardcoded per-element */
--toggle-off: #555;
--toggle-on: #DA2323;
--brand: #DA2323;
--brand-rgb: 218, 35, 35;
--brand-hover: #FF1B20;
--shadow: rgba(0,0,0,.3);
--menu-shadow: rgba(0,0,0,.3);
--scrollbar-bg: #282828;
--scrollbar-thumb: #666;
--scrollbar-thumb-hover: #999;
}
.lfm-light-mode {
--bg-primary: #f5f5f5;
--bg-secondary: #e8e8e8;
--bg-hover: #e0e0e0;
--text-primary: #1a1a1a;
--text-secondary: #777;
--text-disabled: #bbb;
--border-color: #ddd;
--input-bg: #fff;
--input-border: #ccc;
/* scrollbar: hardcoded per-element */
--toggle-off: #ddd;
--shadow: rgba(0,0,0,.12);
--menu-shadow: rgba(0,0,0,.12);
--scrollbar-bg: #e5e5e5;
--scrollbar-thumb: #a0a0a0;
--scrollbar-thumb-hover: #7a7a7a;
}
#external-music-button {
position: fixed;
width: 40px;
height: 40px;
background: var(--bg-primary);
color: var(--brand);
border-radius: 50%;
text-align: center;
line-height: 40px;
cursor: pointer;
font-size: 20px;
box-shadow: 0 2px 5px var(--shadow);
z-index: 99999999 !important;
transition: background .2s, color .2s;
}
#external-music-button:hover {
background: var(--bg-hover);
color: var(--brand-hover);
}
#external-music-menu {
position: fixed;
background: var(--bg-primary);
border-radius: 8px;
box-shadow: 0 4px 16px var(--menu-shadow);
display: none;
z-index: 99999999 !important;
width: 260px;
max-height: 600px;
direction: rtl;
overflow: hidden;
}
#lfm-scroll-area {
direction: ltr;
overflow-y: auto;
max-height: 600px;
padding: 4px 0;
}
#lfm-scroll-area::-webkit-scrollbar { width: 6px; }
#lfm-scroll-area::-webkit-scrollbar-track { background: var(--scrollbar-bg); }
#lfm-scroll-area::-webkit-scrollbar-thumb { background: var(--scrollbar-thumb); border-radius: 3px; }
#lfm-scroll-area::-webkit-scrollbar-thumb:hover { background: var(--scrollbar-thumb-hover); }
.lfm-light-mode #lfm-scroll-area::-webkit-scrollbar-track { background: var(--scrollbar-bg); }
.lfm-light-mode #lfm-scroll-area::-webkit-scrollbar-thumb { background: var(--scrollbar-thumb); }
.lfm-light-mode #lfm-scroll-area::-webkit-scrollbar-thumb:hover { background: var(--scrollbar-thumb-hover); }
#external-music-menu.visible {
display: block;
animation: lfmSlideUp .2s ease-out;
}
html.lfm-toggle-bottom-left #external-music-button { top: auto; right: auto; bottom: 20px; left: 20px; }
html.lfm-toggle-bottom-left #external-music-menu { top: auto; right: auto; bottom: 70px; left: 20px; }
html.lfm-toggle-bottom-right #external-music-button { top: auto; left: auto; bottom: 20px; right: 20px; }
html.lfm-toggle-bottom-right #external-music-menu { top: auto; left: auto; bottom: 70px; right: 20px; }
html.lfm-toggle-top-left #external-music-button { bottom: auto; right: auto; top: 75px; left: 20px; }
html.lfm-toggle-top-left #external-music-menu { top: auto; right: auto; bottom: 20px; left: 20px; }
html.lfm-toggle-top-right #external-music-button { bottom: auto; left: auto; top: 75px; right: 20px; }
html.lfm-toggle-top-right #external-music-menu { top: auto; left: auto; bottom: 20px; right: 20px; }
@keyframes lfmSlideUp {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes lfmSlideDown {
from { opacity: 0; transform: translateY(-10px); }
to { opacity: 1; transform: translateY(0); }
}
#external-music-menu p {
margin: 0;
padding: 5px 15px;
color: var(--text-secondary);
font-size: 12px;
direction: ltr;
}
#current-context {
color: var(--brand-hover) !important;
font-size: 18px;
font-weight: bold;
direction: ltr;
}
.menu-section {
cursor: pointer;
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
user-select: none;
direction: ltr;
padding: 7px 15px;
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: .5px;
color: var(--text-primary);
transition: color .15s;
}
.menu-section:hover {
color: var(--brand) !important;
}
.menu-section .section-chevron {
font-size: 11px;
color: var(--text-secondary);
flex-shrink: 0;
line-height: 1;
transition: color .15s;
user-select: none;
}
.menu-section:hover .section-chevron {
color: var(--brand);
}
.section-block { display: block; }
.section-block.hidden-section { display: none !important; }
#external-music-menu hr {
margin: 6px 0;
border: none;
height: 1px;
background: var(--border-color);
}
#external-music-menu a i,
#external-music-menu a svg {
width: 18px !important;
min-width: 18px !important;
margin-right: 10px !important;
text-align: center;
color: var(--text-secondary);
transition: color .15s;
}
#external-music-menu a:hover {
background: var(--bg-hover);
color: var(--brand) !important;
}
#external-music-menu a:hover i,
#external-music-menu a:hover svg {
color: var(--brand) !important;
}
#external-music-menu a.disabled {
color: var(--text-disabled) !important;
pointer-events: none;
}
.settings-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 6px 15px;
color: var(--text-primary);
font-size: 13px;
direction: ltr;
}
.toggle-switch {
width: 20px;
height: 20px;
border: 2px solid var(--border-color);
border-radius: 4px;
background: transparent;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
transition: all .15s ease;
}
.toggle-switch i,
.toggle-switch svg {
display: none;
color: var(--text-primary);
font-size: 15px;
}
.toggle-switch.active i,
.toggle-switch.active svg {
display: inline-block;
}
.toggle-switch:hover {
border-color: var(--brand);
}
.toggle-switch.active {
border-color: var(--brand);
background: rgba(218,35,35,.08);
}
.toggle-switch::after {
display: none;
}
.hidden-section { display: none !important; }
#search-input-container {
padding: 10px 15px;
display: flex;
justify-content: center;
direction: ltr;
position: relative;
}
#search-input-wrapper {
position: relative;
width: 170px;
}
#search-input {
width: 100%;
background: var(--input-bg);
border: 1px solid var(--input-border);
border-radius: 4px;
/* Increased right padding (28px) so typed text doesn't hide behind the ? */
padding: 6px 28px 6px 10px;
color: var(--text-primary);
font-size: 13px;
transition: border-color .2s;
box-sizing: border-box;
}
#search-input:focus {
outline: none;
border-color: var(--brand);
}
/* The "?" Icon */
#search-help-toggle {
position: absolute;
right: 8px;
top: 50%;
transform: translateY(-50%);
color: var(--text-secondary);
font-size: 12px;
font-weight: bold;
cursor: help;
user-select: none;
}
#search-help-popup {
position: absolute;
bottom: 100%;
left: 50%; /* Anchor to the horizontal center of the search bar */
margin-bottom: 8px;
background: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: 6px;
padding: 10px 14px;
font-size: 12px;
color: var(--text-primary);
white-space: normal;
z-index: 9999;
box-shadow: 0 4px 12px var(--shadow);
line-height: 1.5;
min-width: 240px;
max-width: 380px;
word-break: break-word;
visibility: hidden;
opacity: 0;
/* Shift left by 50% of its own width to center it, and push down 5px for the animation */
transform: translate(-50%, 5px);
transform-origin: bottom center;
transition: opacity .12s ease, transform .12s ease, visibility .12s;
pointer-events: none;
}
#search-input-wrapper:hover #search-help-popup {
visibility: visible;
opacity: 1;
/* Maintain the horizontal centering while sliding up to 0px */
transform: translate(-50%, 0);
}
.lfm-custom-form input {
background: var(--input-bg);
border: 1px solid var(--input-border);
border-radius: 4px;
padding: 4px 8px;
color: var(--text-primary);
font-size: 11px;
}
.lfm-custom-form button {
background: var(--brand);
color: #fff;
border: none;
border-radius: 4px;
padding: 4px 10px;
cursor: pointer;
font-size: 11px;
}
.lfm-custom-item {
display: flex;
justify-content: space-between;
padding: 4px 15px;
color: var(--text-primary);
font-size: 12px;
direction: ltr;
}
.lfm-custom-item button {
background: none;
border: none;
color: var(--brand);
cursor: pointer;
}
@keyframes lfmFadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
#lfm-modal-overlay {
position: fixed;
top: 0; left: 0; right: 0; bottom: 0;
background: rgba(0,0,0,.6);
z-index: 99999;
display: flex;
align-items: center;
justify-content: center;
animation: lfmFadeIn .15s ease-out;
}
#lfm-modal-box {
background: var(--bg-primary);
border-radius: 10px;
box-shadow: 0 8px 32px var(--menu-shadow);
min-width: 340px;
max-width: 520px;
max-height: 80vh;
overflow-y: auto;
padding: 20px;
direction: ltr;
animation: lfmSlideUp .2s ease-out;
}
#lfm-modal-box .modal-header {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 14px;
}
#lfm-modal-box .modal-header-icon {
font-size: 18px;
color: var(--brand);
width: 24px;
text-align: center;
}
#lfm-modal-box .modal-header-title {
flex: 1;
font-size: 17px;
font-weight: 700;
color: var(--text-primary);
}
#lfm-modal-box .modal-header-close {
cursor: pointer;
color: var(--text-secondary);
font-size: 18px;
transition: color .15s;
line-height: 1;
}
#lfm-modal-box .modal-header-close:hover { color: var(--brand); }
#lfm-modal-box h2 {
margin: 0 0 16px;
color: var(--brand);
font-size: 18px;
font-weight: 700;
}
#lfm-modal-close {
float: right;
cursor: pointer;
color: var(--text-secondary);
font-size: 20px;
transition: color .15s;
}
#lfm-modal-close:hover { color: var(--brand); }
#lfm-modal-box textarea {
width: 100%;
box-sizing: border-box;
background: var(--input-bg);
border: 1px solid var(--input-border);
border-radius: 6px;
padding: 10px;
color: var(--text-primary);
font-size: 13px;
resize: vertical;
min-height: 80px;
font-family: inherit;
}
#lfm-modal-box textarea:focus {
outline: none;
border-color: var(--brand);
}
#lfm-modal-box select,
#lfm-modal-box input[type="text"],
#lfm-modal-box input[type="number"],
#lfm-modal-box button {
background: var(--bg-secondary);
border: 1px solid var(--input-border);
border-radius: 4px;
padding: 5px 8px;
color: var(--text-primary);
font-size: 12px;
cursor: pointer;
transition: background .15s, border-color .15s;
width: 140px;
min-width: 140px;
box-sizing: border-box;
}
#lfm-modal-box select:hover,
#lfm-modal-box input:hover,
#lfm-modal-box button:hover {
background: var(--bg-hover);
border-color: var(--text-secondary);
}
#lfm-modal-box select:focus,
#lfm-modal-box input:focus {
outline: none;
border-color: var(--brand);
}
#lfm-modal-box button {
width: auto;
min-width: auto;
}
/* AI Popup Specific Polish */
.ai-modal .modal-header {
border-bottom: 1px solid var(--border-color);
padding-bottom: 12px;
margin-bottom: 12px;
}
#ai-context-display {
background: rgba(255, 255, 255, 0.03);
border: 1px solid var(--border-color);
border-radius: 6px;
padding: 8px 12px;
margin-bottom: 12px;
}
#lfm-modal-box .lfm-modal-actions {
display: flex;
gap: 8px;
margin-top: 12px;
flex-wrap: wrap;
}
#lfm-modal-box .lfm-modal-actions button {
flex: 1;
min-width: 80px;
}
#lfm-modal-box .btn-primary {
background: var(--brand) !important;
color: #fff !important;
border-color: var(--brand) !important;
font-weight: 600;
}
#lfm-modal-box .btn-primary:hover {
background: var(--brand-hover) !important;
}
.lfm-prompt-chip {
display: inline-block;
padding: 4px 10px;
margin: 3px 4px 3px 0;
background: var(--bg-secondary);
border: 1px solid var(--input-border);
border-radius: 14px;
color: var(--text-primary);
font-size: 12px;
cursor: pointer;
transition: background .15s, border-color .15s;
}
.lfm-prompt-chip:hover {
background: var(--brand);
color: #fff;
border-color: var(--brand);
}
.lfm-toolbox-wrapper {
display: inline;
}
.lfm-hover-icon {
visibility: hidden;
opacity: 0;
display: inline-flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
margin-left: 6px;
border-radius: 50%;
background: rgba(0,0,0,.75);
color: #DA2323;
font-size: 10px;
cursor: pointer;
vertical-align: middle;
transition: opacity .15s ease;
}
.lfm-toolbox-wrapper:hover .lfm-hover-icon {
visibility: visible;
opacity: 1;
}
.lfm-grid-toolbox-icon {
position: absolute;
right: 6px;
bottom: 6px;
width: 22px;
height: 22px;
border-radius: 50%;
background: rgba(0,0,0,.75);
color: #DA2323;
display: flex;
align-items: center;
justify-content: center;
visibility: hidden;
opacity: 0;
cursor: pointer;
z-index: 9999;
transition: opacity .15s ease;
}
.grid-items-item:hover .lfm-grid-toolbox-icon,
.resource-list--release-list-item:hover .lfm-grid-toolbox-icon,
.chartlist-row:hover .lfm-grid-toolbox-icon,
.chartlist tr:hover .lfm-grid-toolbox-icon,
.cover-art:hover .lfm-grid-toolbox-icon,
.chartlist-image:hover .lfm-grid-toolbox-icon {
visibility: visible;
opacity: 1;
}
.lfm-hover-icon i,
.lfm-hover-icon svg,
.lfm-grid-toolbox-icon i,
.lfm-grid-toolbox-icon svg {
pointer-events: none;
}
/* Hide inline elements globally when the select choice is 'hidden' */
html.lfm-icons-hidden .lfm-hover-icon,
html.lfm-icons-hidden .lfm-grid-toolbox-icon {
display: none !important;
}
/* Minimal Icon Override Variant */
html.lfm-icon-style-minimal .lfm-hover-icon,
html.lfm-icon-style-minimal .lfm-grid-toolbox-icon {
background: transparent !important;
box-shadow: none !important;
color: var(--brand) !important;
font-size: 14px;
}
/* ==========================================================================
TEXT & GRID HIGHLIGHT LAYER
Active via standard switch (.lfm-highlight-eligible)
OR automatically forced when inline icons are hidden (.lfm-icons-hidden)
========================================================================== */
/* 1. Standard inline text links turn red with an underline */
html.lfm-highlight-eligible a.lfm-toolbox-enabled:hover,
html.lfm-icons-hidden a.lfm-toolbox-enabled:hover {
color: var(--brand) !important;
text-decoration: underline !important;
}
/* 2. Albums Grid Layout: Strictly target title nodes. Do not highlight play counts or artist subtexts */
html.lfm-highlight-eligible .grid-items-item-main-text > a:hover,
html.lfm-icons-hidden .grid-items-item-main-text > a:hover,
html.lfm-highlight-eligible .resource-list--release-list-item-main-text > a:hover,
html.lfm-icons-hidden .resource-list--release-list-item-main-text > a:hover {
color: var(--brand) !important;
text-shadow: 0 0 8px rgba(218, 35, 35, 0.6) !important;
text-decoration: none !important;
}
/* 3. Artists Grid Layout: Glow primary string text titles */
html.lfm-highlight-eligible .grid-items-item-name:hover,
html.lfm-icons-hidden .grid-items-item-name:hover,
html.lfm-highlight-eligible .grid-items-item-details > a:hover,
html.lfm-icons-hidden .grid-items-item-details > a:hover,
html.lfm-highlight-eligible .cover-art-caption > a:hover,
html.lfm-icons-hidden .cover-art-caption > a:hover {
color: var(--brand) !important;
text-shadow: 0 0 8px rgba(218, 35, 35, 0.6) !important;
text-decoration: none !important;
}
.menu-section .section-chevron {
width: 14px;
min-width: 14px;
display: inline-block;
text-align: center;
color: var(--brand);
font-size: 13px;
font-weight: 700;
line-height: 1;
transition: transform .2s;
}
.section-content {
max-height: 0;
opacity: 0;
overflow: hidden;
transition: max-height .35s ease, opacity .25s ease, padding .25s ease;
}
.section-content.visible {
max-height: 5000px;
opacity: 1;
overflow: visible;
}
#external-music-menu a {
display: block;
padding: 8px 15px;
color: var(--text-primary) !important;
text-decoration: none !important;
font-size: 13px;
direction: ltr;
transition: background .15s, padding-left .15s, color .15s;
border-left: 3px solid transparent;
}
#external-music-menu a:hover {
background: var(--bg-hover);
color: var(--brand) !important;
padding-left: 18px;
border-left-color: var(--brand);
}
#external-music-menu a.disabled {
color: var(--text-disabled) !important;
pointer-events: none;
border-left-color: transparent;
}
#external-music-menu a.disabled:hover {
padding-left: 15px;
}
#lfm-modal-box .btn-primary {
background: var(--brand) !important;
color: #fff !important;
border-color: var(--brand) !important;
font-weight: 600;
transition: transform .2s, background .2s, box-shadow .2s;
}
#lfm-modal-box .btn-primary:hover {
background: var(--brand-hover) !important;
transform: translateY(-1px);
box-shadow: 0 3px 10px rgba(218,35,35,.25);
}
#lfm-modal-box .btn-primary:active {
transform: translateY(0);
}
@media (prefers-reduced-motion: reduce) {
#external-music-menu.visible,
#lfm-modal-box,
#lfm-modal-overlay,
.section-content,
#external-music-menu a,
#lfm-modal-box .btn-primary,
.lfm-hover-icon,
.lfm-grid-toolbox-icon,
.menu-section .section-chevron {
animation: none !important;
transition: none !important;
transform: none !important;
}
}
.lfm-light-mode #external-music-menu,
.lfm-light-mode #lfm-modal-box {
border: 1px solid #d0d0d0;
}
.lfm-light-mode #external-music-button,
.lfm-light-mode .lfm-hover-icon,
.lfm-light-mode .lfm-grid-toolbox-icon {
background: #fff;
border: 1px solid #d0d0d0;
color: #DA2323;
box-shadow: 0 2px 6px rgba(0,0,0,.18);
}
#lfm-ai-toggle-bar {
display: flex;
justify-content: center;
padding: 4px 12px 2px;
direction: ltr;
}
#open-ai-btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 4px;
padding: 2px 8px;
min-height: 26px;
min-width: max-content;
background: var(--bg-secondary);
border: 1px solid transparent;
border-radius: 6px;
color: var(--text-primary) !important;
text-decoration: none !important;
font-size: 10px;
font-weight: 700;
letter-spacing: .5px;
text-transform: uppercase;
line-height: 1;
transition: border-color .15s, color .15s, background .15s;
}
#open-ai-btn:hover {
border-color: var(--brand);
color: var(--brand) !important;
text-decoration: none !important;
}
#open-ai-btn .ai-btn-icon {
display: none;
font-size: 11px;
color: var(--brand);
}
#open-ai-btn:hover .ai-btn-icon {
display: inline;
}
/* #lfm-ai-toggle-btn is just a passthrough span now */
#lfm-ai-toggle-btn {
display: contents;
}
#lfm-menu-header {
padding: 4px 15px 4px;
direction: ltr;
display: flex;
flex-direction: column;
gap: 2px;
}
/* Hide the standalone header badge since context is shown inline in quick-actions */
#lfm-header-badge {
display: none;
}
.context-badge-artist {
background: #3b5f8a;
color: #fff;
border: 1px solid #4a7ab5;
}
.context-badge-album {
background: rgba(230,126,34,.2);
color: #f0a050;
border: 1px solid rgba(230,126,34,.35);
}
.context-badge-track {
background: rgba(46,204,113,.2);
color: #6bdb9a;
border: 1px solid rgba(46,204,113,.35);
}
.lfm-light-mode .context-badge-artist {
background: rgba(218,35,35,.12);
color: #c62828;
border-color: rgba(218,35,35,.3);
}
.lfm-light-mode .context-badge-album {
background: rgba(230,126,34,.12);
color: #c67a1e;
border-color: rgba(230,126,34,.3);
}
.lfm-light-mode .context-badge-track {
background: rgba(46,204,113,.12);
color: #2a9d6b;
border-color: rgba(46,204,113,.3);
}
#lfm-header-title {
color: var(--text-primary);
font-size: 16px;
font-weight: 700;
line-height: 1.3;
word-break: break-word;
margin-top: 4px;
}
#lfm-header-subtitle {
color: var(--text-secondary);
font-size: 12px;
font-weight: 400;
line-height: 1.3;
margin-bottom: 2px;
}
#lfm-quick-actions {
display: flex;
gap: 4px;
padding: 6px 15px 10px;
direction: ltr;
align-items: center;
}
#lfm-quick-actions a {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
gap: 4px;
padding: 6px 4px;
background: var(--bg-secondary);
border: 1px solid transparent;
border-radius: 6px;
color: var(--text-primary) !important;
text-decoration: none !important;
font-size: 12px;
font-weight: 600;
transition: border-color .15s, color .15s;
}
#lfm-quick-actions a span {
display: none;
line-height: 1;
}
#lfm-quick-actions a:hover {
border-color: var(--brand);
color: var(--brand) !important;
}
#lfm-quick-actions a:hover span {
display: inline;
}
#lfm-quick-actions a i {
font-size: 16px;
color: var(--text-secondary);
transition: color .15s;
}
#lfm-quick-actions a:hover i {
color: var(--brand);
}
#lfm-context-inline-badge {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 6px 4px;
border-radius: 4px;
font-size: 9px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: .8px;
line-height: 1.4;
cursor: default;
flex: 2;
}
#search-help {
margin: 4px 15px 8px;
font-size: 11px;
color: var(--text-secondary);
direction: ltr;
}
#lfm-footer {
display: flex;
justify-content: center;
gap: 10px;
padding: 8px 15px;
direction: ltr;
border-top: 1px solid var(--border-color);
}
#lfm-footer a {
display: flex;
align-items: center;
justify-content: center;
width: 34px;
height: 34px;
border-radius: 50%;
color: var(--text-secondary) !important;
text-decoration: none !important;
font-size: 16px;
transition: background .15s, color .15s;
}
#lfm-footer a:hover {
background: var(--bg-hover);
color: var(--brand) !important;
}
.section-block {
margin-bottom: 2px;
}
.section-block[data-section="settings"],
.section-block[data-section="custom"] {
margin-top: 6px;
}
.menu-section {
padding: 8px 15px;
}
.menu-section + .section-content a {
padding: 9px 15px 9px 20px;
}
#lfm-modal-box.settings-modal {
width: 650px;
height: 650px;
max-width: 95vw;
max-height: 90vh;
box-sizing: border-box;
display: flex;
flex-direction: column;
overflow: hidden;
padding: 0;
}
#lfm-modal-box.settings-modal .settings-header {
display: flex;
align-items: center;
justify-content: center;
padding: 16px 24px 0;
position: relative;
}
#lfm-modal-box.settings-modal .settings-header h2 {
margin: 0;
font-size: 22px;
font-weight: 700;
color: var(--text-primary);
text-align: center;
}
#lfm-modal-box.settings-modal .settings-header .settings-header-btn {
position: absolute;
right: 16px;
top: 50%;
transform: translateY(-50%);
width: 34px;
height: 34px;
border-radius: 50%;
background: var(--bg-secondary);
color: var(--brand);
border: none;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 16px;
transition: background .15s, color .15s;
text-decoration: none;
}
#lfm-modal-box.settings-modal .settings-header .settings-header-btn:hover {
background: var(--bg-hover);
color: var(--brand-hover);
}
#lfm-modal-box.settings-modal .settings-body {
display: flex;
flex-direction: column;
flex: 1;
overflow: hidden;
padding: 12px 20px 20px;
gap: 12px;
min-height: 0;
}
.settings-modal .settings-content {
display: flex;
flex-direction: column;
flex: 1;
min-width: 0;
min-height: 0;
overflow: hidden;
}
.settings-modal .settings-tabs {
display: flex;
flex-direction: row;
flex-wrap: wrap;
gap: 8px;
width: 100%;
border: none;
margin: 0 0 12px;
padding: 0;
justify-content: center;
overflow-x: auto;
}
#lfm-modal-box .settings-tab {
padding: 4px 10px !important;
border: 1px solid var(--border-color);
background: transparent;
color: var(--text-secondary);
font-size: 11px !important;
font-weight: 600;
cursor: pointer;
border-radius: 4px !important;
transition: all .2s ease;
display: inline-flex;
align-items: center;
gap: 5px;
white-space: nowrap;
}
#lfm-modal-box .settings-tab:hover {
background: var(--bg-hover);
color: var(--text-primary);
border-color: var(--text-secondary);
}
#lfm-modal-box .settings-tab:active {
transform: none;
}
#lfm-modal-box .settings-tab.active {
background: rgba(var(--brand-rgb), .10) !important;
color: var(--text-primary) !important;
border-color: var(--brand) !important;
text-decoration: underline;
text-decoration-color: var(--brand);
text-underline-offset: 2px;
box-shadow: inset 0 0 0 1px rgba(var(--brand-rgb), .15);
}
#lfm-modal-box .settings-tab.active i {
color: var(--text-primary) !important;
}
#lfm-modal-box .settings-tab:focus {
outline: none;
}
#lfm-modal-box .settings-tab:focus-visible {
outline: 2px solid var(--brand);
outline-offset: -1px;
}
#lfm-modal-box .settings-tab i {
font-size: 12px;
width: 14px;
text-align: center;
flex-shrink: 0;
transition: color .2s;
}
.settings-modal .settings-panel {
flex: 1 1 auto;
min-height: 0;
overflow-y: auto;
padding-right: 4px;
display: flex;
flex-direction: column;
gap: 12px;
min-width: 0;
}
.service-launch-btn.hidden {
display: none !important;
}
.settings-panel {
min-height: 80px;
}
.settings-card {
background: var(--bg-secondary);
border-radius: 6px;
border: 1px solid var(--border-color);
padding: 12px;
}
.settings-card-title {
margin: 0 0 8px;
font-size: 12px;
font-weight: 600;
color: var(--text-primary);
}
.settings-grid-card {
background: var(--bg-secondary) !important;
border: 1px solid var(--border-color) !important;
border-radius: 8px !important;
padding: 12px !important;
transition: border-color .15s, background .15s !important;
box-sizing: border-box;
}
a.settings-grid-card:hover {
border-color: var(--brand) !important;
background: var(--bg-hover) !important;
}
.settings-group {
display: flex;
flex-direction: column;
gap: 10px;
}
.settings-info {
display: flex;
flex-direction: column;
gap: 2px;
flex: 1;
min-width: 0;
}
.settings-label {
font-size: 13px;
font-weight: 600;
color: var(--text-primary);
}
.settings-desc {
font-size: 11px;
color: var(--text-secondary);
line-height: 1.4;
}
.settings-section-title {
margin: 0 0 10px;
font-size: 12px;
color: var(--text-secondary);
line-height: 1.5;
}
.settings-author-link {
color: inherit;
text-decoration: underline;
transition: color .15s;
}
.settings-author-link:hover {
color: var(--brand);
}
.settings-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
width: 100%;
padding: 8px 10px;
border-radius: 8px;
transition: background .15s, border-color .15s;
box-sizing: border-box;
border: 1px solid transparent;
}
.settings-item + .settings-item {
border-top: 1px solid rgba(255, 255, 255, .08);
}
.settings-item:hover {
background: var(--bg-hover);
border-color: rgba(218, 35, 35, .25);
}
.settings-item select {
flex-shrink: 0;
min-width: 130px;
}
.modal-hr {
border: none;
height: 1px;
background: var(--border-color);
margin: 12px 0;
}
.ai-dropdown-row {
display: flex;
gap: 12px;
margin: 0;
direction: ltr;
}
.ai-field {
flex: 1;
display: flex;
flex-direction: column;
gap: 5px;
}
.ai-field label {
font-size: 11px;
font-weight: 600;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: .6px;
}
.ai-field select {
background: var(--input-bg);
border: 1px solid var(--input-border);
border-radius: 6px;
padding: 8px 10px;
color: var(--text-primary);
font-size: 13px;
cursor: pointer;
transition: border-color .15s;
}
.ai-field select:focus {
border-color: var(--brand);
outline: none;
}
.ai-provider-row {
display: flex;
align-items: center;
gap: 12px;
margin: 0;
direction: ltr;
}
.ai-provider-row label {
font-size: 11px;
font-weight: 600;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: .6px;
white-space: nowrap;
}
.ai-provider-row select {
flex: 1;
background: var(--input-bg);
border: 1px solid var(--input-border);
border-radius: 6px;
padding: 8px 10px;
color: var(--text-primary);
font-size: 13px;
cursor: pointer;
transition: border-color .15s;
}
.ai-provider-row select:focus {
border-color: var(--brand);
outline: none;
}
.lfm-modal-actions {
display: flex;
gap: 8px;
margin-top: 14px;
direction: ltr;
}
.lfm-modal-actions button {
flex: 1;
padding: 10px 14px;
border: 1px solid var(--border-color);
border-radius: 8px;
font-size: 12px;
font-weight: 600;
cursor: pointer;
transition: all .2s ease;
background: var(--bg-secondary);
color: var(--text-primary);
}
.lfm-modal-actions button:hover {
background: var(--bg-hover);
transform: translateY(-1px);
box-shadow: 0 2px 8px var(--shadow);
border-color: var(--text-secondary);
}
.lfm-modal-actions button:active {
transform: translateY(0);
}
.lfm-modal-actions button i {
margin-right: 6px;
}
#ai-context-display {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
background: var(--bg-secondary);
border-radius: 6px;
margin-bottom: 10px;
direction: ltr;
}
.ai-context-icon {
font-size: 16px;
color: var(--brand);
width: 20px;
text-align: center;
flex-shrink: 0;
}
.ai-context-type {
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: .5px;
color: var(--text-secondary);
padding: 2px 6px;
background: var(--bg-primary);
border-radius: 4px;
}
.ai-context-name {
font-size: 13px;
font-weight: 600;
color: var(--text-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.settings-custom-form {
display: flex;
gap: 4px;
padding: 10px 12px;
direction: ltr;
flex-wrap: wrap;
}
.settings-custom-form input {
flex: 1;
min-width: 100px;
background: var(--input-bg);
border: 1px solid var(--input-border);
border-radius: 6px;
padding: 6px 8px;
color: var(--text-primary);
font-size: 12px;
}
.settings-custom-form button {
background: var(--brand);
color: #fff;
border: none;
border-radius: 6px;
padding: 6px 12px;
font-size: 12px;
font-weight: 600;
cursor: pointer;
white-space: nowrap;
}
.settings-remove-btn {
background: none;
border: none;
color: var(--text-disabled);
cursor: pointer;
font-size: 16px;
padding: 4px;
border-radius: 4px;
transition: color .15s, background .15s;
flex-shrink: 0;
}
.settings-remove-btn:hover {
color: var(--brand);
background: var(--bg-hover);
}
#settings-custom-list {
max-height: 200px;
overflow-y: auto;
margin-bottom: 8px;
}
#settings-custom-list:empty {
display: none;
}
#lfm-modal-box.ai-modal {
width: 650px;
height: 650px;
max-width: 95vw;
max-height: 90vh;
box-sizing: border-box;
display: flex;
flex-direction: column;
overflow: hidden;
padding: 24px;
}
#lfm-modal-box.ai-modal #ai-prompt-text {
flex: 1;
resize: none;
margin: 10px 0;
}
#lfm-scroll-area::-webkit-scrollbar,
#lfm-modal-box.ai-modal::-webkit-scrollbar,
.settings-modal .settings-panel::-webkit-scrollbar,
#settings-custom-list::-webkit-scrollbar {
width: 6px;
}
#lfm-scroll-area::-webkit-scrollbar-track,
#lfm-modal-box.ai-modal::-webkit-scrollbar-track,
.settings-modal .settings-panel::-webkit-scrollbar-track,
#settings-custom-list::-webkit-scrollbar-track {
background: var(--scrollbar-bg);
}
#lfm-scroll-area::-webkit-scrollbar-thumb,
#lfm-modal-box.ai-modal::-webkit-scrollbar-thumb,
.settings-modal .settings-panel::-webkit-scrollbar-thumb,
#settings-custom-list::-webkit-scrollbar-thumb {
background: var(--scrollbar-thumb);
border-radius: 3px;
}
#lfm-scroll-area::-webkit-scrollbar-thumb:hover,
#lfm-modal-box.ai-modal::-webkit-scrollbar-thumb:hover,
.settings-modal .settings-panel::-webkit-scrollbar-thumb:hover,
#settings-custom-list::-webkit-scrollbar-thumb:hover {
background: var(--scrollbar-thumb-hover);
}
.lfm-light-mode #lfm-scroll-area::-webkit-scrollbar-track,
.lfm-light-mode #lfm-modal-box.ai-modal::-webkit-scrollbar-track,
.lfm-light-mode .settings-modal .settings-panel::-webkit-scrollbar-track,
.lfm-light-mode #settings-custom-list::-webkit-scrollbar-track {
background: var(--scrollbar-bg);
}
.lfm-light-mode #lfm-scroll-area::-webkit-scrollbar-thumb,
.lfm-light-mode #lfm-modal-box.ai-modal::-webkit-scrollbar-thumb,
.lfm-light-mode .settings-modal .settings-panel::-webkit-scrollbar-thumb,
.lfm-light-mode #settings-custom-list::-webkit-scrollbar-thumb {
background: var(--scrollbar-thumb);
}
.lfm-light-mode #lfm-scroll-area::-webkit-scrollbar-thumb:hover,
.lfm-light-mode #lfm-modal-box.ai-modal::-webkit-scrollbar-thumb:hover,
.lfm-light-mode .settings-modal .settings-panel::-webkit-scrollbar-thumb:hover,
.lfm-light-mode #settings-custom-list::-webkit-scrollbar-thumb:hover {
background: var(--scrollbar-thumb-hover);
}
.service-row {
display: flex;
align-items: center;
}
.service-link {
flex: 1;
min-width: 0;
display: flex !important;
align-items: center;
}
.service-launch-btn {
flex: 0 0 auto;
background: transparent;
border: none;
cursor: pointer;
padding: 4px 10px;
font-size: 11px;
color: var(--text-secondary);
opacity: 0;
transition: opacity .15s;
line-height: 1;
}
.service-row:hover .service-launch-btn {
opacity: .65;
}
/* Always show the theaudiodb json/md buttons and make action icons visible */
.service-json-btn, .service-md-btn {
opacity: 1 !important;
display: inline-flex !important;
align-items: center;
justify-content: center;
padding: 4px 8px;
margin-left: 6px;
color: var(--text-secondary);
background: transparent;
border: none;
cursor: pointer;
}
.service-json-btn:hover, .service-md-btn:hover {
color: var(--brand);
}
/* Style for small action icons placed next to services */
.service-action {
display: inline-flex;
align-items: center;
gap: 8px;
margin-left: 8px;
}
.service-action-btn {
flex: 0 0 auto;
background: transparent;
border: none;
cursor: pointer;
padding: 4px 4px;
font-size: 11px;
color: var(--text-secondary);
opacity: 0.75;
transition: opacity .15s, color .15s;
line-height: 1;
}
.service-action-btn:hover {
opacity: 1;
color: var(--brand);
}
.service-action-btn i,
.service-action-btn svg {
pointer-events: none;
}
.service-launch-btn:hover {
opacity: 1 !important;
color: var(--brand);
}
.service-ai-btn {
flex: 0 0 auto;
background: transparent;
border: none;
cursor: pointer;
padding: 4px 4px;
font-size: 11px;
color: var(--text-secondary);
opacity: 0;
transition: opacity .15s, color .15s;
line-height: 1;
}
.service-row:hover .service-ai-btn {
opacity: .65;
}
.service-ai-btn:hover {
opacity: 1 !important;
color: var(--brand) !important;
}
.service-ai-btn i,
.service-ai-btn svg,
.service-json-btn i,
.service-json-btn svg,
.service-md-btn i,
.service-md-btn svg {
pointer-events: none;
}
.service-json-btn,
.service-md-btn {
flex: 0 0 auto;
background: transparent;
border: none;
cursor: pointer;
padding: 4px 4px;
font-size: 11px;
color: var(--text-secondary);
opacity: 0;
transition: opacity .15s, color .15s;
line-height: 1;
}
.service-row:hover .service-json-btn,
.service-row:hover .service-md-btn {
opacity: .65;
}
.service-json-btn:hover,
.service-md-btn:hover {
opacity: 1 !important;
color: var(--brand) !important;
}
.service-launch-btn i,
.service-launch-btn svg {
pointer-events: none;
}
/* Hide specific individual services when deactivated in settings */
#external-music-menu .service-row.lfm-service-hidden {
display: none !important;
}
`);
let currentContext = { type: "artist", artist: "", album: "", track: "" };
let lastUrl = location.href;
const CATEGORY_ORDER = ["databases", "streaming", "lyrics", "covers", "social", "additional", "lfm-tools", "ai"];
const CATEGORY_LABELS = {
databases: "Databases",
streaming: "Streaming",
lyrics: "Lyrics",
covers: "Covers & Images",
social: "Social Media",
additional: "Utilities",
"lfm-tools": "LFM Tools",
ai: "AI",
};
const SERVICE_CATEGORIES = {
databases: ["google-band-link", "album-of-the-year-link", "theaudiodb-link", "metal-archives-link", "rym-link", "discogs-link", "musicbrainz-link", "wikipedia-link", "allmusic-link", "listenbrainz-link", "everynoise-link"],
streaming: ["spotify-link", "youtube-link", "youtube-music-link", "apple-music-link", "bandcamp-link", "soundcloud-link", "deezer-link", "tidal-link", "amazon-link", "qobuz-link", "audiomack-link", "monochrome-link"],
lyrics: ["genius-link", "darklyrics-link", "google-lyrics-link", "musixmatch-link"],
covers: ["cov-musichoarderz-link", "google-images-link", "yahoo-images-link", "bing-images-link", "fanart-tv-link"],
social: ["instagram-link", "facebook-link", "reddit-link", "twitter-x-link"],
additional: ["chosic-link", "spirit-of-metal-link", "metalstorm-link", "lucida-link", "sputnikmusic-link", "audio-archive-link", "whosampled-link", "musicmap-link"],
"lfm-tools": ["bijou-link", "lastfmstats-link", "lastfmlive-link", "explr-link", "tapmusic-link", "universal-scrobbler-link", "time-capsule-link", "manual-scrobbler-link"],
ai: ["perplexity-link", "chatgpt-link", "claude-link", "brave-ai-link", "mistral-link", "huggingchat-link", "you-link", "grok-link"],
};
const SERVICE_LABELS = {
"search-link": "Search",
"listen-link": "Listen",
"google-band-link": "Google",
"metal-archives-link": "Metal Archives",
"rym-link": "Rate Your Music",
"discogs-link": "Discogs",
"musicbrainz-link": "MusicBrainz",
"wikipedia-link": "Wikipedia",
"spotify-link": "Spotify",
"youtube-link": "YouTube",
"youtube-music-link": "YT Music",
"apple-music-link": "Apple Music",
"bandcamp-link": "Bandcamp",
"soundcloud-link": "SoundCloud",
"deezer-link": "Deezer",
"tidal-link": "Tidal",
"qobuz-link": "Qobuz",
"amazon-link": "Amazon Music",
"genius-link": "Genius",
"darklyrics-link": "Dark Lyrics",
"google-lyrics-link": "Google",
"musixmatch-link": "Musixmatch",
"cov-musichoarderz-link": "COV - MusicHoarders",
"google-images-link": "Google",
"yahoo-images-link": "Yahoo",
"bing-images-link": "Bing",
"instagram-link": "Instagram",
"facebook-link": "Facebook",
"reddit-link": "Reddit",
"allmusic-link": "AllMusic",
"chosic-link": "Chosic",
"spirit-of-metal-link": "Spirit of Metal",
"metalstorm-link": "Metal Storm",
"fanart-tv-link": "Fanart.tv",
"lucida-link": "Lucida",
"album-of-the-year-link": "AOTY",
"sputnikmusic-link": "Sputnikmusic",
"theaudiodb-link": "TheAudioDB",
"listenbrainz-link": "ListenBrainz",
"everynoise-link": "Every Noise at Once",
"monochrome-link": "Monochrome",
"twitter-x-link": "X (Twitter)",
"perplexity-link": "Perplexity",
"chatgpt-link": "ChatGPT",
"claude-link": "Claude",
"brave-ai-link": "Brave AI",
"mistral-link": "Mistral",
"huggingchat-link": "HuggingChat",
"you-link": "You.com",
"grok-link": "Grok",
"audio-archive-link": "Audio Archive",
"whosampled-link": "WhoSampled",
"audiomack-link": "Audiomack",
"bijou-link": "Bijou",
"lastfmstats-link": "Stats",
"lastfmlive-link": "Live Dashboard",
"explr-link": "Explr.fm - Regions",
"tapmusic-link": "TapMusic - Collage",
"universal-scrobbler-link": "Universal Scrobbler",
"time-capsule-link": "Time Capsule",
"manual-scrobbler-link": "Manual Scrobbler",
"musicmap-link": "MusicMap",
};
function getServiceIcon(id) {
const icons = {
"search-link": '<i class="fa-solid fa-search"></i>',
"listen-link": '<i class="fa-solid fa-headphones"></i>',
"google-band-link": '<i class="fa-brands fa-google"></i>',
"metal-archives-link": '<i class="fa-solid fa-radiation"></i>',
"rym-link": '<i class="fa-solid fa-star"></i>',
"discogs-link": '<i class="fa-solid fa-record-vinyl"></i>',
"musicbrainz-link": '<i class="fa-solid fa-brain"></i>',
"wikipedia-link": '<i class="fa-brands fa-wikipedia-w"></i>',
"spotify-link": '<i class="fa-brands fa-spotify"></i>',
"youtube-link": '<i class="fa-brands fa-youtube"></i>',
"youtube-music-link": '<i class="fa-solid fa-circle-play"></i>',
"apple-music-link": '<i class="fa-brands fa-itunes"></i>',
"bandcamp-link": '<i class="fa-brands fa-bandcamp"></i>',
"soundcloud-link": '<i class="fa-brands fa-soundcloud"></i>',
"deezer-link": '<i class="fa-brands fa-deezer"></i>',
"tidal-link": '<i class="fa-solid fa-water"></i>',
"qobuz-link": '<i class="fa-solid fa-music"></i>',
"amazon-link": '<i class="fa-brands fa-amazon"></i>',
"genius-link": '<i class="fa-solid fa-lightbulb"></i>',
"darklyrics-link": '<i class="fa-solid fa-scroll"></i>',
"google-lyrics-link": '<i class="fa-brands fa-google"></i>',
"musixmatch-link": '<i class="fa-solid fa-microphone-lines"></i>',
"cov-musichoarderz-link": '<i class="fa-solid fa-image"></i>',
"google-images-link": '<i class="fa-brands fa-google"></i>',
"yahoo-images-link": '<i class="fa-brands fa-yahoo"></i>',
"bing-images-link": '<i class="fa-brands fa-microsoft"></i>',
"instagram-link": '<i class="fa-brands fa-instagram"></i>',
"facebook-link": '<i class="fa-brands fa-facebook"></i>',
"reddit-link": '<i class="fa-brands fa-reddit"></i>',
"allmusic-link": '<i class="fa-solid fa-circle-info"></i>',
"chosic-link": '<i class="fa-solid fa-wave-square"></i>',
"spirit-of-metal-link": '<i class="fa-solid fa-guitar"></i>',
"metalstorm-link": '<i class="fa-solid fa-bolt"></i>',
"fanart-tv-link": '<i class="fa-solid fa-photo-film"></i>',
"lucida-link": '<i class="fa-solid fa-download"></i>',
"album-of-the-year-link": '<i class="fa-solid fa-calendar"></i>',
"sputnikmusic-link": '<i class="fa-solid fa-guitar"></i>',
"theaudiodb-link": '<i class="fa-solid fa-headphones-simple"></i>',
"listenbrainz-link": '<i class="fa-solid fa-ear-listen"></i>',
"everynoise-link": '<i class="fa-solid fa-circle-radiation"></i>',
"audio-archive-link": '<i class="fa-solid fa-archive"></i>',
"whosampled-link": '<i class="fa-solid fa-users-between-lines"></i>',
"audiomack-link": '<i class="fa-solid fa-compact-disc"></i>',
"bijou-link": '<i class="fa-solid fa-chart-line"></i>',
"lastfmstats-link": '<i class="fa-solid fa-chart-column"></i>',
"lastfmlive-link": '<i class="fa-solid fa-signal"></i>',
"explr-link": '<i class="fa-solid fa-map"></i>',
"tapmusic-link": '<i class="fa-solid fa-images"></i>',
"universal-scrobbler-link": '<i class="fa-solid fa-cloud-arrow-up"></i>',
"time-capsule-link": '<i class="fa-solid fa-clock-rotate-left"></i>',
"manual-scrobbler-link": '<i class="fa-solid fa-pen-to-square"></i>',
"musicmap-link": '<i class="fa-solid fa-earth-americas"></i>',
/* google action icons */
"google-image-action": '<i class="fa-solid fa-image"></i>',
"google-video-action": '<i class="fa-solid fa-video"></i>',
"google-ai-action": '<i class="fa-solid fa-flask"></i>',
/* Album of the Year sub-actions mapping */
"aoty-tags-action": '<i class="fa-solid fa-tags"></i>',
"aoty-lists-action": '<i class="fa-solid fa-list"></i>',
"aoty-global-action": '<i class="fa-solid fa-earth-europe"></i>',
"monochrome-link": '<i class="fa-solid fa-file-audio"></i>',
"twitter-x-link": '<i class="fa-brands fa-twitter"></i>',
"perplexity-link": '<i class="fa-solid fa-magnifying-glass"></i>',
"chatgpt-link": '<i class="fa-solid fa-robot"></i>',
"claude-link": '<i class="fa-solid fa-clover"></i>',
"brave-ai-link": '<i class="fa-solid fa-shield-halved"></i>',
"mistral-link": '<i class="fa-solid fa-wind"></i>',
"huggingchat-link": '<i class="fa-solid fa-face-smile"></i>',
"you-link": '<i class="fa-solid fa-circle-question"></i>',
"grok-link": '<i class="fa-brands fa-square-x-twitter"></i>',
};
return icons[id] || '<i class="fa-solid fa-link"></i>';
}
const SERVICE_URLS = {
"search-link": ctx => ctx.artist ? `https://www.google.com/search?udm=50&source=searchlabs&q=${ctx.query}` : "#",
"listen-link": ctx => ctx.artist ? `https://monochrome.tf/search/${ctx.query}` : "#",
"google-band-link": ctx => ctx.artist ? `https://www.google.com/search?q=${ctx.query}+${ctx.type}` : "#",
"metal-archives-link": ctx => ctx.artist ? ctx.track ? `https://www.metal-archives.com/search?searchString=${ctx.encodedTrack}&type=song_title` : ctx.album ? `https://www.metal-archives.com/search?type=album_title&searchString=${ctx.encodedAlbum}` : `https://www.metal-archives.com/search?type=band_name&searchString=${ctx.encodedArtist}` : "#",
"rym-link": ctx => ctx.artist ? `https://rateyourmusic.com/search?searchtype=${ctx.album ? "l" : "a"}&searchterm=${ctx.query}` : "#",
"discogs-link": ctx => ctx.artist ? `https://www.discogs.com/search/?q=${ctx.query}&type=${ctx.album ? "release" : "artist"}` : "#",
"musicbrainz-link": ctx => ctx.artist ? `https://musicbrainz.org/search?query=${ctx.query}&type=${ctx.album ? "release" : "artist"}` : "#",
"wikipedia-link": ctx => ctx.artist ? ctx.album ? `https://en.wikipedia.org/w/index.php?search=${ctx.query}` : `https://en.wikipedia.org/wiki/${ctx.encodedArtist}` : "#",
"spotify-link": ctx => ctx.artist ? `https://open.spotify.com/search/${ctx.query}` : "#",
"youtube-link": ctx => ctx.artist ? `https://www.youtube.com/results?search_query=${ctx.query}` : "#",
"youtube-music-link": ctx => ctx.artist ? `https://music.youtube.com/search?q=${ctx.query}` : "#",
"apple-music-link": ctx => ctx.artist ? `https://music.apple.com/us/search?term=${ctx.query}` : "#",
"bandcamp-link": ctx => ctx.artist ? `https://bandcamp.com/search?q=${ctx.query}` : "#",
"soundcloud-link": ctx => ctx.artist ? `https://soundcloud.com/search?q=${ctx.query}` : "#",
"deezer-link": ctx => ctx.artist ? `https://www.deezer.com/search/${ctx.query}` : "#",
"tidal-link": ctx => ctx.artist ? `https://tidal.com/search?q=${ctx.query}` : "#",
"qobuz-link": ctx => ctx.artist ? `https://www.qobuz.com/us-en/search?q=${ctx.query}` : "#",
"amazon-link": ctx => ctx.artist ? `https://music.amazon.com/search?k=${ctx.query}` : "#",
"genius-link": ctx => ctx.artist ? `https://genius.com/search?q=${ctx.query}` : "#",
"darklyrics-link": ctx => ctx.artist ? `http://www.darklyrics.com/search?q=${ctx.query}` : "#",
"google-lyrics-link": ctx => ctx.artist ? `https://www.google.com/search?q=${ctx.query}+lyrics` : "#",
"musixmatch-link": ctx => ctx.artist ? `https://www.musixmatch.com/search/${ctx.query}` : "#",
"cov-musichoarderz-link": ctx => ctx.artist ? ctx.album ? `https://covers.musichoarders.xyz/?artist=${ctx.encodedArtist}&album=${ctx.encodedAlbum}` : `https://covers.musichoarders.xyz/?artist=${ctx.encodedArtist}` : "#",
"google-images-link": ctx => ctx.artist ? `https://www.google.com/search?tbm=isch&q=${ctx.query}+${ctx.type}&tbs=isz:l` : "#",
"yahoo-images-link": ctx => ctx.artist ? `https://images.search.yahoo.com/search/images?p=${ctx.query}` : "#",
"bing-images-link": ctx => ctx.artist ? `https://www.bing.com/images/search?q=${ctx.query}` : "#",
"instagram-link": ctx => ctx.artist ? `https://www.instagram.com/explore/search/keyword/?q=${ctx.encodedArtist}+${ctx.album ? "album" : "band"}` : "#",
"facebook-link": ctx => ctx.artist ? `https://www.facebook.com/search/top?q=${ctx.query}` : "#",
"reddit-link": ctx => ctx.artist ? `https://www.reddit.com/search/?q=${ctx.query}` : "#",
"allmusic-link": ctx => ctx.artist ? `https://www.allmusic.com/search/all/${ctx.query}` : "#",
"chosic-link": ctx => ctx.artist ? `https://www.chosic.com/search-results/?q=${ctx.query}` : "#",
"spirit-of-metal-link": ctx => ctx.artist ? `https://www.spirit-of-metal.com/liste_groupe.php?recherche_groupe=${ctx.encodedArtist}` : "#",
"metalstorm-link": ctx => ctx.artist ? ctx.album ? `https://metalstorm.net/bands/albums.php?a_where=a.albumname&a_what=${ctx.encodedAlbum}` : `https://metalstorm.net/bands/index.php?b_where=b.bandname&b_what=${ctx.encodedArtist}` : "#",
"fanart-tv-link": ctx => ctx.artist ? `https://fanart.tv/add-entry/?tab=music&search=${ctx.encodedArtist}${ctx.album ? "+" + ctx.encodedAlbum : ""}#music` : "#",
"audio-archive-link": ctx => ctx.artist ? `https://archive.org/details/audio?tab=collection&query=${ctx.query}` : "#",
"whosampled-link": ctx => ctx.track ? `https://www.whosampled.com/search/tracks/?q=${ctx.encodedTrack}` : ctx.album ? `https://www.whosampled.com/search/tracks/?q=${encodeURIComponent(ctx.album + ' ' + ctx.artist)}` : ctx.artist ? `https://www.whosampled.com/search/artists/?q=${ctx.encodedArtist}` : "#",
"audiomack-link": ctx => ctx.artist ? `https://audiomack.com/search?q=${ctx.query}` : "#",
"bijou-link": ctx => { const u = localStorage.getItem("setting-lfm-username") || "username"; return `https://www.bijou.fm/user/${u}/forever`; },
"lastfmstats-link": ctx => { const u = localStorage.getItem("setting-lfm-username") || "username"; return `https://lastfmstats.com/user/${u}/general`; },
"lastfmlive-link": ctx => { const u = localStorage.getItem("setting-lfm-username") || "username"; return `https://lastfm.live/${u}`; },
"explr-link": ctx => { const u = localStorage.getItem("setting-lfm-username") || "username"; return `https://mold.github.io/explr/?username=${u}`; },
"tapmusic-link": ctx => { const u = localStorage.getItem("setting-lfm-username") || "username"; return `https://www.tapmusic.net/collage.php?user=${u}&type=3month&size=5x5&caption=true&playcount=true`; },
"lucida-link": ctx => ctx.artist ? `https://lucida.to/search?query=${ctx.query}&service=qobuz` : "#",
"album-of-the-year-link": ctx => ctx.artist ? ctx.album ? `https://www.albumoftheyear.org/search/albums/?q=${ctx.encodedAlbum}` : `https://www.albumoftheyear.org/search/?q=${ctx.encodedArtist}` : "#",
"sputnikmusic-link": ctx => ctx.artist ? `https://www.sputnikmusic.com/search_results.php?genreid=0&search_in=Bands&search_text=${ctx.query}` : "#",
"theaudiodb-link": ctx => ctx.artist ? `https://www.theaudiodb.com/api/v1/json/123/search.php?s=${ctx.encodedArtist}` : "#",
"listenbrainz-link": ctx => ctx.artist ? `https://listenbrainz.org/search/?search_term=${ctx.query}&search_type=${ctx.track ? 'track' : ctx.album ? 'album' : 'artist'}` : "#",
"everynoise-link": ctx => ctx.artist ? ctx.track ? `https://everynoise.com/research.html?mode=track&name=${ctx.encodedTrack}` : ctx.album ? `https://everynoise.com/research.html?mode=album&name=${ctx.encodedAlbum}` : `https://everynoise.com/research.html?mode=name&name=${ctx.encodedArtist}` : "#",
"monochrome-link": ctx => ctx.artist ? `https://monochrome.tf/search/${ctx.query}` : "#",
"twitter-x-link": ctx => ctx.artist ? `https://x.com/search?q=${ctx.query}&src=typed_query&f=user` : "#",
"perplexity-link": ctx => ctx.artist ? `https://www.perplexity.ai/search/new?q=${encodeURIComponent(ctx.album ? `give me a comprehensive overview of the album ${ctx.album} by ${ctx.artist}` : `give me a comprehensive overview of the band ${ctx.artist}`)}` : "#",
"chatgpt-link": ctx => ctx.artist ? `https://chatgpt.com/?prompt=${encodeURIComponent(ctx.album ? `give me a comprehensive overview of the album ${ctx.album} by ${ctx.artist}` : `give me a comprehensive overview of the band ${ctx.artist}`)}` : "#",
"claude-link": ctx => ctx.artist ? `https://claude.ai/new?q=${encodeURIComponent(ctx.album ? `give me a comprehensive overview of the album ${ctx.album} by ${ctx.artist}` : `give me a comprehensive overview of the band ${ctx.artist}`)}` : "#",
"brave-ai-link": ctx => ctx.artist ? `https://search.brave.com/ask?q=${encodeURIComponent(ctx.album ? `give me a comprehensive overview of the album ${ctx.album} by ${ctx.artist}` : `give me a comprehensive overview of the band ${ctx.artist}`)}` : "#",
"mistral-link": ctx => ctx.artist ? `https://chat.mistral.ai/chat?q=${encodeURIComponent(ctx.album ? `give me a comprehensive overview of the album ${ctx.album} by ${ctx.artist}` : `give me a comprehensive overview of the band ${ctx.artist}`)}` : "#",
"huggingchat-link": ctx => ctx.artist ? `https://huggingface.co/chat/?q=${encodeURIComponent(ctx.album ? `give me a comprehensive overview of the album ${ctx.album} by ${ctx.artist}` : `give me a comprehensive overview of the band ${ctx.artist}`)}` : "#",
"you-link": ctx => ctx.artist ? `https://you.com/search?q=${encodeURIComponent(ctx.album ? `give me a comprehensive overview of the album ${ctx.album} by ${ctx.artist}` : `give me a comprehensive overview of the band ${ctx.artist}`)}` : "#",
"grok-link": ctx => ctx.artist ? `https://grok.com?q=${encodeURIComponent(ctx.album ? `give me a comprehensive overview of the album ${ctx.album} by ${ctx.artist}` : `give me a comprehensive overview of the band ${ctx.artist}`)}` : "#",
"universal-scrobbler-link": ctx => `https://universalscrobbler.com/`,
"time-capsule-link": ctx => { const u = localStorage.getItem("setting-lfm-username") || "username"; return `https://bxh9261.github.io/last-fm-time-capsule/?username=${u}`; },
"manual-scrobbler-link": ctx => `https://www.bijou.fm/manual-scrobbler`,
"musicmap-link": ctx => `https://musicmap.info/`,
/* google action shortcuts */
"google-image-action": ctx => ctx.artist ? `https://www.google.com/search?q=${ctx.query}&udm=2` : "#",
"google-video-action": ctx => ctx.artist ? `https://www.google.com/search?q=${ctx.query}&udm=7` : "#",
"google-ai-action": ctx => ctx.artist ? `https://www.google.com/search?udm=50&source=searchlabs&q=${ctx.query}` : "#",
/* Album of the Year sub-action endpoints */
"aoty-tags-action": ctx => ctx.artist ? `https://www.albumoftheyear.org/search/tags/?q=${ctx.query}` : "#",
"aoty-lists-action": ctx => ctx.artist ? `https://www.albumoftheyear.org/search/lists/?q=${ctx.query}` : "#",
"aoty-global-action": ctx => ctx.artist ? `https://www.albumoftheyear.org/search/?q=${ctx.query}` : "#",
};
function cleanName(value) {
return decodeURIComponent(value || "").replace(/\+/g, " ").trim();
}
function buildContext(ctxOrArtist, album) {
let ctx;
if (typeof ctxOrArtist === "object" && ctxOrArtist !== null) {
ctx = ctxOrArtist;
} else {
ctx = { type: album ? "album" : "artist", artist: ctxOrArtist || "", album: album || "", track: "" };
}
const encodedArtist = encodeURIComponent(ctx.artist || "");
const encodedAlbum = encodeURIComponent(ctx.album || "");
const encodedTrack = encodeURIComponent(ctx.track || "");
const parts = [];
if (ctx.track) parts.push(encodedTrack);
if (ctx.album) parts.push(encodedAlbum);
if (ctx.artist) parts.push(encodedArtist);
const query = parts.join(" ") || encodedArtist;
const type = ctx.track ? "track" : ctx.album ? "album" : "band";
let displayText = ctx.artist || "";
if (ctx.track) displayText = `${ctx.track} by ${displayText}`;
else if (ctx.album) displayText = `${ctx.album} by ${displayText}`;
return { ...ctx, encodedArtist, encodedAlbum, encodedTrack, query, type, displayText };
}
function updateMenuLinks(ctx) {
ctx = buildContext(ctx);
const badgeEl = document.getElementById("lfm-header-badge");
const inlineBadgeEl = document.getElementById("lfm-context-inline-badge");
const titleEl = document.getElementById("lfm-header-title");
const subtitleEl = document.getElementById("lfm-header-subtitle");
if (badgeEl) {
const typeKey = ctx.type === "track" ? "track" : ctx.type === "album" ? "album" : "artist";
const typeLabel = typeKey === "track" ? "Track" : typeKey === "album" ? "Album" : "Artist";
badgeEl.textContent = typeLabel;
badgeEl.className = "context-badge context-badge-" + typeKey;
// Also update inline badge in quick-actions
if (inlineBadgeEl) {
inlineBadgeEl.textContent = typeLabel;
inlineBadgeEl.className = "context-badge context-badge-" + typeKey;
inlineBadgeEl.id = "lfm-context-inline-badge";
}
}
if (titleEl) {
titleEl.textContent = ctx.track || ctx.album || ctx.artist || "";
}
if (subtitleEl) {
if (ctx.track && ctx.artist) subtitleEl.textContent = `by ${ctx.artist}`;
else if (ctx.album && ctx.artist) subtitleEl.textContent = `by ${ctx.artist}`;
else subtitleEl.textContent = "";
}
document.querySelectorAll("#external-music-menu a").forEach(link => {
const isUtilityLink =
link.id === "open-settings-btn" ||
link.id === "open-ai-btn" ||
link.id === "footer-toggle-lights" ||
link.id === "footer-collapse-all";
const isServiceLink =
link.id &&
SERVICE_URLS[link.id];
if (isUtilityLink) {
link.classList.remove("disabled");
link.href = "#";
return;
}
if (!ctx.artist && isServiceLink) {
link.classList.add("disabled");
link.href = "#";
} else if (isServiceLink) {
link.classList.remove("disabled");
link.href = SERVICE_URLS[link.id](ctx);
}
});
applyCustomServiceUrls(ctx);
}
function showPopupForArtist(artistName) {
currentContext = { type: "artist", artist: cleanName(artistName), album: "", track: "" };
updateMenuLinks(currentContext);
document.getElementById("external-music-menu")?.classList.add("visible");
}
function showPopupForAlbum(artistName, albumName) {
currentContext = { type: "album", artist: cleanName(artistName), album: cleanName(albumName), track: "" };
updateMenuLinks(currentContext);
document.getElementById("external-music-menu")?.classList.add("visible");
}
function detectPageContext() {
const path = location.pathname;
const trackMatch = path.match(/\/music\/([^/]+)\/_\/([^/#]+)$/i);
if (trackMatch) return { type: "track", artist: cleanName(trackMatch[1]), album: "", track: cleanName(trackMatch[2]) };
const albumMatch = path.match(/\/music\/([^/]+)\/([^/#]+)$/i);
if (albumMatch) return { type: "album", artist: cleanName(albumMatch[1]), album: cleanName(albumMatch[2]), track: "" };
const artistMatch = path.match(/\/music\/([^/#]+)$/i);
if (artistMatch) return { type: "artist", artist: cleanName(artistMatch[1]), album: "", track: "" };
const libraryMatch = path.match(/\/library\/music\/([^/]+)(?:\/([^/?#]+))?/i);
if (libraryMatch) {
return {
type: libraryMatch[2] ? "album" : "artist",
artist: cleanName(libraryMatch[1]),
album: libraryMatch[2] ? cleanName(libraryMatch[2]) : "",
track: "",
};
}
const fallback = detectPageContextFallback();
if (fallback) return fallback;
return null;
}
function detectPageContextFallback() {
const breadcrumbs = document.querySelectorAll(".breadcrumb a");
const crumbs = Array.from(breadcrumbs).map(a => cleanName(a.textContent)).filter(Boolean);
const musicIdx = crumbs.findIndex(c => c.toLowerCase() === "music");
if (musicIdx >= 0) {
const afterMusic = crumbs.slice(musicIdx + 1);
if (afterMusic.length >= 2) return { type: "album", artist: afterMusic[0], album: afterMusic[1], track: "" };
if (afterMusic.length === 1) return { type: "artist", artist: afterMusic[0], album: "", track: "" };
}
const ogTitle = document.querySelector('meta[property="og:title"]');
if (ogTitle) {
const text = ogTitle.getAttribute("content") || "";
const sep = text.lastIndexOf(" - ");
if (sep > 0) {
const possibleAlbum = text.substring(0, sep).trim();
const possibleArtist = text.substring(sep + 3).trim();
if (possibleArtist && possibleAlbum) return { type: "album", artist: possibleArtist, album: possibleAlbum, track: "" };
}
}
const titleEl = document.querySelector("title");
if (titleEl) {
const text = titleEl.textContent || "";
const sep = text.lastIndexOf(" - ");
if (sep > 0) {
const afterSep = text.substring(sep + 3).trim();
if (afterSep.toLowerCase().includes("last.fm")) {
const possibleAlbum = text.substring(0, sep).trim();
return { type: "artist", artist: possibleAlbum, album: "", track: "" };
}
}
}
return null;
}
function showPopupWithContext() {
const detected = detectPageContext();
if (detected) {
currentContext = { ...currentContext, ...detected };
}
updateMenuLinks(currentContext);
document.getElementById("external-music-menu")?.classList.add("visible");
}
function openAllInCategory(categoryId) {
const ctx = buildContext(currentContext);
if (!ctx.artist) return;
const urls = (SERVICE_CATEGORIES[categoryId] || [])
.map(id => SERVICE_URLS[id]?.(ctx))
.filter(url => url && url !== "#");
urls.forEach((url, i) => {
setTimeout(() => window.open(url, "_blank", "noopener,noreferrer"), i * 400);
});
}
function getCustomServices() {
try {
return JSON.parse(localStorage.getItem("lfm-custom-services") || "[]");
} catch {
return [];
}
}
function saveCustomServices(services) {
localStorage.setItem("lfm-custom-services", JSON.stringify(services));
}
function escHtml(s) {
const d = document.createElement("div");
d.appendChild(document.createTextNode(s));
return d.innerHTML;
}
function showToast(message, duration = 3000) {
const existing = document.getElementById("lfm-toast");
if (existing) existing.remove();
const toast = document.createElement("div");
toast.id = "lfm-toast";
toast.textContent = message;
Object.assign(toast.style, {
position: "fixed", bottom: "80px", left: "50%", transform: "translateX(-50%)",
background: "var(--bg-primary)", color: "var(--text-primary)",
padding: "10px 20px", borderRadius: "8px", fontSize: "13px",
zIndex: "999999999", boxShadow: "0 4px 12px var(--shadow)",
border: "1px solid var(--border-color)",
transition: "opacity .3s", opacity: "0",
});
document.body.appendChild(toast);
requestAnimationFrame(() => { toast.style.opacity = "1"; });
setTimeout(() => {
toast.style.opacity = "0";
setTimeout(() => toast.remove(), 300);
}, duration);
}
function openExternalToolWindow(url, name, width = 1000, height = 800) {
const left = (screen.width - width) / 2;
const top = (screen.height - height) / 2;
const popup = window.open(url, name,
`width=${width},height=${height},left=${left},top=${top},resizable=yes,scrollbars=yes`
);
if (!popup || popup.closed) {
window.open(url, "_blank", "noopener,noreferrer");
}
}
function openAIUrl(url) {
if (localStorage.getItem("setting-ai-popup") !== "false") {
openExternalToolWindow(url, "LastFmToolboxAI");
} else {
window.open(url, "_blank", "noopener,noreferrer");
}
}
function openToolPopup(url, title) {
const width = 1200;
const height = 900;
const left = Math.round((screen.width - width) / 2);
const top = Math.round((screen.height - height) / 2);
const popup = window.open(url, title,
`width=${width},height=${height},left=${left},top=${top},resizable=yes,scrollbars=yes`
);
if (!popup) {
window.open(url, "_blank", "noopener,noreferrer");
}
return popup;
}
function getDefaultOpenMode() {
return localStorage.getItem("musicengine.defaultOpenMode") || "tab";
}
function updateServiceLaunchButtonsVisibility() {
const isPopupMode = getDefaultOpenMode() !== "tab";
document.querySelectorAll(".service-launch-btn").forEach(btn => {
btn.classList.toggle("hidden", !isPopupMode);
});
}
const TOGGLE_POSITIONS = ["bottom-left", "bottom-right", "top-left", "top-right"];
const TOGGLE_POSITION_KEY = "setting-toggle-position";
function getTogglePosition() {
const stored = localStorage.getItem(TOGGLE_POSITION_KEY);
return TOGGLE_POSITIONS.includes(stored) ? stored : "bottom-left";
}
function applyTogglePosition() {
const pos = getTogglePosition();
TOGGLE_POSITIONS.forEach(p => document.documentElement.classList.remove("lfm-toggle-" + p));
document.documentElement.classList.add("lfm-toggle-" + pos);
}
function cycleTogglePosition() {
const current = getTogglePosition();
const nextIndex = (TOGGLE_POSITIONS.indexOf(current) + 1) % TOGGLE_POSITIONS.length;
const nextPos = TOGGLE_POSITIONS[nextIndex];
localStorage.setItem(TOGGLE_POSITION_KEY, nextPos);
applyTogglePosition();
const posSelect = document.getElementById("modal-toggle-position");
if (posSelect) {
posSelect.value = nextPos;
}
}
if (typeof GM_registerMenuCommand !== "undefined") {
GM_registerMenuCommand("Toggle", () => {
const menu = document.getElementById("external-music-menu");
if (menu) {
if (menu.classList.contains("visible")) {
menu.classList.remove("visible");
} else {
showPopupWithContext();
}
}
});
GM_registerMenuCommand("Settings", () => {
const existing = document.getElementById("lfm-modal-overlay");
if (!existing) {
openSettingsModal();
}
});
GM_registerMenuCommand("Cycle Popup", cycleTogglePosition);
}
function renderCustomServices() {
const container = document.getElementById("custom-services-list");
if (!container) return;
container.innerHTML = "";
getCustomServices().forEach((svc, i) => {
const div = document.createElement("div");
div.className = "lfm-custom-item";
div.innerHTML = `<span>${escHtml(svc.label)}</span><button data-index="${i}">✕</button>`;
container.appendChild(div);
});
container.querySelectorAll("button[data-index]").forEach(btn => {
btn.addEventListener("click", function () {
const list = getCustomServices();
list.splice(parseInt(this.dataset.index, 10), 1);
saveCustomServices(list);
renderCustomServices();
populateMenuCustomLinks();
});
});
}
function populateMenuCustomLinks() {
const container = document.getElementById("custom-services-container");
if (!container) return;
container.querySelectorAll(".lfm-custom-link").forEach(el => el.remove());
getCustomServices().forEach(svc => {
const a = document.createElement("a");
a.className = "lfm-custom-link";
a.href = "#";
a.target = "_blank";
a.dataset.template = svc.urlTemplate;
a.innerHTML = `<i class="fa-solid fa-link"></i>${escHtml(svc.label)}`;
container.appendChild(a);
});
}
function applyCustomServiceUrls(ctx) {
document.querySelectorAll(".lfm-custom-link").forEach(link => {
if (!ctx.artist) {
link.classList.add("disabled");
link.href = "#";
return;
}
link.classList.remove("disabled");
link.href = link.dataset.template
.replace(/\{artist\}/g, ctx.encodedArtist)
.replace(/\{album\}/g, ctx.encodedAlbum || "")
.replace(/\{track\}/g, ctx.encodedTrack || "")
.replace(/\{query\}/g, ctx.query)
.replace(/\{type\}/g, ctx.type);
});
}
const CONTENT_SELECTOR = [
'a:not(.auth-dropdown-menu-item):not([aria-hidden="true"])[href^="/music/"]',
'a:not(.auth-dropdown-menu-item):not([aria-hidden="true"])[href*="last.fm/music/"]'
].join(",");
function shouldSkipLink(link) {
if (!link || !link.href) return true;
if (link.classList.contains("lfm-toolbox-enabled")) return true;
if (link.closest("#external-music-menu")) return true;
if (link.closest("#external-music-button")) return true;
if (link.closest(".dropdown-menu-clickable, .disclose-hide, .chartlist-more-menu")) return true;
if (link.closest(".cover-art, .chartlist-image, .chartlist-play")) return true;
const path = new URL(link.href, location.origin).pathname.replace(/\/+$/, "");
if (!path.startsWith("/music/")) return true;
if (path.includes("/+")) return true;
return false;
}
function addContextMenu(root) {
if (!root?.querySelectorAll) return;
root.querySelectorAll(CONTENT_SELECTOR).forEach(link => {
if (link.dataset.lfmCtxProcessed) return;
if (shouldSkipLink(link)) {
link.dataset.lfmCtxProcessed = "true";
return;
}
const path = new URL(link.href, location.origin).pathname.replace(/\/+$/, "");
const trackMatch = path.match(/^\/music\/([^/]+)\/_\/([^/#]+)$/i);
const albumMatch = path.match(/^\/music\/([^/#]+)\/([^/#]+)$/i);
const artistMatch = path.match(/^\/music\/([^/#]+)$/i);
if (!trackMatch && !albumMatch && !artistMatch) {
link.dataset.lfmCtxProcessed = "true";
return;
}
const card = link.closest(".grid-items-item, .resource-list--release-list-item, .cover-art, .chartlist-image");
// On image cards, prefer album links over artist links.
if (card && artistMatch && card.querySelector('a[href*="/music/"][href*="/"]')) {
const albumLink = Array.from(card.querySelectorAll('a[href*="/music/"]'))
.find(a => {
const p = new URL(a.href, location.origin).pathname.replace(/\/+$/, "");
return /^\/music\/([^/#]+)\/([^/#]+)$/i.test(p);
});
if (albumLink && albumLink !== link) {
link.dataset.lfmCtxProcessed = "true";
return;
}
}
link.dataset.lfmCtxProcessed = "true";
let contextData;
if (trackMatch) {
contextData = { type: "track", artist: cleanName(trackMatch[1]), album: "", track: cleanName(trackMatch[2]) };
} else if (albumMatch) {
contextData = { type: "album", artist: cleanName(albumMatch[1]), album: cleanName(albumMatch[2]), track: "" };
} else {
contextData = { type: "artist", artist: cleanName(artistMatch[1]), album: "", track: "" };
}
const openToolbox = function (e) {
e.preventDefault();
e.stopPropagation();
currentContext = { ...contextData };
updateMenuLinks(currentContext);
document.getElementById("external-music-menu")?.classList.add("visible");
};
link.addEventListener("contextmenu", openToolbox);
if (card) {
if (card.querySelector(".lfm-grid-toolbox-icon")) return;
if (getComputedStyle(card).position === "static") {
card.style.position = "relative";
}
const icon = document.createElement("span");
icon.className = "lfm-grid-toolbox-icon";
icon.innerHTML = '<i class="fa-brands fa-lastfm"></i>';
icon.title = contextData.track
? `Open Toolbox for ${contextData.track} by ${contextData.artist}`
: contextData.album
? `Open Toolbox for ${contextData.album} by ${contextData.artist}`
: `Open Toolbox for ${contextData.artist}`;
icon.addEventListener("click", openToolbox);
icon.addEventListener("contextmenu", openToolbox);
card.appendChild(icon);
return;
}
link.classList.add("lfm-toolbox-enabled");
if (link.parentElement?.classList?.contains("lfm-toolbox-wrapper")) return;
const wrapper = document.createElement("span");
wrapper.className = "lfm-toolbox-wrapper";
link.parentNode.insertBefore(wrapper, link);
wrapper.appendChild(link);
const icon = document.createElement("span");
icon.className = "lfm-hover-icon";
icon.innerHTML = '<i class="fa-brands fa-lastfm"></i>';
icon.title = contextData.track
? `Open Toolbox for ${contextData.track} by ${contextData.artist}`
: contextData.album
? `Open Toolbox for ${contextData.album} by ${contextData.artist}`
: `Open Toolbox for ${contextData.artist}`;
icon.addEventListener("click", openToolbox);
icon.addEventListener("contextmenu", openToolbox);
wrapper.appendChild(icon);
});
}
function addChartlistContextEntries(root) {
const menus = root.querySelectorAll ? root.querySelectorAll(".chartlist-more-menu ul") : [];
menus.forEach(menu => {
if (menu.dataset.lfmProcessed) return;
menu.dataset.lfmProcessed = "true";
const row = menu.closest("tr");
if (!row) return;
const artistLink = row.querySelector(".chartlist-artist a");
const albumLink = row.querySelector(".chartlist-album a");
if (!artistLink) return;
const artist = artistLink.textContent.trim();
const album = albumLink ? albumLink.textContent.trim() : "";
const sep = document.createElement("li");
sep.style.cssText = "border-top:1px solid #444;margin:4px 8px;list-style:none;";
const item = document.createElement("li");
item.style.cssText = "list-style:none;";
const a = document.createElement("a");
a.href = "#";
a.innerHTML = `<i class="fa-brands fa-lastfm"></i> ${artist}${album ? " — " + album : ""}`;
a.style.cssText = "display:flex;align-items:center;gap:6px;padding:8px 12px;color:white;text-decoration:none;font-size:12px;cursor:pointer;";
a.addEventListener("click", e => {
e.preventDefault();
album ? showPopupForAlbum(artist, album) : showPopupForArtist(artist);
});
item.appendChild(a);
menu.insertBefore(sep, menu.firstChild);
menu.insertBefore(item, menu.firstChild);
});
}
function buildCategoryHTML() {
let html = "";
CATEGORY_ORDER.forEach(cat => {
html += `<div class="section-block" data-section="${cat}">`;
html += `<p class="menu-section" data-section="${cat}" aria-expanded="false">
<span>${CATEGORY_LABELS[cat]}</span>
<span class="section-chevron">▶</span>
</p>`;
html += `<div class="section-content" data-section="${cat}">`;
SERVICE_CATEGORIES[cat].forEach(id => {
html += `<div class="service-row">`;
html += `<a href="#" id="${id}" class="service-link" target="_blank">${getServiceIcon(id)}${SERVICE_LABELS[id]}</a>`;
if (id === "theaudiodb-link") {
html += `<button class="service-json-btn" data-service-id="${id}" title="Download JSON"><i class="fa-solid fa-code"></i></button>`;
html += `<button class="service-md-btn" data-service-id="${id}" title="Download Markdown"><i class="fa-brands fa-markdown"></i></button>`;
}
// add Google quick action icons next to the main Google entry
if (id === "google-band-link") {
html += `<span class="service-action">`;
html += `<button class="service-action-btn" data-action="google-image-action" title="Google Images">${getServiceIcon('google-image-action')}</button>`;
html += `<button class="service-action-btn" data-action="google-video-action" title="Google Video">${getServiceIcon('google-video-action')}</button>`;
html += `<button class="service-action-btn" data-action="google-ai-action" title="Google AI Mode">${getServiceIcon('google-ai-action')}</button>`;
html += `</span>`;
}
// Add sub-action layout elements next to Album of the Year
if (id === "album-of-the-year-link") {
html += `<span class="service-action">`;
html += `<button class="service-action-btn" data-action="aoty-tags-action" title="AOTY Tags">${getServiceIcon('aoty-tags-action')}</button>`;
html += `<button class="service-action-btn" data-action="aoty-lists-action" title="AOTY Lists">${getServiceIcon('aoty-lists-action')}</button>`;
html += `<button class="service-action-btn" data-action="aoty-global-action" title="AOTY Global Search">${getServiceIcon('aoty-global-action')}</button>`;
html += `</span>`;
}
if (cat === "ai") {
html += `<button class="service-ai-btn" data-service-id="${id}" title="Open AI prompt"><i class="fa-solid fa-flask"></i></button>`;
}
html += `<button class="service-launch-btn" data-service-id="${id}" title="Open in new tab"><i class="fa-solid fa-plus"></i></button>`;
html += `</div>`;
});
const isOpenAll = localStorage.getItem("setting-openall-" + cat) === "true";
const displayStyle = isOpenAll ? "" : 'style="display: none;"';
html += `<a href="#" class="open-all-link" data-category="${cat}" ${displayStyle}><i class="fa-solid fa-forward"></i>Open All</a>`;
html += `</div><hr></div>`;
});
return html;
}
function setupUI() {
const button = document.createElement("div");
button.id = "external-music-button";
button.innerHTML = '<i class="fa-brands fa-lastfm"></i>';
button.title = "Last.fm Toolbox (Ctrl+Shift+E) | Right-click to move";
button.addEventListener("contextmenu", e => {
e.preventDefault();
cycleTogglePosition();
});
button.addEventListener("click", e => {
e.stopPropagation();
const menu = document.getElementById("external-music-menu");
if (menu.classList.contains("visible")) {
menu.classList.remove("visible");
} else {
showPopupWithContext();
}
});
document.body.appendChild(button);
const menu = document.createElement("div");
menu.id = "external-music-menu";
menu.innerHTML = `
<div id="lfm-scroll-area">
<div id="lfm-ai-toggle-bar">
<a href="#" id="open-ai-btn" title="Open AI Prompt Generator">
<i class="fa-solid fa-flask ai-btn-icon"></i>Query AI
</a>
</div>
<div id="lfm-menu-header">
<div id="lfm-header-badge"></div>
<div id="lfm-header-title"></div>
<div id="lfm-header-subtitle"></div>
</div>
<div id="lfm-quick-actions">
<a href="#" id="search-link" target="_blank" title="Search"><i class="fa-solid fa-search"></i><span>Search</span></a>
<div id="lfm-context-inline-badge"></div>
<a href="#" id="listen-link" target="_blank" title="Listen"><i class="fa-solid fa-headphones"></i><span>Listen</span></a>
</div>
${buildCategoryHTML()}
<div class="section-block" data-section="custom">
<p class="menu-section" data-section="custom" aria-expanded="false">
<span>Manual Search</span>
<span class="section-chevron">▶</span>
</p>
<div class="section-content" data-section="custom">
<div id="search-input-container">
<div id="search-input-wrapper">
<input type="text" id="search-input" placeholder="Artist or Artist - Album">
<div id="search-help-toggle">?</div>
<div id="search-help-popup">Search by artist, album, or track.<br><strong>Supported Formats:</strong><br>
• Artist<br>
• Artist - Album<br>
• Artist - Album - Track
</div>
</div>
</div>
</div></div>
<div id="lfm-footer">
<a href="#" id="open-settings-btn" title="Settings"><i class="fa-solid fa-gear"></i></a>
<a href="#" id="footer-toggle-lights" title="Toggle dark/light mode"><i class="fa-solid fa-moon"></i></a>
<a href="#" id="footer-collapse-all" title="Collapse all categories"><i class="fa-solid fa-arrow-up-short-wide"></i></a>
</div>
</div>
`;
document.body.appendChild(menu);
// Ensure New Tab vs Popup visibility applies immediately on load
updateServiceLaunchButtonsVisibility();
document.addEventListener("click", e => {
if (
!menu.contains(e.target) &&
!button.contains(e.target) &&
!e.target.closest(".lfm-hover-icon") &&
!e.target.closest(".lfm-grid-toolbox-icon")
) {
menu.classList.remove("visible");
}
});
menu.addEventListener("click", e => {
const openAll = e.target.closest(".open-all-link");
if (openAll) {
e.preventDefault();
openAllInCategory(openAll.dataset.category);
return;
}
if (e.target.closest("a") && !e.target.closest(".open-all-link") && !e.target.closest(".service-row")) {
menu.classList.remove("visible");
}
});
function handleMenuClose() {
const behavior = localStorage.getItem("setting-close-behavior") || "close";
if (behavior === "keep") return false;
if (behavior === "timeout") {
const delaySec = parseInt(localStorage.getItem("setting-close-delay") || "3", 10);
setTimeout(() => {
document.getElementById("external-music-menu")?.classList.remove("visible");
}, delaySec * 1000);
return false;
}
return true;
}
menu.querySelectorAll(".service-link").forEach(link => {
link.addEventListener("click", function (e) {
e.preventDefault();
e.stopPropagation();
const url = this.href;
if (!url || url === "#" || url === location.href + "#") return;
if (this.id === "theaudiodb-link") {
menu.classList.remove("visible");
fetch(url).then(r => r.json()).then(data => {
const id = data.artists?.[0]?.idArtist;
if (id) window.open(`https://www.theaudiodb.com/artist/${id}`, "_blank", "noopener,noreferrer");
else window.open(url, "_blank", "noopener,noreferrer");
}).catch(() => {
window.open(url, "_blank", "noopener,noreferrer");
});
return;
}
if (handleMenuClose()) menu.classList.remove("visible");
if (getDefaultOpenMode() === "tab") {
window.open(url, "_blank", "noopener,noreferrer");
} else {
openToolPopup(url, "MusicEngineTool");
}
});
});
menu.querySelectorAll(".service-launch-btn").forEach(btn => {
btn.addEventListener("click", function (e) {
e.stopPropagation();
const link = document.getElementById(this.dataset.serviceId);
if (!link || !link.href || link.href === "#" || link.href === location.href + "#") return;
if (handleMenuClose()) menu.classList.remove("visible");
window.open(link.href, "_blank", "noopener,noreferrer");
});
});
menu.querySelectorAll(".service-ai-btn").forEach(btn => {
btn.addEventListener("click", function (e) {
e.stopPropagation();
const providerId = this.dataset.serviceId;
if (providerId) {
localStorage.setItem("lfm-last-ai-provider", providerId);
}
menu.classList.remove("visible");
openAIModal();
});
});
// inline action buttons (e.g., Google image/video/AI shortcuts)
menu.querySelectorAll(".service-action-btn").forEach(btn => {
btn.addEventListener("click", function (e) {
e.stopPropagation();
const actionId = this.dataset.action;
if (!actionId || !SERVICE_URLS[actionId]) return;
const url = SERVICE_URLS[actionId](buildContext(currentContext));
if (!url || url === "#") return;
if (handleMenuClose()) menu.classList.remove("visible");
if (getDefaultOpenMode() === "tab") {
window.open(url, "_blank", "noopener,noreferrer");
} else {
openToolPopup(url, "MusicEngineTool");
}
});
});
function formatArtistMarkdown(data) {
const artist = data.artists?.[0];
if (!artist) return "# No artist data found";
const name = artist.strArtist || "Unknown";
const banner = artist.strArtistBanner ? `\n\n` : "";
const logo = artist.strArtistLogo ? `\n\n` : "";
const genre = artist.strGenre || "Unknown";
const country = artist.strCountry || "Unknown";
const formed = artist.intFormedYear || "Unknown";
const members = artist.intMembers || "Unknown";
const label = artist.strLabel || "Unknown";
const bio = artist.strBiography || "No biography available.";
const thumb = artist.strArtistThumb ? `\n\n` : "";
const wide = artist.strArtistWideThumb ? `\n\n` : "";
const fanarts = [artist.strArtistFanart, artist.strArtistFanart2, artist.strArtistFanart3, artist.strArtistFanart4].filter(Boolean);
const clearart = artist.strArtistClearart ? `\n\n` : "";
return `# ${name}
${banner}
**${genre} • ${country}**
## Band Information
| Attribute | Details |
|-----------|---------|
| **Formed** | ${formed} |
| **Members** | ${members} |
| **Origin** | ${country} |
| **Label** | ${label} |
${logo}
## Biography
${bio}
## Gallery
${thumb}${wide}${fanarts.length ? "\n**Fan Art:**\n" + fanarts.map(f => `\n\n`).join("") : ""}${clearart}
---
*Data from TheAudioDB • Last updated via API*`;
}
function downloadFromAudioDB(url, format) {
fetch(url).then(r => r.json()).then(data => {
const artist = data.artists?.[0];
if (!artist) {
showToast("No TheAudioDB entry found for this artist.");
return;
}
const name = artist.strArtist?.replace(/[^a-z0-9]/gi, "_") || "theaudiodb";
let content, mime, ext;
if (format === "json") {
content = JSON.stringify(data, null, 2);
mime = "application/json";
ext = "json";
} else {
content = formatArtistMarkdown(data);
mime = "text/markdown";
ext = "md";
}
const blob = new Blob([content], { type: mime });
const a = document.createElement("a");
a.href = URL.createObjectURL(blob);
a.download = `${name}.${ext}`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
setTimeout(() => URL.revokeObjectURL(a.href), 1000);
}).catch(err => showToast("Download failed: " + err.message));
}
menu.querySelectorAll(".service-json-btn, .service-md-btn").forEach(btn => {
btn.addEventListener("click", function (e) {
e.stopPropagation();
const serviceId = this.dataset.serviceId;
const link = document.getElementById(serviceId);
if (!link || !link.href || link.href === "#") return;
const format = this.classList.contains("service-json-btn") ? "json" : "md";
menu.classList.remove("visible");
downloadFromAudioDB(link.href, format);
});
});
function setSectionArrow(section, expanded) {
const arrow = section.querySelector(".section-chevron");
if (!arrow) return;
arrow.textContent = expanded ? "▼" : "▶";
}
document.querySelectorAll(".menu-section").forEach(section => {
const name = section.dataset.section;
const block = document.querySelector(`.section-block[data-section="${name}"]`);
const content = block ? block.querySelector(`.section-content`) : document.querySelector(`.section-content[data-section="${name}"]`);
const expanded = localStorage.getItem("menu-section-" + name) === "expanded";
section.classList.toggle("collapsed", !expanded);
content?.classList.toggle("visible", expanded);
setSectionArrow(section, expanded);
section.setAttribute("aria-expanded", expanded ? "true" : "false");
section.addEventListener("click", function () {
const nowVisible = !content?.classList.contains("visible");
this.classList.toggle("collapsed", !nowVisible);
content?.classList.toggle("visible", nowVisible);
setSectionArrow(this, nowVisible);
this.setAttribute("aria-expanded", nowVisible ? "true" : "false");
localStorage.setItem(
"menu-section-" + name,
nowVisible ? "expanded" : "collapsed"
);
});
});
document.getElementById("open-settings-btn")?.addEventListener("click", e => {
e.preventDefault();
menu.classList.remove("visible");
openSettingsModal();
});
document.getElementById("open-ai-btn")?.addEventListener("click", e => {
e.preventDefault();
menu.classList.remove("visible");
openAIModal();
});
const lightsBtn = document.getElementById("footer-toggle-lights");
if (lightsBtn) {
const isLight = document.documentElement.classList.contains("lfm-light-mode");
const icon = lightsBtn.querySelector("i");
if (icon) icon.className = isLight ? "fa-solid fa-sun" : "fa-solid fa-moon";
}
document.getElementById("footer-toggle-lights")?.addEventListener("click", e => {
e.preventDefault();
e.stopPropagation();
const isLight = document.documentElement.classList.toggle("lfm-light-mode");
localStorage.setItem("setting-light-mode", isLight ? "true" : "false");
const icon = e.currentTarget.querySelector("i");
if (icon) icon.className = isLight ? "fa-solid fa-sun" : "fa-solid fa-moon";
});
document.getElementById("footer-collapse-all")?.addEventListener("click", e => {
e.preventDefault();
e.stopPropagation();
document.querySelectorAll(".menu-section").forEach(section => {
const name = section.dataset.section;
const block = document.querySelector(`.section-block[data-section="${name}"]`);
const content = block ? block.querySelector(`.section-content`) : document.querySelector(`.section-content[data-section="${name}"]`);
if (content?.classList.contains("visible")) {
section.classList.add("collapsed");
content.classList.remove("visible");
const arrow = section.querySelector(".section-chevron");
if (arrow) arrow.textContent = "▶";
section.setAttribute("aria-expanded", "false");
localStorage.setItem("menu-section-" + name, "collapsed");
}
});
const collapseIcon = e.currentTarget.querySelector("i");
if (collapseIcon) {
collapseIcon.className = "fa-solid fa-arrow-up-wide-short";
setTimeout(() => { collapseIcon.className = "fa-solid fa-arrow-up-short-wide"; }, 600);
}
});
const searchInput = document.getElementById("search-input");
searchInput.addEventListener("keypress", e => {
if (e.key !== "Enter") return;
const query = searchInput.value.trim();
if (!query) return;
const albumTrackMatch = query.match(/^(.+)\s+-\s+(.+)\s+-\s+(.+)$/);
const albumMatch = query.match(/^(.+)\s+-\s+(.+)$/);
if (albumTrackMatch) {
currentContext = { type: "track", artist: albumTrackMatch[1].trim(), album: "", track: albumTrackMatch[3].trim() };
} else if (albumMatch) {
currentContext = { type: "album", artist: albumMatch[1].trim(), album: albumMatch[2].trim(), track: "" };
} else {
currentContext = { type: "artist", artist: query, album: "", track: "" };
}
updateMenuLinks(currentContext);
searchInput.value = "";
});
setupToggles();
}
function setupToggles() {
// 1. Text Highlight Layer Toggle Switch Handler
const highlightToggle = document.getElementById("modal-toggle-highlight");
if (highlightToggle) {
const isEnabled = localStorage.getItem("setting-highlight-eligible") === "true";
highlightToggle.classList.toggle("active", isEnabled);
highlightToggle.onclick = function () {
const active = this.classList.toggle("active");
localStorage.setItem("setting-highlight-eligible", active ? "true" : "false");
document.documentElement.classList.toggle("lfm-highlight-eligible", active);
};
}
// 3. Category Sections
CATEGORY_ORDER.forEach(cat => {
const toggle = document.getElementById("modal-toggle-section-" + cat);
const block = document.querySelector(`.section-block[data-section="${cat}"]`);
const subGroup = document.getElementById(`subgroup-cat-${cat}`);
// Bind Individual 'Open All' Toggles
const openAllToggle = document.getElementById("modal-toggle-openall-" + cat);
if (openAllToggle) {
openAllToggle.onclick = function (e) {
e.stopPropagation();
const active = this.classList.toggle("active");
localStorage.setItem("setting-openall-" + cat, active ? "true" : "false");
// Instantly toggle visibility in the DOM menu
const menuLink = document.querySelector(`.open-all-link[data-category="${cat}"]`);
if (menuLink) menuLink.style.display = active ? "block" : "none";
};
}
if (!toggle) return;
const isCatHidden = localStorage.getItem("setting-section-" + cat) === "false";
toggle.classList.toggle("active", !isCatHidden);
block?.classList.toggle("hidden-section", isCatHidden);
toggle.onclick = function () {
const active = this.classList.toggle("active");
localStorage.setItem("setting-section-" + cat, active ? "true" : "false");
block?.classList.toggle("hidden-section", !active);
if (subGroup) subGroup.style.display = active ? "grid" : "none";
};
// Loop bind handlers for individual services inside this category block
SERVICE_CATEGORIES[cat].forEach(id => {
const itemToggle = document.getElementById("modal-toggle-svc-" + id);
if (!itemToggle) return;
const isItemHidden = localStorage.getItem("setting-svc-hidden-" + id) === "true";
itemToggle.classList.toggle("active", !isItemHidden);
itemToggle.onclick = function () {
const active = this.classList.toggle("active");
localStorage.setItem("setting-svc-hidden-" + id, active ? "false" : "true");
const targets = document.querySelectorAll(`.service-row:has(#${id})`);
targets.forEach(t => t.classList.toggle("lfm-service-hidden", !active));
};
});
});
// 5. Light Mode
const lightToggle = document.getElementById("modal-toggle-light-mode") || document.getElementById("toggle-light-mode");
if (lightToggle) {
const isEnabled = localStorage.getItem("setting-light-mode") === "true";
lightToggle.classList.toggle("active", isEnabled);
lightToggle.onclick = function () {
const active = this.classList.toggle("active");
localStorage.setItem("setting-light-mode", active ? "true" : "false");
document.documentElement.classList.toggle("lfm-light-mode", active);
const footerIcon = document.querySelector("#footer-toggle-lights i");
if (footerIcon) footerIcon.className = active ? "fa-solid fa-sun" : "fa-solid fa-moon";
};
}
// 6. AI Popup Mode
const aiPopupToggle = document.getElementById("modal-toggle-ai-popup");
if (aiPopupToggle) {
const isPopup = localStorage.getItem("setting-ai-popup") !== "false";
aiPopupToggle.classList.toggle("active", isPopup);
aiPopupToggle.onclick = function () {
const active = this.classList.toggle("active");
localStorage.setItem("setting-ai-popup", active ? "true" : "false");
};
}
const defaultAISelect = document.getElementById("setting-select-default-ai-provider");
if (defaultAISelect) {
defaultAISelect.value = localStorage.getItem("setting-default-ai-provider") || "chatgpt-link";
defaultAISelect.addEventListener("change", function () {
localStorage.setItem("setting-default-ai-provider", this.value);
});
}
// Auto-Copy
const aiAutoCopyToggle = document.getElementById("modal-toggle-ai-autocopy");
if (aiAutoCopyToggle) {
const isAutoCopy = localStorage.getItem("setting-ai-autocopy") === "true";
aiAutoCopyToggle.classList.toggle("active", isAutoCopy);
aiAutoCopyToggle.onclick = function () {
const active = this.classList.toggle("active");
localStorage.setItem("setting-ai-autocopy", active ? "true" : "false");
};
}
// Default Category
const defaultAICatSelect = document.getElementById("setting-select-default-ai-category");
if (defaultAICatSelect) {
defaultAICatSelect.value = localStorage.getItem("setting-default-ai-category") || "overview";
defaultAICatSelect.addEventListener("change", function () {
localStorage.setItem("setting-default-ai-category", this.value);
});
}
const aiSuffixTextarea = document.getElementById("setting-ai-custom-suffix");
if (aiSuffixTextarea) {
aiSuffixTextarea.value = localStorage.getItem("setting-ai-custom-suffix") || "";
aiSuffixTextarea.addEventListener("input", function () {
localStorage.setItem("setting-ai-custom-suffix", this.value);
});
}
}
const AI_PROVIDERS = [
{ id: "chatgpt-link", label: "ChatGPT", url: prompt => `https://chatgpt.com/?prompt=${encodeURIComponent(prompt)}` },
{ id: "claude-link", label: "Claude", url: prompt => `https://claude.ai/new?q=${encodeURIComponent(prompt)}` },
{ id: "perplexity-link", label: "Perplexity", url: prompt => `https://www.perplexity.ai/search/new?q=${encodeURIComponent(prompt)}` },
{ id: "grok-link", label: "Grok", url: prompt => `https://grok.com?q=${encodeURIComponent(prompt)}` },
{ id: "brave-ai-link", label: "Brave AI", url: prompt => `https://search.brave.com/ask?q=${encodeURIComponent(prompt)}` },
{ id: "mistral-link", label: "Mistral", url: prompt => `https://chat.mistral.ai/chat?q=${encodeURIComponent(prompt)}` },
{ id: "huggingchat-link", label: "HuggingChat", url: prompt => `https://huggingface.co/chat/?q=${encodeURIComponent(prompt)}` },
{ id: "you-link", label: "You.com", url: prompt => `https://you.com/search?q=${encodeURIComponent(prompt)}` },
];
const AI_CATEGORIES = {
overview: {
label: "Overview",
items: [
{
label: "Summary", prompt: ctx => ctx.track
? `Role: Act as a senior music historian.\n\nTask: Provide a highly structured executive summary of the track "${ctx.track}" by ${ctx.artist}.\n\nConstraints: Avoid conversational pleasantries and subjective filler. Rely strictly on evidence-based facts.\n\nOutput Format: Return a Markdown document with the following sections:\n- **Core Details:** (Release year, label, primary/secondary sub-genres)\n- **Personnel:** (Producers, primary writers, mixing engineers)\n- **Sonic Profile:** (One sentence defining its technical audio characteristic)`
: ctx.album
? `Role: Act as a senior music historian.\n\nTask: Provide a highly structured executive summary of the album "${ctx.album}" by ${ctx.artist}.\n\nConstraints: Avoid conversational pleasantries. Rely strictly on verified release data.\n\nOutput Format: Return a Markdown document with the following sections:\n- **Core Details:** (Release date, label, primary sub-genres)\n- **Personnel:** (Key producers, guest features)\n- **Consensus:** (Critical and commercial reception summary)`
: `Role: Act as a senior music historian.\n\nTask: Provide a highly structured executive summary of the artist ${ctx.artist}.\n\nConstraints: Avoid conversational pleasantries. Focus on objective career data.\n\nOutput Format: Return a Markdown document with the following sections:\n- **Core Details:** (Active years, geographical origin, definitive sub-genres)\n- **Metrics:** (Estimated total sales/streams, peak chart success)\n- **Legacy:** (Primary contribution to the evolution of their genre)`
},
{
label: "Comprehensive Overview", prompt: ctx => ctx.track
? `Role: Act as a music encyclopedia editor.\n\nTask: Compile a single comprehensive overview of the track "${ctx.track}" by ${ctx.artist} covering every major facet — origin, sound, reception, and legacy.\n\nOutput Format: Return a Markdown document with sections for **Background**, **Sound & Style**, **Reception**, and **Legacy**, each 2-3 sentences.`
: ctx.album
? `Role: Act as a music encyclopedia editor.\n\nTask: Compile a single comprehensive overview of the album "${ctx.album}" by ${ctx.artist} covering every major facet — origin, sound, reception, and legacy.\n\nOutput Format: Return a Markdown document with sections for **Background**, **Sound & Style**, **Reception**, and **Legacy**, each 2-3 sentences.`
: `Role: Act as a music encyclopedia editor.\n\nTask: Compile a single comprehensive overview of ${ctx.artist} covering every major facet of their career — origin, sound, evolution, and legacy.\n\nOutput Format: Return a Markdown document with sections for **Background**, **Sound & Style**, **Career Evolution**, and **Legacy**, each 2-3 sentences.`
},
{
label: "Guide", prompt: ctx => ctx.track
? `Role: Act as an analytical music curator.\n\nTask: Create a structural listening guide for a first-time listener of "${ctx.track}" by ${ctx.artist}.\n\nOutput Format: Provide a bulleted Markdown list detailing exactly what audio elements to focus on during:\n1. The intro/rhythm establishment\n2. The vocal delivery and phrasing\n3. The bridge or structural deviations`
: ctx.album
? `Role: Act as an analytical music curator.\n\nTask: Create a beginner's entry-point guide for "${ctx.album}" by ${ctx.artist}.\n\nOutput Format: Return a Markdown table featuring the 3 most essential tracks. Columns must include: Track Name, Thematic Relevance, and Sonic Identity (described in plain, technical language).`
: `Role: Act as an analytical music curator.\n\nTask: Map an entry-point listening sequence for ${ctx.artist}.\n\nOutput Format: Suggest exactly 5 essential tracks to listen to in order. Output as a numbered list where each entry includes the track name and a concise, evidence-based rationale for its placement in the sequence to demonstrate stylistic evolution.`
},
{
label: "Timeline", prompt: ctx => ctx.track
? `Task: Construct a chronological timeline mapping the lifecycle of "${ctx.track}" by ${ctx.artist}.\n\nOutput Format: Output strictly as a Markdown table with columns: Date (or Year), Event (e.g., studio sessions, single release, chart peak), and Significance.`
: ctx.album
? `Task: Construct a chronological timeline mapping the lifecycle of "${ctx.album}" by ${ctx.artist}.\n\nOutput Format: Output strictly as a Markdown table with columns: Date (or Year), Event (e.g., recording phase, lead singles, album release, tour), and Significance.`
: `Task: Construct a chronological timeline of the pivotal milestones in the career of ${ctx.artist}.\n\nOutput Format: Output strictly as a Markdown table with columns: Year, Event (e.g., major releases, label shifts, hiatuses), and Industry Impact.`
},
{
label: "Commercial", prompt: ctx => ctx.track
? `Task: Retrieve raw commercial and streaming metrics for "${ctx.track}" by ${ctx.artist}.\n\nConstraints: Do not summarize. Provide raw data only.\n\nOutput Format: Output as a Markdown table including: RIAA (or global) certifications, peak Billboard/global chart positions, and major streaming milestones.`
: ctx.album
? `Task: Retrieve raw commercial and sales metrics for "${ctx.album}" by ${ctx.artist}.\n\nConstraints: Do not summarize. Provide raw data only.\n\nOutput Format: Output as a Markdown table including: First-week sales figures, total certified units, chart longevity, and physical vs. digital performance splits.`
: `Task: Detail the absolute commercial apex of ${ctx.artist}.\n\nOutput Format: Output a Markdown table listing their top 3 best-selling albums, top 3 highest-grossing tours, and overall market share/certifications during their peak era.`
},
],
},
analysis: {
label: "Analysis",
items: [
{
label: "Theory", prompt: ctx => ctx.track
? `Role: Act as a music theorist.\n\nTask: Analyze the composition of "${ctx.track}" by ${ctx.artist}.\n\nOutput Format: Return a structured breakdown identifying:\n- Key and relative shifts\n- BPM and time signature\n- Primary chord progressions\n- Notable rhythmic syncopation or modulations`
: ctx.album
? `Role: Act as a music theorist.\n\nTask: Analyze the overarching musical and structural framework of "${ctx.album}" by ${ctx.artist}.\n\nOutput Format: Return a bulleted list discussing:\n- Prevailing tempos and keys\n- Recurring harmonic motifs\n- How track sequencing dictates the album's dynamic pacing`
: `Role: Act as a music theorist.\n\nTask: Identify the structural and harmonic hallmarks of ${ctx.artist}'s catalog.\n\nOutput Format: Return a structured analysis of their typical chord progressions, scale preferences, rhythmic tendencies, and how they deviate from standard pop or genre formats.`
},
{
label: "Genre Deep Dive", prompt: ctx => ctx.track
? `Role: Act as a genre taxonomist.\n\nTask: Perform a deep dive into the genre classification of "${ctx.track}" by ${ctx.artist}.\n\nOutput Format: Return a Markdown document covering:\n- **Primary & Sub-Genre:** (exact classification and defining traits)\n- **Lineage:** (the genres and movements it draws from)\n- **Hybridity:** (any cross-genre elements present)`
: ctx.album
? `Role: Act as a genre taxonomist.\n\nTask: Perform a deep dive into the genre classification of "${ctx.album}" by ${ctx.artist}.\n\nOutput Format: Return a Markdown document covering:\n- **Primary & Sub-Genre:** (exact classification and defining traits)\n- **Lineage:** (the genres and movements it draws from)\n- **Hybridity:** (any cross-genre elements present)`
: `Role: Act as a genre taxonomist.\n\nTask: Perform a deep dive into the genre classification and evolution of ${ctx.artist}.\n\nOutput Format: Return a Markdown document covering:\n- **Primary & Sub-Genre:** (exact classification and defining traits)\n- **Lineage:** (the genres and movements they draw from)\n- **Evolution:** (how their genre identity has shifted across their catalog)`
},
{
label: "Lyrics", prompt: ctx => ctx.track
? `Role: Act as a critical literary analyst.\n\nTask: Deconstruct the lyrics of "${ctx.track}" by ${ctx.artist}.\n\nOutput Format: Provide a structured breakdown identifying:\n- The central thematic conceit\n- Specific literary devices used (metaphors, double entendres)\n- The dominant rhyming scheme and syllable structure`
: ctx.album
? `Role: Act as a critical literary analyst.\n\nTask: Perform a macro thematic analysis of the lyrics across "${ctx.album}" by ${ctx.artist}.\n\nOutput Format: Detail the recurring motifs, overarching narrative arcs, and distinct character perspectives maintained throughout the project.`
: `Role: Act as a critical literary analyst.\n\nTask: Analyze the dominant lyrical themes and conceptual motifs present throughout ${ctx.artist}'s entire discography.\n\nOutput Format: Document how their subject matter, worldview, and lyrical complexity have evolved over their active years.`
},
{
label: "Sonic", prompt: ctx => ctx.track
? `Role: Act as an audio arrangement expert.\n\nTask: Break down the sonic architecture of "${ctx.track}" by ${ctx.artist}.\n\nOutput Format: Deconstruct the function of the track by separating it into:\n- The rhythm section (drums/bass dynamics)\n- Lead melody instrumentation\n- Counter-melodies and atmospheric background textures`
: ctx.album
? `Role: Act as an audio arrangement expert.\n\nTask: Break down the overarching sonic palette of "${ctx.album}" by ${ctx.artist}.\n\nOutput Format: Analyze the balance of instrumentation, recurring sound design textures, and how the physical arrangement supports the album's primary mood.`
: `Role: Act as an audio arrangement expert.\n\nTask: Define the core sonic architecture of ${ctx.artist}.\n\nOutput Format: Detail the specific instrumental textures, arrangement techniques, and tone choices that define their signature, recognizable sound.`
},
{
label: "Technical", prompt: ctx => ctx.track
? `Role: Act as a studio mix engineer.\n\nTask: Analyze the technical mix and master of "${ctx.track}" by ${ctx.artist}.\n\nOutput Format: Return a technical breakdown discussing:\n- Specific outboard gear or synthesizers utilized\n- Frequency balance and panning choices\n- Dynamic range compression and spatial effects (reverb/delay)`
: ctx.album
? `Role: Act as a studio mix engineer.\n\nTask: Analyze the mix engineering and mastering philosophy of "${ctx.album}" by ${ctx.artist}.\n\nOutput Format: Discuss the dynamic range (e.g., Loudness War context), frequency spectrum balance, analog vs. digital recording mediums used, and spatial depth.`
: `Role: Act as a studio mix engineer.\n\nTask: Describe the typical mixing and mastering aesthetics favored by ${ctx.artist} and their frequent engineers.\n\nOutput Format: Categorize their typical mixes (e.g., dense, sparse, highly compressed, dynamic) and list their staple outboard gear or DAWs.`
},
{
label: "Vocals", prompt: ctx => ctx.track
? `Role: Act as a vocal producer.\n\nTask: Deconstruct the vocal production on "${ctx.track}" by ${ctx.artist}.\n\nOutput Format: Detail the suspected microphone techniques, vocal layering/stacking, harmonic structure, tuning processing (e.g., Melodyne/Auto-Tune), and specific spatial effect chains.`
: ctx.album
? `Role: Act as a vocal producer.\n\nTask: Deconstruct the vocal production techniques heavily utilized across "${ctx.album}" by ${ctx.artist}.\n\nOutput Format: Focus on vocal tracking methods, doubling, choir/harmonic arrangements, and consistent effect chains used throughout the project.`
: `Role: Act as a vocal producer.\n\nTask: Analyze the physical vocal techniques and processing norms of ${ctx.artist}.\n\nOutput Format: Discuss their physical vocal range, phrasing tendencies, distinctive timbres, and preferred studio vocal chain processing.`
},
],
},
discovery: {
label: "Discovery",
items: [
{
label: "Similar Artists", prompt: ctx =>
`Task: Identify 5 well-known, easily accessible artists similar to ${ctx.artist}${ctx.album ? ` and their album "${ctx.album}"` : ""} in sound and style.\n\nOutput Format: Return a Markdown table with columns: Artist, Signature Album or Track, and Similarity Rationale (one sentence, mainstream-friendly).`
},
{
label: "Niche", prompt: ctx => ctx.track
? `Task: Bypass standard algorithmic recommendations.\n\nConstraints: Do not suggest mainstream or highly popular artists.\n\nOutput Format: Suggest 5 highly obscure, niche, or underground tracks that perfectly match the tempo, sonic characteristics, and production philosophy of "${ctx.track}" by ${ctx.artist}. Provide a 1-sentence technical justification for each.`
: ctx.album
? `Task: Bypass standard algorithmic recommendations.\n\nConstraints: Do not suggest mainstream or highly popular artists.\n\nOutput Format: Suggest 5 highly obscure, niche, or underground albums that share the exact sonic characteristics, mood, and production ethos of "${ctx.album}" by ${ctx.artist}. Provide a 1-sentence technical justification for each.`
: `Task: Bypass standard algorithmic recommendations.\n\nConstraints: Do not suggest mainstream or highly popular artists.\n\nOutput Format: Suggest 5 highly obscure, niche, or underground artists that share the exact sonic characteristics and artistic ethos of ${ctx.artist}. Provide a 1-sentence technical justification for each.`
},
{
label: "Samples", prompt: ctx => ctx.track
? `Task: Map the complete sample DNA of "${ctx.track}" by ${ctx.artist}.\n\nOutput Format: List any tracks sampled or interpolated to create this song, detailing the exact timestamp if known. Additionally, list notable subsequent tracks by other artists that have sampled this specific song.`
: ctx.album
? `Task: Map the sample DNA of "${ctx.album}" by ${ctx.artist}.\n\nOutput Format: Detail the primary samples and interpolations used in the production of this album. Note if this album has subsequently been heavily sampled by modern producers.`
: `Task: Map the overall sample lineage of ${ctx.artist}.\n\nOutput Format: Identify their most frequent sample sources and genres. Detail how often their own catalog is sampled by other artists, providing 3 notable examples.`
},
{
label: "Playlist", prompt: ctx => ctx.track
? `Task: Design a highly curated 10-track playlist where "${ctx.track}" by ${ctx.artist} serves as the centerpiece (track 5).\n\nConstraints: Ensure seamless sonic and rhythmic transitions between all tracks. Do not include other songs by the same artist.\n\nOutput Format: Output as a Markdown table with columns: Track #, Artist, Track Name, and Transition Rationale (focusing on tempo and key matching).`
: ctx.album
? `Task: Design a 10-track playlist acting as a sonic and thematic sequel to "${ctx.album}" by ${ctx.artist}.\n\nConstraints: Exclude all songs by ${ctx.artist}.\n\nOutput Format: Output as a Markdown table with columns: Track #, Artist, Track Name, and Sonic Rationale.`
: `Task: Design a 10-track introductory playlist for ${ctx.artist}.\n\nConstraints: Sequence the tracks to gradually build in musical complexity.\n\nOutput Format: Output as a Markdown table with columns: Track #, Track Name, and Rationale for its specific placement in the sequence.`
},
{
label: "Live", prompt: ctx => ctx.track
? `Task: Explain the live translation of "${ctx.track}" by ${ctx.artist}.\n\nOutput Format: Detail the arrangement changes when performed live. Note any instrumentation substitutions, tempo shifts, backing track reliance, or extended solo sections compared to the studio version.`
: ctx.album
? `Task: Detail the touring cycle and live translation associated with "${ctx.album}" by ${ctx.artist}.\n\nOutput Format: Provide data on the primary stage design concepts, the structure of the standard setlist, and how complex studio production was adapted for live arenas (hired musicians vs. backing tracks).`
: `Task: Summarize the live performance philosophy and structural touring history of ${ctx.artist}.\n\nOutput Format: Analyze whether they favor strict studio replication, heavy improvisation, or complete structural rearrangement. Highlight technical stage innovations from their most significant tours.`
},
],
},
context: {
label: "Context",
items: [
{
label: "Influences", prompt: ctx => ctx.track
? `Task: Identify the specific artists, genres, or movements that directly influenced "${ctx.track}" by ${ctx.artist}.\n\nOutput Format: Return a Markdown list of primary influences, each with a one-sentence explanation of how that influence is audible or evident.`
: ctx.album
? `Task: Identify the specific artists, genres, or movements that directly influenced "${ctx.album}" by ${ctx.artist}.\n\nOutput Format: Return a Markdown list of primary influences, each with a one-sentence explanation of how that influence is audible or evident.`
: `Task: Identify the specific artists, genres, or movements that most directly shaped ${ctx.artist}'s sound and career.\n\nOutput Format: Return a Markdown list of primary influences, each with a one-sentence explanation of how that influence is audible or evident.`
},
{
label: "Impact", prompt: ctx => ctx.track
? `Task: Detail the socio-political climate and industry impact of "${ctx.track}" by ${ctx.artist}.\n\nOutput Format: Explain how the era's zeitgeist influenced the writing, and conversely, how the track disrupted radio formats, streaming trends, or sub-genre viability.`
: ctx.album
? `Task: Detail the socio-political climate and industry impact of "${ctx.album}" by ${ctx.artist}.\n\nOutput Format: Explain how the project reflects or critiques its era. Discuss changes it catalyzed regarding label release strategies, production standards, or broader music industry economics.`
: `Task: Analyze the macro impact of ${ctx.artist} on the music business and culture.\n\nOutput Format: Contextualize them within the socio-political movements of their peak era. Detail how they altered touring models, marketing strategies, or broader genre boundaries.`
},
{
label: "Lore", prompt: ctx => ctx.track
? `Task: Document the internet culture and fandom lore associated with "${ctx.track}" by ${ctx.artist}.\n\nOutput Format: Identify specific viral trends, persistent memes, copypastas, or subcultural terminology that originated from this audio or its music video.`
: ctx.album
? `Task: Analyze the core fanbase demographic and lore that formed around "${ctx.album}" by ${ctx.artist}.\n\nOutput Format: Identify specific subcultures, fashion trends, internet milestones, or widespread memes driven by this specific era of the artist.`
: `Task: Document the internet lore, fandom demographics, and running jokes native to the ${ctx.artist} catalog.\n\nOutput Format: Detail the official/unofficial name of the fandom, associated subcultures, primary platforms where the fanbase operates, and major memes.`
},
{
label: "Aesthetics", prompt: ctx => ctx.track
? `Role: Act as an art director.\n\nTask: Analyze the visual language of the official music video or promotional art for "${ctx.track}" by ${ctx.artist}.\n\nOutput Format: Discuss the cinematography, color grading palette, and direct symbolism utilized.`
: ctx.album
? `Role: Act as an art director.\n\nTask: Analyze the visual identity of "${ctx.album}" by ${ctx.artist}.\n\nOutput Format: Identify the cover art designer or photographer. Discuss the specific artistic movements that influenced the packaging, typography, and era aesthetics.`
: `Role: Act as an art director.\n\nTask: Deconstruct the career-spanning visual aesthetics and iconography utilized by ${ctx.artist}.\n\nOutput Format: Analyze how their stage fashion, album artworks, and visual presentation interface with and enhance their audio output.`
},
{
label: "Trivia", prompt: ctx => ctx.track
? `Task: Provide deep-cut trivia and debunk myths regarding "${ctx.track}" by ${ctx.artist}.\n\nOutput Format: Return two Markdown lists:\n1. **Facts:** 5 highly obscure, verifiable facts about the recording process.\n2. **Myths:** Identify and correct the most common misheard lyrics or structural misconceptions.`
: ctx.album
? `Task: Provide deep-cut trivia and debunk myths regarding "${ctx.album}" by ${ctx.artist}.\n\nOutput Format: Return two Markdown lists:\n1. **Facts:** 5 highly obscure, verifiable facts about scrapped ideas or studio sessions.\n2. **Myths:** Identify and correct the most prominent urban legends or false narratives surrounding the album's meaning.`
: `Task: Provide deep-cut trivia and debunk myths regarding ${ctx.artist}.\n\nOutput Format: Return two Markdown lists:\n1. **Facts:** 5 obscure, verifiable pieces of insider lore regarding unreleased projects or pre-fame life.\n2. **Myths:** Identify and thoroughly debunk the largest public misconceptions or persistent rumors regarding the artist.`
},
{
label: "Philosophy", prompt: ctx => ctx.album
? `Task: Identify the underlying artistic manifesto or ideology driving "${ctx.album}" by ${ctx.artist}.\n\nOutput Format: Detail the philosophical leaning or psychological statement of the work. Cite any specific literature, films, or philosophers that explicitly influenced the project.`
: `Task: Analyze the core creative philosophy and worldview driving ${ctx.artist}.\n\nOutput Format: Detail the ethical, artistic, or existential viewpoints that consistently define their output and how they navigate industry compromises.`
},
],
},
};
const AI_PRESETS = Object.values(AI_CATEGORIES).flatMap(c => c.items.map(item => item.prompt));
function openSettingsModal(initialTab) {
const existing = document.getElementById("lfm-modal-overlay");
if (existing) existing.remove();
const overlay = document.createElement("div");
overlay.id = "lfm-modal-overlay";
// Added "Info" tab
const tabs = [
{ id: "general", label: "General", icon: "fa-sliders" },
{ id: "sections", label: "Sections", icon: "fa-layer-group" },
{ id: "ai", label: "AI", icon: "fa-flask" },
{ id: "custom", label: "Custom", icon: "fa-link" },
{ id: "advanced", label: "Advanced", icon: "fa-screwdriver-wrench" },
{ id: "docs", label: "Docs", icon: "fa-book" },
{ id: "info", label: "Info", icon: "fa-circle-info" }
];
const customServicesHtml = getCustomServices().map((svc, i) =>
`<div class="settings-item">
<div class="settings-info">
<span class="settings-label">${escHtml(svc.label)}</span>
<span class="settings-desc">${escHtml(svc.urlTemplate)}</span>
</div>
<button class="settings-remove-btn" data-index="${i}"><i class="fa-solid fa-xmark"></i></button>
</div>`
).join("") ||
'<p class="settings-section-title" style="padding:10px 12px;margin:0;">No custom services added yet.</p>';
overlay.innerHTML = `
<div id="lfm-modal-box" class="settings-modal">
<div class="settings-header">
<h2>Settings</h2>
<div style="display:flex;align-items:center;gap:8px;position:absolute;right:16px;top:50%;transform:translateY(-50%);">
<span id="lfm-modal-close" style="cursor:pointer;color:var(--text-secondary);font-size:16px;line-height:1;transition:color .15s;"><i class="fa-solid fa-xmark"></i></span>
</div>
</div>
<div class="settings-body">
<div class="settings-tabs" id="settings-sidebar">
${tabs.map(t => `<button class="settings-tab${t.id === "general" ? " active" : ""}" data-tab="${t.id}" title="${t.label}"><i class="fa-solid ${t.icon}"></i><span class="tab-label">${t.label}</span></button>`).join("")}
</div>
<div class="settings-content">
<div class="settings-panel" data-panel="general" style="display:flex">
<div class="settings-group" style="gap: 8px;">
<div class="settings-item">
<div class="settings-info">
<span class="settings-label">Last.fm Username</span>
<span class="settings-desc">Generate dynamic profile metrics and image canvas links.</span>
</div>
<input type="text" id="modal-username-input" placeholder="Username">
</div>
<div class="settings-item">
<div class="settings-info">
<span class="settings-label">Inline Toggle</span>
<span class="settings-desc">Choose between the bubble button, minimal icon, or hide entirely.</span>
</div>
<select id="modal-icon-style">
<option value="bubble">Bubble</option>
<option value="minimal">Minimal</option>
<option value="hidden">Highlight Only</option>
</select>
</div>
<div class="settings-item">
<div class="settings-info">
<span class="settings-label">Opening Behavior</span>
<span class="settings-desc">Popup opens a centered window; Tab opens a regular background tab.</span>
</div>
<select id="modal-toggle-open-mode">
<option value="popup">Popup Window</option>
<option value="tab">New Tab</option>
</select>
</div>
<div class="settings-item">
<div class="settings-info">
<span class="settings-label">Toggle Position</span>
<span class="settings-desc">Move the main floating button to avoid UI overlaps.</span>
</div>
<select id="modal-toggle-position">
<option value="bottom-left">Bottom Left</option>
<option value="bottom-right">Bottom Right</option>
<option value="top-left">Top Left</option>
<option value="top-right">Top Right</option>
</select>
</div>
<div class="settings-item">
<div class="settings-info">
<span class="settings-label">Menu Behavior</span>
<span class="settings-desc">Control when the toolbox menu closes after clicking a service.</span>
</div>
<select id="modal-close-behavior">
<option value="close">Unfocus Close</option>
<option value="keep">Keep Open</option>
<option value="timeout">Auto-Close</option>
</select>
</div>
<div class="settings-item" id="modal-close-delay-container" style="display: none;">
<div class="settings-info">
<span class="settings-label">Auto-Close Delay (Seconds)</span>
<span class="settings-desc">Time to wait before closing the panel.</span>
</div>
<input type="number" id="modal-close-delay-input" min="1" max="60" value="3">
</div>
<div class="settings-item">
<div class="settings-info">
<span class="settings-label">Hover Highlight</span>
<span class="settings-desc">Eligible items turn red when hovered to denote right-click capacity.</span>
</div>
<div id="modal-toggle-highlight" class="toggle-switch"><i class="fa-solid fa-radiation"></i></div>
</div>
<div class="settings-item">
<div class="settings-info">
<span class="settings-label">Color Mode</span>
<span class="settings-desc">Toggle user interface between Dark (Default) and Light Mode.</span>
</div>
<div id="modal-toggle-light-mode" class="toggle-switch"><i class="fa-solid fa-radiation"></i></div>
</div>
</div>
</div>
<div class="settings-panel" data-panel="sections" style="display:none">
<p class="settings-section-title">Enable or disable categories or fine-tune specific items.</p>
<div style="display: flex; flex-direction: column; gap: 12px;">
${CATEGORY_ORDER.map(cat => {
const isCatHidden = localStorage.getItem("setting-section-" + cat) === "false";
const isOpenAll = localStorage.getItem("setting-openall-" + cat) === "true";
let itemsHtml = `<div class="settings-item-subgroup" id="subgroup-cat-${cat}" style="margin-top: 10px; padding-top: 10px; border-top: 1px solid var(--border-color); display: ${isCatHidden ? 'none' : 'grid'}; grid-template-columns: repeat(3, 1fr); gap: 6px 12px;">`;
SERVICE_CATEGORIES[cat].forEach(id => {
const label = SERVICE_LABELS[id] || id;
const isItemHidden = localStorage.getItem("setting-svc-hidden-" + id) === "true";
itemsHtml += `
<div class="settings-item" style="padding: 2px 4px; min-height: 24px;">
<div class="settings-info">
<span class="settings-label" style="font-weight: 500; font-size: 11.5px; color: var(--text-primary);">${label}</span>
</div>
<div id="modal-toggle-svc-${id}" class="toggle-switch ${!isItemHidden ? 'active' : ''}" style="width: 14px; height: 14px; border-radius: 3px; border-width: 1px;">
<i class="fa-solid fa-check" style="font-size: 9px;"></i>
</div>
</div>`;
});
// Append Open All toggle inline with other grid items
itemsHtml += `
<div class="settings-item" style="padding: 2px 4px; min-height: 24px;">
<div class="settings-info">
<span class="settings-label" style="font-weight: 700; font-size: 10px; color: var(--text-secondary); text-transform: uppercase;">Open All</span>
</div>
<div id="modal-toggle-openall-${cat}" class="toggle-switch ${isOpenAll ? 'active' : ''}" style="width: 14px; height: 14px; border-radius: 3px; border-width: 1px;">
<i class="fa-solid fa-check" style="font-size: 9px;"></i>
</div>
</div>
</div>`;
return `
<div class="category-toggle-block" style="background: var(--bg-secondary); border-radius: 6px; padding: 12px; border: 1px solid var(--border-color);">
<div class="settings-item" style="padding: 0;">
<div class="settings-info">
<span class="settings-label" style="text-transform: uppercase; letter-spacing: 0.5px; font-size: 13px; font-weight: 700; color: var(--brand);">${CATEGORY_LABELS[cat]}</span>
</div>
<div id="modal-toggle-section-${cat}" class="toggle-switch ${!isCatHidden ? 'active' : ''}">
<i class="fa-solid fa-radiation"></i>
</div>
</div>
${itemsHtml}
</div>
`;
}).join("")}
</div>
</div>
<div class="settings-panel" data-panel="ai" style="display:none">
<div class="settings-group" style="gap: 12px;">
<!-- Toggles Grid -->
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 10px;">
<div class="settings-item settings-grid-card">
<div class="settings-info">
<span class="settings-label">Popup Behavior</span>
<span class="settings-desc">Open providers in a popup window.</span>
</div>
<div id="modal-toggle-ai-popup" class="toggle-switch"><i class="fa-solid fa-check"></i></div>
</div>
<div class="settings-item settings-grid-card">
<div class="settings-info">
<span class="settings-label">Auto-Copy Prompt</span>
<span class="settings-desc">Copy to clipboard on open.</span>
</div>
<div id="modal-toggle-ai-autocopy" class="toggle-switch"><i class="fa-solid fa-check"></i></div>
</div>
</div>
<!-- Selects Grid -->
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 10px;">
<div class="settings-item settings-grid-card">
<div class="settings-info">
<span class="settings-label">Default Provider</span>
</div>
<select id="setting-select-default-ai-provider" style="width: auto; min-width: 110px;">
${AI_PROVIDERS.map(p => `<option value="${p.id}">${p.label}</option>`).join("")}
</select>
</div>
<div class="settings-item settings-grid-card">
<div class="settings-info">
<span class="settings-label">Default Category</span>
</div>
<select id="setting-select-default-ai-category" style="width: auto; min-width: 110px;">
${Object.entries(AI_CATEGORIES).map(([key, cat]) => `<option value="${key}">${cat.label}</option>`).join("")}
</select>
</div>
</div>
<!-- Prompt Preview & Editor -->
<div class="settings-grid-card" style="display: flex; flex-direction: column;">
<p class="settings-card-title">Prompt Preview & Editor</p>
<p class="settings-desc" style="margin: -6px 0 10px;">Select a preset to preview and edit. Changes are saved per-preset.</p>
<div style="display: flex; gap: 8px; width: 100%;">
<select id="setting-ai-preview-category" style="flex: 1;"></select>
<select id="setting-ai-preview-preset" style="flex: 1;"></select>
</div>
<textarea id="setting-ai-preview-text" style="width: 100%; height: 90px; box-sizing: border-box; resize: vertical; padding: 8px; margin-top: 8px; font-size: 12px; background: var(--input-bg); border: 1px solid var(--input-border); color: var(--text-primary); border-radius: 4px;"></textarea>
<div style="display: flex; gap: 8px; align-items: center; width: 100%; margin-top: 8px;">
<button id="setting-ai-preview-save-btn" style="padding: 5px 14px; font-size: 11px; font-weight: 700; border-radius: 4px; background: rgba(var(--brand-rgb),.12); border: 1px solid var(--brand); color: var(--text-primary); cursor: pointer; transition: background .15s;">Save Preset</button>
<button id="setting-ai-preview-reset-btn" style="padding: 5px 10px; font-size: 11px; border-radius: 4px; background: var(--bg-hover); border: 1px solid var(--border-color); color: var(--text-secondary); cursor: pointer; transition: background .15s;">Reset to Default</button>
<span id="setting-ai-preview-status" style="font-size: 11px; color: var(--text-secondary); margin-left: auto;"></span>
</div>
</div>
<!-- Custom Instructions -->
<div class="settings-grid-card" style="display: flex; flex-direction: column;">
<p class="settings-card-title">Custom Instructions</p>
<p class="settings-desc" style="margin: -6px 0 10px;">Appended to all generated prompts (e.g., "Format as markdown").</p>
<textarea id="setting-ai-custom-suffix" style="width: 100%; height: 60px; box-sizing: border-box; resize: vertical; padding: 8px; font-size: 12px; background: var(--input-bg); border: 1px solid var(--input-border); color: var(--text-primary); border-radius: 4px;"></textarea>
</div>
</div>
</div>
<div class="settings-panel" data-panel="custom" style="display:none">
<p class="settings-section-title">Add custom links to the toolbox. Use variables like <code>{artist}</code>, <code>{album}</code>, <code>{track}</code>, and <code>{query}</code> in the URL template.</p>
<div id="settings-custom-list">
${customServicesHtml}
</div>
<div class="settings-custom-form">
<input type="text" id="settings-custom-label" placeholder="Label">
<input type="text" id="settings-custom-url" placeholder="URL with {artist} {album} {track}">
<button id="settings-custom-add"><i class="fa-solid fa-plus"></i> Add</button>
</div>
</div>
<div class="settings-panel" data-panel="advanced" style="display:none;">
<p class="settings-section-title" style="margin: 0 0 10px; flex-shrink: 0;">Backup, restore, fine-tune, or reset your Last.fm Toolbox configuration state variables.</p>
<div style="flex: 1; display: flex; flex-direction: column; min-height: 0; gap: 8px;">
<!-- Editor stretches to fill all available space automatically -->
<textarea id="modal-advanced-json-editor" spellcheck="false" style="flex: 1; width: 100%; min-height: 0; box-sizing: border-box; font-family: monospace; font-size: 11px; line-height: 1.4; background: var(--input-bg); border: 1px solid var(--input-border); color: #00ce37; border-radius: 6px; padding: 10px; resize: none; margin: 0;"></textarea>
<!-- Bottom action row -->
<div style="display: flex; justify-content: space-between; align-items: center; gap: 8px; padding-top: 8px; border-top: 1px solid var(--border-color); flex-wrap: wrap; flex-shrink: 0;">
<div style="display: flex; gap: 6px;">
<button id="settings-export-btn" class="settings-tab" style="padding: 4px 10px !important; font-size: 11px !important; border-radius: 4px !important; margin: 0; background: transparent;"><i class="fa-solid fa-copy"></i> Export</button>
<button id="settings-import-btn" class="settings-tab" style="padding: 4px 10px !important; font-size: 11px !important; border-radius: 4px !important; margin: 0; background: transparent;"><i class="fa-solid fa-file-import"></i> Import</button>
<button id="settings-reset-btn" class="settings-tab" style="padding: 4px 10px !important; font-size: 11px !important; border-radius: 4px !important; margin: 0; border-color: var(--brand) !important; color: var(--brand) !important; background: rgba(218,35,35,0.06);"><i class="fa-solid fa-arrow-rotate-left"></i> Reset</button>
</div>
<button id="settings-save-json-btn" class="settings-tab active" style="padding: 4px 14px !important; font-size: 11px !important; border-radius: 4px !important; margin: 0; font-weight: 600;"><i class="fa-solid fa-floppy-disk"></i> Apply Live Edits</button>
</div>
</div>
</div>
<div class="settings-panel" data-panel="docs" style="display:none">
<p class="settings-section-title">A quick reference for everything the toolbox can do.</p>
<div style="display: flex; flex-direction: column; gap: 10px;">
<div class="settings-card">
<p class="settings-card-title">Keyboard Shortcuts</p>
<p class="settings-desc" style="margin:0;"><code>Ctrl+Shift+E</code> opens or closes the toolbox using the current page's context. <code>Esc</code> closes whatever's open — a modal first, then the menu.</p>
</div>
<div class="settings-card">
<p class="settings-card-title">The Floating Button</p>
<p class="settings-desc" style="margin:0;">Left-click toggles the menu. Right-click cycles its screen corner: bottom-left → bottom-right → top-left → top-right.</p>
</div>
<div class="settings-card">
<p class="settings-card-title">Right-Click Any Link</p>
<p class="settings-desc" style="margin:0;">Right-click any artist, album, or track link on Last.fm — grid cards, chartlists, and the "..." more menu included — to jump straight into the toolbox for that item, even with inline icons hidden.</p>
</div>
<div class="settings-card">
<p class="settings-card-title">Inline Icons</p>
<p class="settings-desc" style="margin:0;">Choose Bubble, Minimal, or Highlight Only under Settings > General > Inline Toggle. Highlight Only skips the icons and just turns eligible links red on hover.</p>
</div>
<div class="settings-card">
<p class="settings-card-title">Manual Search</p>
<p class="settings-desc" style="margin:0;">Type <code>Artist</code>, <code>Artist - Album</code>, or <code>Artist - Album - Track</code> into the search box and hit Enter to load that context. Click the <code>?</code> icon for a format reminder.</p>
</div>
<div class="settings-card">
<p class="settings-card-title">Sections & Open All</p>
<p class="settings-desc" style="margin:0;">Click a category header to expand or collapse it — your choice is remembered. Open All is off by default; enable it per-category in Settings > Sections to launch every service in that category, staggered so your browser doesn't choke.</p>
</div>
<div class="settings-card">
<p class="settings-card-title">Service Rows</p>
<p class="settings-desc" style="margin:0;">Clicking a service opens it using your Opening Behavior setting (Tab or Popup). Hover a row for a <code>+</code> button that always forces a new tab, or a robot icon that sends that context straight into the AI Prompt Generator.</p>
</div>
<div class="settings-card">
<p class="settings-card-title">TheAudioDB Extras</p>
<p class="settings-desc" style="margin:0;">The main link resolves straight to the artist's visual page. The <code></></code> and Markdown buttons next to it download the raw API data or a formatted band profile — nothing downloads if no match is found.</p>
</div>
<div class="settings-card">
<p class="settings-card-title">AI Prompt Generator</p>
<p class="settings-desc" style="margin:0;">Pick a category and preset, edit the generated prompt inline, then Copy, Open, or Copy & Open. Your last-used provider is remembered, and Settings > AI > Auto-Copy Prompt copies before opening.</p>
</div>
<div class="settings-card">
<p class="settings-card-title">Menu Behavior</p>
<p class="settings-desc" style="margin:0;">Decide whether the toolbox closes immediately after clicking a service, stays open, or auto-closes after a delay — set it in Settings > General > Menu Behavior.</p>
</div>
<div class="settings-card">
<p class="settings-card-title">Troubleshooting</p>
<p class="settings-desc" style="margin:0;">Button hidden behind other page UI? Use <code>Ctrl+Shift+E</code>, or your userscript manager's menu commands (Toggle / Settings / Cycle Popup) instead.</p>
</div>
</div>
</div>
<div class="settings-panel" data-panel="info" style="display:none">
<div style="text-align: center; margin-bottom: 4px; padding-top: 0;">
<img
src="https://cdn.jsdelivr.net/gh/deathrashed/gupload@main/uPic/graf-banner.png"
alt="Last.fm Toolbox"
style="display: block; width: 100%; height: auto; margin: 0 auto 2px;"
>
<h3 style="margin: 0; color: var(--text-primary); font-size: 18px;">Last.fm: Toolbox</h3>
<p style="margin: 4px 0 0; color: var(--text-secondary); font-size: 12px;">Version 6 by <a href="https://www.last.fm/user/deathrashed" target="_blank" class="settings-author-link">deathrashed</a></p>
</div>
<div class="settings-group" style="gap: 4px;">
<p class="settings-section-title" style="margin: 0 0 4px;">Project Links</p>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 6px;">
<a href="https://greasyfork.org/scripts/563609" target="_blank" class="settings-item settings-grid-card" style="text-decoration: none; align-items: flex-start;">
<div class="settings-info" style="gap: 4px;">
<span class="settings-label">Greasy Fork</span>
<span class="settings-desc">Rate & review the script.</span>
</div>
<i class="fa-solid fa-arrow-up-right-from-square" style="color: var(--text-secondary); margin-top: 2px;"></i>
</a>
<a href="https://github.com/deathrashed/lastfm-userscript" target="_blank" class="settings-item settings-grid-card" style="text-decoration: none; align-items: flex-start;">
<div class="settings-info" style="gap: 4px;">
<span class="settings-label">GitHub Repository</span>
<span class="settings-desc">Source, fork, or star.</span>
</div>
<i class="fa-brands fa-github" style="color: var(--text-secondary); font-size: 16px; margin-top: 2px;"></i>
</a>
<a href="https://github.com/deathrashed/lastfm-userscript/issues/new" target="_blank" class="settings-item settings-grid-card" style="text-decoration: none; grid-column: 1 / -1;">
<div class="settings-info" style="gap: 4px;">
<span class="settings-label">Feature Requests & Bug Reports</span>
<span class="settings-desc">Submit ideas or report issues directly via GitHub Issues.</span>
</div>
<i class="fa-solid fa-lightbulb" style="color: var(--brand); font-size: 16px;"></i>
</a>
</div>
<p class="settings-section-title" style="margin: 8px 0 4px;">Recommended Resources</p>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 10px;">
<a href="https://www.bijou.fm/tools?service=Last.fm" target="_blank" class="settings-item settings-grid-card" style="text-decoration: none; align-items: flex-start;">
<div class="settings-info" style="gap: 4px;">
<span class="settings-label">Bijou</span>
<span class="settings-desc">Last.fm tools & utilities.</span>
</div>
<i class="fa-solid fa-arrow-up-right-from-square" style="color: var(--text-secondary); margin-top: 2px;"></i>
</a>
<a href="https://github.com/chr1sx/Last.fm-Artwork-Upload-Helper/" target="_blank" class="settings-item settings-grid-card" style="text-decoration: none; align-items: flex-start;">
<div class="settings-info" style="gap: 4px;">
<span class="settings-label">Artwork Uploader</span>
<span class="settings-desc">Upload custom artwork.</span>
</div>
<i class="fa-brands fa-github" style="color: var(--text-secondary); font-size: 16px; margin-top: 2px;"></i>
</a>
</div>
</div>
</div>
<div id="settings-footer" style="text-align:center;padding:12px 24px 6px;font-size:11px;color:var(--text-secondary);border-top:1px solid var(--border-color);margin-top:12px;flex-shrink:0;">
<span id="settings-footer-label">General</span>
</div>
</div>
</div>
`;
document.body.appendChild(overlay);
overlay.addEventListener("click", e => {
if (e.target === overlay) overlay.remove();
});
document.getElementById("lfm-modal-close").addEventListener("click", () => overlay.remove());
overlay.querySelectorAll(".settings-tab").forEach(tab => {
tab.addEventListener("click", function () {
overlay.querySelectorAll(".settings-tab").forEach(t => t.classList.remove("active"));
this.classList.add("active");
overlay.querySelectorAll(".settings-panel").forEach(p => p.style.display = "none");
const panel = overlay.querySelector(`.settings-panel[data-panel="${this.dataset.tab}"]`);
if (panel) panel.style.display = "flex";
const footerLabel = document.getElementById("settings-footer-label");
const tabLabel = this.querySelector(".tab-label");
if (footerLabel && tabLabel) footerLabel.textContent = tabLabel.textContent;
});
});
// If a specific tab was requested (e.g. from AI modal gear button), activate it
if (initialTab) {
const targetTab = overlay.querySelector(`.settings-tab[data-tab="${initialTab}"]`);
if (targetTab) {
overlay.querySelectorAll(".settings-tab").forEach(t => t.classList.remove("active"));
targetTab.classList.add("active");
overlay.querySelectorAll(".settings-panel").forEach(p => p.style.display = "none");
const panel = overlay.querySelector(`.settings-panel[data-panel="${initialTab}"]`);
if (panel) panel.style.display = "flex";
const footerLabel = document.getElementById("settings-footer-label");
const tabLabel = targetTab.querySelector(".tab-label");
if (footerLabel && tabLabel) footerLabel.textContent = tabLabel.textContent;
}
}
const sidebarToggle = document.getElementById("settings-sidebar-toggle-btn");
const sidebar = document.getElementById("settings-sidebar");
if (sidebarToggle && sidebar) {
if (localStorage.getItem("setting-sidebar-expanded") !== "false") {
sidebar.classList.add("expanded");
sidebarToggle.innerHTML = '<i class="fa-solid fa-chevron-left"></i><span class="toggle-label">Collapse</span>';
}
sidebarToggle.addEventListener("click", function (e) {
e.stopPropagation();
const expanded = sidebar.classList.toggle("expanded");
localStorage.setItem("setting-sidebar-expanded", expanded ? "true" : "false");
this.innerHTML = expanded
? '<i class="fa-solid fa-chevron-left"></i><span class="toggle-label">Collapse</span>'
: '<i class="fa-solid fa-chevron-right"></i><span class="toggle-label">Expand</span>';
});
}
overlay.querySelectorAll(".settings-remove-btn").forEach(btn => {
btn.addEventListener("click", function () {
const list = getCustomServices();
list.splice(parseInt(this.dataset.index, 10), 1);
saveCustomServices(list);
overlay.remove();
openSettingsModal();
});
});
document.getElementById("settings-custom-add")?.addEventListener("click", () => {
const label = document.getElementById("settings-custom-label").value.trim();
const urlTemplate = document.getElementById("settings-custom-url").value.trim();
if (!label || !urlTemplate) return;
const services = getCustomServices();
services.push({ label, urlTemplate });
saveCustomServices(services);
overlay.remove();
openSettingsModal();
});
setupToggles();
// Wire up AI settings prompt preview with persistent per-preset editing
const aiPreviewCat = document.getElementById("setting-ai-preview-category");
const aiPreviewPreset = document.getElementById("setting-ai-preview-preset");
const aiPreviewText = document.getElementById("setting-ai-preview-text");
const aiPreviewSaveBtn = document.getElementById("setting-ai-preview-save-btn");
const aiPreviewResetBtn = document.getElementById("setting-ai-preview-reset-btn");
const aiPreviewStatus = document.getElementById("setting-ai-preview-status");
if (aiPreviewCat && aiPreviewPreset && aiPreviewText) {
// Populate category dropdown
aiPreviewCat.innerHTML = Object.entries(AI_CATEGORIES).map(([key, cat]) =>
`<option value="${key}">${cat.label}</option>`
).join("");
function getPreviewStorageKey() {
const realCtx = buildContext(currentContext);
const ctxType = realCtx.track ? "track" : (realCtx.album ? "album" : "artist");
return `setting-ai-preset-override-${aiPreviewCat.value}-${aiPreviewPreset.value}-${ctxType}`;
}
function getDefaultPromptText() {
const catKey = aiPreviewCat.value;
const category = AI_CATEGORIES[catKey];
if (!category) return "";
const idx = parseInt(aiPreviewPreset.value, 10);
const item = category.items[idx];
if (!item) return "";
const realCtx = buildContext(currentContext);
const placeholderCtx = {};
if (realCtx.artist) placeholderCtx.artist = "{artist}";
if (realCtx.album) placeholderCtx.album = "{album}";
if (realCtx.track) placeholderCtx.track = "{track}";
const suffix = localStorage.getItem("setting-ai-custom-suffix") || "";
const suffixStr = suffix ? "\n\n" + suffix : "";
return item.prompt(placeholderCtx) + suffixStr;
}
function loadPreviewText() {
const override = localStorage.getItem(getPreviewStorageKey());
aiPreviewText.value = override !== null ? override : getDefaultPromptText();
const hasOverride = override !== null;
if (aiPreviewStatus) aiPreviewStatus.textContent = hasOverride ? "✎ Custom" : "";
}
function populatePresets() {
const catKey = aiPreviewCat.value;
const category = AI_CATEGORIES[catKey];
if (!category) return;
aiPreviewPreset.innerHTML = category.items.map((item, i) =>
`<option value="${i}">${item.label}</option>`
).join("");
loadPreviewText();
}
aiPreviewCat.addEventListener("change", populatePresets);
aiPreviewPreset.addEventListener("change", loadPreviewText);
populatePresets();
if (aiPreviewSaveBtn) {
aiPreviewSaveBtn.addEventListener("click", () => {
localStorage.setItem(getPreviewStorageKey(), aiPreviewText.value);
if (aiPreviewStatus) {
aiPreviewStatus.textContent = "✓ Saved";
setTimeout(() => { aiPreviewStatus.textContent = "✎ Custom"; }, 1500);
}
});
}
if (aiPreviewResetBtn) {
aiPreviewResetBtn.addEventListener("click", () => {
localStorage.removeItem(getPreviewStorageKey());
aiPreviewText.value = getDefaultPromptText();
if (aiPreviewStatus) {
aiPreviewStatus.textContent = "↺ Reset";
setTimeout(() => { aiPreviewStatus.textContent = ""; }, 1500);
}
});
}
}
const posSelect = document.getElementById("modal-toggle-position");
if (posSelect) {
posSelect.value = getTogglePosition();
posSelect.addEventListener("change", function () {
localStorage.setItem(TOGGLE_POSITION_KEY, this.value);
applyTogglePosition();
});
}
const iconStyleSelect = document.getElementById("modal-icon-style");
if (iconStyleSelect) {
iconStyleSelect.value = localStorage.getItem("setting-icon-style") || "bubble";
iconStyleSelect.addEventListener("change", function () {
localStorage.setItem("setting-icon-style", this.value);
document.documentElement.classList.toggle("lfm-icon-style-minimal", this.value === "minimal");
document.documentElement.classList.toggle("lfm-icons-hidden", this.value === "hidden");
});
}
const openModeSelect = document.getElementById("modal-toggle-open-mode");
if (openModeSelect) {
openModeSelect.value = localStorage.getItem("musicengine.defaultOpenMode") || "tab";
openModeSelect.addEventListener("change", function () {
localStorage.setItem("musicengine.defaultOpenMode", this.value);
updateServiceLaunchButtonsVisibility();
});
}
// 1. Stored Profile User string tracker
const usernameInput = document.getElementById("modal-username-input");
if (usernameInput) {
usernameInput.value = localStorage.getItem("setting-lfm-username") || "";
usernameInput.addEventListener("input", function () {
localStorage.setItem("setting-lfm-username", this.value.trim());
});
}
// 2. Menu Auto-Close Behavior Visibility Loop & Listeners
const closeBehaviorSelect = document.getElementById("modal-close-behavior");
const delayContainer = document.getElementById("modal-close-delay-container");
const delayInput = document.getElementById("modal-close-delay-input");
if (closeBehaviorSelect && delayContainer && delayInput) {
const updateDelayVisibility = (val) => {
delayContainer.style.display = val === "timeout" ? "flex" : "none";
};
closeBehaviorSelect.value = localStorage.getItem("setting-close-behavior") || "close";
updateDelayVisibility(closeBehaviorSelect.value);
closeBehaviorSelect.addEventListener("change", function () {
localStorage.setItem("setting-close-behavior", this.value);
updateDelayVisibility(this.value);
});
delayInput.value = localStorage.getItem("setting-close-delay") || "3";
delayInput.addEventListener("input", function () {
localStorage.setItem("setting-close-delay", this.value);
});
}
document.getElementById("settings-export-btn")?.addEventListener("click", () => {
const config = {};
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
if (key.startsWith("setting-") || key.startsWith("musicengine.") || key === "lfm-custom-services") {
config[key] = localStorage.getItem(key);
}
}
const json = JSON.stringify(config, null, 2);
const blob = new Blob([json], { type: "application/json" });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = "lastfm-toolbox-settings.json";
link.style.display = "none";
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
showToast("Settings file downloaded.");
});
document.getElementById("settings-import-btn")?.addEventListener("click", () => {
showToast("Paste settings JSON into the prompt that follows");
setTimeout(() => {
const dataStr = prompt("Paste your exported settings JSON string here:");
if (!dataStr) return;
try {
const config = JSON.parse(dataStr);
Object.keys(config).forEach(key => {
if (key.startsWith("setting-") || key.startsWith("musicengine.") || key === "lfm-custom-services") {
localStorage.setItem(key, config[key]);
}
});
showToast("Settings imported! Reloading page...");
setTimeout(() => location.reload(), 800);
} catch (err) {
showToast("Invalid settings JSON: " + err.message);
}
}, 100);
});
document.getElementById("settings-reset-btn")?.addEventListener("click", () => {
if (confirm("Are you sure you want to reset all preferences to defaults? (Your custom services list will be preserved)")) {
for (let i = localStorage.length - 1; i >= 0; i--) {
const key = localStorage.key(i);
if ((key.startsWith("setting-") || key.startsWith("musicengine.")) && key !== "lfm-custom-services") {
localStorage.removeItem(key);
}
}
showToast("Settings reset! Reloading page...");
setTimeout(() => location.reload(), 800);
}
});
// Power-User JSON Editor Initialization & Event Hooks
const jsonEditor = document.getElementById("modal-advanced-json-editor");
const saveJsonBtn = document.getElementById("settings-save-json-btn");
if (jsonEditor && saveJsonBtn) {
refreshJsonEditor();
// Bind parsing intercept listener trigger on click events
saveJsonBtn.addEventListener("click", function () {
try {
const parsedConfig = JSON.parse(jsonEditor.value);
// Clear current keys to guarantee absolute parity on batch deletions
for (let i = localStorage.length - 1; i >= 0; i--) {
const key = localStorage.key(i);
if (key.startsWith("setting-") || key.startsWith("musicengine.") || key === "lfm-custom-services") {
localStorage.removeItem(key);
}
}
// Commit valid parsed fields back into localStorage keys
Object.keys(parsedConfig).forEach(key => {
const val = parsedConfig[key];
const serializedVal = (typeof val === "object" && val !== null) ? JSON.stringify(val) : String(val);
localStorage.setItem(key, serializedVal);
});
showToast("Configuration state rewritten cleanly! Reloading page...");
setTimeout(() => location.reload(), 800);
} catch (err) {
alert("Syntax Error: Invalid JSON detected. Please check brackets, comma assignments, and quotes.\n\nDetails: " + err.message);
}
});
}
// Auto-update JSON editor when interacting with UI toggles
document.getElementById("lfm-modal-box").addEventListener("click", () => setTimeout(refreshJsonEditor, 10));
document.getElementById("lfm-modal-box").addEventListener("input", () => setTimeout(refreshJsonEditor, 10));
document.getElementById("lfm-modal-box").addEventListener("change", () => setTimeout(refreshJsonEditor, 10));
}
function refreshJsonEditor() {
const jsonEditor = document.getElementById("modal-advanced-json-editor");
if (!jsonEditor || document.activeElement === jsonEditor) return;
const configObj = {};
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
if (key.startsWith("setting-") || key.startsWith("musicengine.") || key === "lfm-custom-services") {
try {
configObj[key] = JSON.parse(localStorage.getItem(key));
} catch {
configObj[key] = localStorage.getItem(key);
}
}
}
jsonEditor.value = JSON.stringify(configObj, null, 2);
}
function openAIModal() {
const existing = document.getElementById("lfm-modal-overlay");
if (existing) existing.remove();
const ctx = buildContext(currentContext);
const suffix = localStorage.getItem("setting-ai-custom-suffix") || "";
const suffixStr = suffix ? "\n\n" + suffix : "";
const defaultPrompt = (ctx.track
? `Give me a comprehensive overview of the song ${ctx.track} by ${ctx.artist}`
: ctx.album
? `Give me a comprehensive overview of the album ${ctx.album} by ${ctx.artist}`
: ctx.artist
? `Give me a comprehensive overview of the band ${ctx.artist}`
: "") + suffixStr;
const defaultProvider = localStorage.getItem("setting-default-ai-provider") || "chatgpt-link";
const lastProvider = localStorage.getItem("lfm-last-ai-provider") || defaultProvider;
const overlay = document.createElement("div");
overlay.id = "lfm-modal-overlay";
const contextIcon = ctx.track ? "fa-music" : ctx.album ? "fa-compact-disc" : ctx.artist ? "fa-user" : "fa-circle-question";
const contextType = ctx.track ? "Track" : ctx.album ? "Album" : ctx.artist ? "Artist" : "None";
const contextName = ctx.track ? `${ctx.track} — ${ctx.artist}` : ctx.album ? `${ctx.album} — ${ctx.artist}` : ctx.artist || "";
// Read default category from settings
const defaultCatKey = localStorage.getItem("setting-default-ai-category") || "overview";
const catOptions = Object.entries(AI_CATEGORIES).map(([key, cat]) =>
`<option value="${key}" ${key === defaultCatKey ? 'selected' : ''}>${cat.label}</option>`
).join("");
overlay.innerHTML = `
<div id="lfm-modal-box" class="ai-modal" style="width: 600px; padding: 20px;">
<div class="modal-header" style="position: relative; display: flex; justify-content: center; align-items: center; border-bottom: 1px solid var(--border-color); padding-bottom: 12px; margin-bottom: 12px;">
<div style="display: flex; flex-direction: column; align-items: center; gap: 4px;">
<div style="color: var(--brand); font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: 1px;"><i class="fa-solid fa-flask"></i> AI Prompt Generator</div>
<div style="font-size: 18px; font-weight: 700; color: var(--text-primary);">Configure Request</div>
</div>
<div style="position: absolute; right: 0; top: 0; display: flex; align-items: center; gap: 8px;">
<span id="ai-modal-settings-btn" title="AI Settings" style="cursor: pointer; color: var(--text-secondary); font-size: 14px; transition: color .15s;"><i class="fa-solid fa-gear"></i></span>
<span id="lfm-modal-close" style="cursor: pointer; color: var(--text-secondary); font-size: 16px; transition: color .15s;"><i class="fa-solid fa-xmark"></i></span>
</div>
</div>
<div id="ai-context-display">
<span class="ai-context-icon" style="background: rgba(218,35,35,0.1); border-radius: 4px; padding: 4px; height: 24px; width: 24px; display: flex; align-items: center; justify-content: center;"><i class="fa-solid ${contextIcon}"></i></span>
<div style="display: flex; flex-direction: column; gap: 2px;">
<span class="ai-context-type" style="padding: 0; background: transparent; font-size: 9px;">${contextType} Context</span>
<span class="ai-context-name" style="font-size: 14px;">${escHtml(contextName)}</span>
</div>
</div>
<div style="display: flex; gap: 12px; margin-bottom: 12px;">
<div style="flex: 1; display: flex; flex-direction: column; gap: 4px;">
<label style="font-size: 10px; font-weight: 600; color: var(--text-secondary); text-transform: uppercase;">Category</label>
<select id="ai-category-select" style="width: 100%; min-width: 100%;">${catOptions}</select>
</div>
<div style="flex: 1; display: flex; flex-direction: column; gap: 4px;">
<label style="font-size: 10px; font-weight: 600; color: var(--text-secondary); text-transform: uppercase;">Prompt Preset</label>
<select id="ai-preset-select" style="width: 100%; min-width: 100%;"></select>
</div>
</div>
<textarea id="ai-prompt-text" style="flex: 1; width: 100%; resize: none; margin-bottom: 12px; background: var(--bg-primary); border: 1px solid var(--border-color);"></textarea>
<div style="display: flex; justify-content: space-between; align-items: flex-end; padding-top: 12px; border-top: 1px solid var(--border-color); gap: 6px; flex-wrap: wrap;">
<div style="display: flex; flex-direction: column; gap: 4px; width: 180px; min-width: 140px;">
<label style="font-size: 10px; font-weight: 600; color: var(--text-secondary); text-transform: uppercase;">Engine</label>
<select id="ai-provider-select" style="width: 100%; min-width: 100%;">
${AI_PROVIDERS.map(p => `<option value="${p.id}"${p.id === lastProvider ? " selected" : ""}>${p.label}</option>`).join("")}
</select>
</div>
<div class="lfm-modal-actions" style="margin-top: 0; display: flex; gap: 6px; flex-wrap: wrap; align-items: stretch;">
<button id="ai-copy-btn" style="padding: 4px 10px; font-size: 11px; border-radius: 4px; margin: 0; background: var(--bg-secondary); color: var(--text-primary); border: 1px solid var(--border-color); cursor: pointer; font-weight: 600; display: inline-flex; align-items: center; gap: 5px; white-space: nowrap; transition: border-color .2s; min-width: max-content;"><i class="fa-solid fa-copy"></i> Copy</button>
<button id="ai-open-btn" style="padding: 4px 10px; font-size: 11px; border-radius: 4px; margin: 0; background: var(--bg-secondary); color: var(--text-primary); border: 1px solid var(--border-color); cursor: pointer; font-weight: 600; display: inline-flex; align-items: center; gap: 5px; white-space: nowrap; transition: border-color .2s; min-width: max-content;"><i class="fa-solid fa-arrow-up-right-from-square"></i> Open</button>
<button id="ai-copy-open-btn" style="padding: 4px 10px; font-size: 11px; border-radius: 4px; margin: 0; background: rgba(var(--brand-rgb), .10); color: var(--text-primary); border: 1px solid var(--brand); cursor: pointer; font-weight: 700; display: inline-flex; align-items: center; gap: 5px; white-space: nowrap; transition: border-color .2s; min-width: max-content;"><i class="fa-solid fa-copy"></i> Copy & Open</button>
</div>
</div>
</div>
`;
document.body.appendChild(overlay);
function updatePresets() {
const catKey = document.getElementById("ai-category-select").value;
const category = AI_CATEGORIES[catKey];
if (!category) return;
const select = document.getElementById("ai-preset-select");
select.innerHTML = category.items.map((item, i) => {
const ctxType = ctx.track ? "track" : (ctx.album ? "album" : "artist");
const override = localStorage.getItem(`setting-ai-preset-override-${catKey}-${i}-${ctxType}`);
let full;
if (override !== null) {
full = override
.replace(/\{artist\}/g, ctx.artist || "")
.replace(/\{album\}/g, ctx.album || "")
.replace(/\{track\}/g, ctx.track || "");
} else {
const suffix = localStorage.getItem("setting-ai-custom-suffix") || "";
const suffixStr = suffix ? "\n\n" + suffix : "";
full = item.prompt(ctx) + suffixStr;
}
return `<option value="${i}" data-prompt="${escHtml(full)}">${item.label}${override !== null ? " ✎" : ""}</option>`;
}).join("");
const first = select.options[0];
if (first) document.getElementById("ai-prompt-text").value = first.dataset.prompt;
}
document.getElementById("ai-category-select").addEventListener("change", updatePresets);
document.getElementById("ai-preset-select").addEventListener("change", function () {
const opt = this.options[this.selectedIndex];
if (opt) document.getElementById("ai-prompt-text").value = opt.dataset.prompt;
});
if (ctx.artist) {
updatePresets();
}
document.getElementById("ai-copy-btn").addEventListener("click", () => {
const text = document.getElementById("ai-prompt-text").value;
if (text) {
navigator.clipboard.writeText(text).then(() => {
const btn = document.getElementById("ai-copy-btn");
const orig = btn.innerHTML;
btn.innerHTML = '<i class="fa-solid fa-check"></i> Copied!';
setTimeout(() => { btn.innerHTML = orig; }, 2000);
});
}
});
document.getElementById("ai-open-btn").addEventListener("click", () => {
const text = document.getElementById("ai-prompt-text").value;
const providerId = document.getElementById("ai-provider-select").value;
if (!text) return;
localStorage.setItem("lfm-last-ai-provider", providerId);
const provider = AI_PROVIDERS.find(p => p.id === providerId);
const isAutoCopy = localStorage.getItem("setting-ai-autocopy") === "true";
if (isAutoCopy) {
navigator.clipboard.writeText(text).then(() => {
if (provider) openAIUrl(provider.url(text));
});
} else {
if (provider) openAIUrl(provider.url(text));
}
});
document.getElementById("ai-copy-open-btn").addEventListener("click", () => {
const text = document.getElementById("ai-prompt-text").value;
const providerId = document.getElementById("ai-provider-select").value;
if (!text) return;
localStorage.setItem("lfm-last-ai-provider", providerId);
navigator.clipboard.writeText(text).then(() => {
const provider = AI_PROVIDERS.find(p => p.id === providerId);
if (provider) openAIUrl(provider.url(text));
});
});
overlay.addEventListener("click", e => {
if (e.target === overlay) overlay.remove();
});
document.getElementById("lfm-modal-close").addEventListener("click", () => overlay.remove());
document.getElementById("ai-modal-settings-btn").addEventListener("click", () => {
overlay.remove();
openSettingsModal("ai");
});
}
function setupKeyboardShortcut() {
document.addEventListener("keydown", e => {
if (e.ctrlKey && e.shiftKey && e.key.toLowerCase() === "e") {
e.preventDefault();
const menu = document.getElementById("external-music-menu");
if (menu.classList.contains("visible")) {
menu.classList.remove("visible");
} else {
showPopupWithContext();
}
}
if (e.key === "Escape") {
const overlay = document.getElementById("lfm-modal-overlay");
if (overlay) { overlay.remove(); e.preventDefault(); return; }
const menu = document.getElementById("external-music-menu");
if (menu?.classList.contains("visible")) {
menu.classList.remove("visible");
e.preventDefault();
}
}
});
}
function setupNavigationDetection() {
const navObserver = new MutationObserver(() => {
if (location.href !== lastUrl) {
lastUrl = location.href;
document.getElementById("external-music-menu")?.classList.remove("visible");
requestAnimationFrame(() => {
addContextMenu(document);
addChartlistContextEntries(document);
});
}
});
navObserver.observe(document.querySelector("title") || document.head, {
childList: true,
subtree: true,
});
}
const iconStyle = localStorage.getItem("setting-icon-style") || "bubble";
document.documentElement.classList.toggle("lfm-icon-style-minimal", iconStyle === "minimal");
document.documentElement.classList.toggle("lfm-icons-hidden", iconStyle === "hidden");
if (localStorage.getItem("setting-highlight-eligible") === "true") {
document.documentElement.classList.add("lfm-highlight-eligible");
}
if (localStorage.getItem("setting-light-mode") === "true") {
document.documentElement.classList.add("lfm-light-mode");
}
// Parse individual category Open All button visibility (Default is hidden/false)
CATEGORY_ORDER.forEach(cat => {
const isOpenAll = localStorage.getItem("setting-openall-" + cat) === "true";
const menuLink = document.querySelector(`.open-all-link[data-category="${cat}"]`);
if (menuLink) {
menuLink.style.display = isOpenAll ? "block" : "none";
}
});
// Parse individual service hidden states on load sequence
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
if (key.startsWith("setting-svc-hidden-") && localStorage.getItem(key) === "true") {
const svcId = key.replace("setting-svc-hidden-", "");
document.querySelector(`.service-row:has(#${svcId})`)?.classList.add("lfm-service-hidden");
}
}
applyTogglePosition();
setupUI();
setupKeyboardShortcut();
setupNavigationDetection();
addContextMenu(document);
addChartlistContextEntries(document);
// Debounced execution to prevent main-thread blocking
let debounceTimer;
const observer = new MutationObserver(mutations => {
let shouldUpdate = false;
mutations.forEach(mutation => {
if (mutation.addedNodes.length > 0) shouldUpdate = true;
});
if (shouldUpdate) {
clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => {
addContextMenu(document);
addChartlistContextEntries(document);
}, 250);
}
});
observer.observe(document.body, {
childList: true,
subtree: true,
});
document.addEventListener("visibilitychange", () => {
if (!document.hidden) {
addContextMenu(document);
addChartlistContextEntries(document);
}
});
})();