// ==UserScript==
// @name Torn Pickpocketing Target Filter
// @namespace http://tampermonkey.net/
// @version 2.9.10
// @description Highlights targets with time-based colors. Greys out/disables others. Collapsible boxes + Master/6x audio toggles (positioned above, side-by-side). Robust selectors. Precise highlight removal. Complete Code.
// @author Elaine [2047176]
// @match https://www.torn.com/loader.php?sid=crimes*
// @require https://cdnjs.cloudflare.com/ajax/libs/tone/14.8.49/Tone.min.js
// @grant GM_setValue
// @grant GM_getValue
// @grant GM_addStyle
// @grant unsafeWindow
// @run-at document-idle
// @license MIT
// ==/UserScript==
(function() {
'use strict';
// --- Configuration ---
const SCRIPT_PREFIX = "PickpocketFilter";
const WIKI_TARGET_LIST = [ // Original casing for display
'Businessman', 'Businesswoman', 'Classy Lady', 'Cyclist', 'Drunk Man',
'Drunk Woman', 'Elderly Man', 'Elderly Woman', 'Gang Member', 'Homeless Person',
'Jogger', 'Junkie', 'Laborer', 'Mobster', 'Police Officer', 'Postal Worker',
'Rich Kid', 'Sex Worker', 'Student', 'Thug', 'Young Man', 'Young Woman'
];
const WIKI_TARGET_LIST_LC = WIKI_TARGET_LIST.map(t => t.toLowerCase()); // Lowercase for keys
const STORAGE_KEY_FILTERS = 'pickpocketingFilterState_v1_lc';
const STORAGE_KEY_COLLAPSED = 'pickpocketingFilterCollapsedState_v1';
const HIGHLIGHT_CLASS = 'kw-target-highlighted';
const FILTERED_OUT_CLASS = 'kw-target-filtered-out';
const CONTROL_BOX_ID = 'pickpocket-filter-box-Gemini';
const COLLAPSED_CLASS = 'kw-collapsed';
const COLLAPSED_BOX_HEIGHT = '38px'; // Define collapsed height
const AUDIO_ALERT_BOX_ID = 'pickpocket-audio-alert-box-Gemini';
const STORAGE_KEY_AUDIO_ALERTS = 'pickpocketingAudioAlertState_v1';
const STORAGE_KEY_AUDIO_COLLAPSED = 'pickpocketingAudioCollapsedState_v1';
const MASTER_AUDIO_BOX_ID = 'pickpocket-master-audio-box-Gemini';
const MASTER_AUDIO_CHECKBOX_ID = 'kw-master-audio-enable';
const SIXFOLD_AUDIO_BOX_ID = 'pickpocket-sixfold-audio-box-Gemini';
const SIXFOLD_AUDIO_CHECKBOX_ID = 'kw-sixfold-audio-enable';
const STORAGE_KEY_SIXFOLD_AUDIO = 'pickpocketingSixfoldAudioState_v1';
const MULTIPLE_AUDIO_DELAY = 0.18; // Delay between multiple sounds in seconds (used for 6x)
// --- Robust Selectors ---
const SEL_CRIME_ROOT = 'div[class*="crime-root"][class*="pickpocketing-root"]';
const SEL_CURRENT_CRIME_CONTAINER = 'div[class*="currentCrime"]'; // Container for the target list
const SEL_TARGET_LIST_CONTAINER = 'div[class*="virtualList"]';
const SEL_TARGET_ITEM = 'div[class*="virtualItem"]';
const SEL_TARGET_ITEM_WRAPPER = 'div[class*="crimeOptionWrapper"]';
const SEL_TARGET_OPTION_DIV = 'div[class*="crimeOption___"]'; // The div holding crime info inside wrapper
const SEL_TARGET_MAIN_SECTION = 'div[class*="mainSection"]';
const SEL_TARGET_TITLE_PROPS = 'div[class*="titleAndProps"]';
const SEL_TARGET_TYPE_DIV = ':scope > div:first-child';
const SEL_COMMIT_BUTTON = 'button[class*="commit-button"]';
const SEL_ACTIVITY_DIV = 'div[class*="activity"]';
const SEL_TIMER_CLOCK = 'div[class*="clock"]';
const SEL_LOCKED_ITEM_MARKER = '[class*="locked___"]'; // Class indicating the item is locked/expired
// --- Color Config ---
const COLOR_GREEN = { r: 50, g: 180, b: 50 };
const COLOR_ORANGE = { r: 255, g: 165, b: 0 };
const COLOR_RED = { r: 200, g: 0, b: 0 };
const HIGHLIGHT_OPACITY = 0.4;
const BORDER_OPACITY = 0.9;
const SHADOW_OPACITY = 0.7;
const URGENCY_THRESHOLD = 10; // Seconds
// --- State ---
let filterState = {};
let targetListContainer = null;
let controlBoxElement = null;
let crimeListObserver = null;
let pageLoadObserver = null;
let crimeRootElement = null;
let isInitialized = false;
let isInitializing = false;
let highlightUpdateIntervalId = null;
let audioAlertState = {};
let audioAlertBoxElement = null;
let synth; // Tone.js synthesizer instance
let toneStarted = false; // Flag to check if Tone.js context is started
let masterAudioEnabled = false; // Master switch for all audio alerts
let masterAudioBoxElement = null;
let sixfoldAudioEnabled = false; // Flag for playing sound 6 times
let sixfoldAudioBoxElement = null;
console.log(`${SCRIPT_PREFIX}: Script loaded (v2.9.10).`); // Version updated
// --- Styles ---
GM_addStyle(`
/* Keep target list container as default block */
${SEL_CRIME_ROOT} > ${SEL_CURRENT_CRIME_CONTAINER} {
display: block;
min-width: 0;
}
/* Wrapper for control boxes */
#kw-control-boxes-wrapper {
display: flex;
flex-direction: row;
gap: 10px; /* Reduced gap */
margin-bottom: 15px; /* Add space below the boxes */
align-items: flex-start; /* Align boxes to the top */
flex-wrap: wrap; /* Allow boxes to wrap on very narrow screens */
position: relative; /* Needed for z-index context if children use it */
z-index: 100; /* Ensure wrapper is generally above crime content */
}
/* Shared styles for control boxes */
.kw-control-box {
border: 1px solid #555; background-color: #2e2e2e; color: #ccc;
border-radius: 5px;
box-sizing: border-box; transition: max-height 0.3s ease-out, background-color 0.3s ease-out;
overflow: visible; /* Allow absolute content to overflow */
/* max-height: 600px; */ /* Max height now controlled by content */
width: 165px; /* Reduced width */
flex-shrink: 0; /* Prevent boxes from shrinking */
position: relative; /* Crucial for absolute positioning of content */
}
/* Collapsible box header styles */
.kw-control-box .kw-filter-header {
display: flex; justify-content: space-between; align-items: center;
padding: 8px 10px;
cursor: pointer; background-color: #3a3a3a;
/* border-bottom: 1px solid #555; */ /* Border moved to content */
transition: background-color 0.2s ease;
height: ${COLLAPSED_BOX_HEIGHT};
box-sizing: border-box;
position: relative; /* Keep header in flow */
z-index: 1; /* Header above content */
border-radius: 5px; /* Round corners when collapsed */
}
.kw-control-box .kw-filter-header:hover { background-color: #454545; }
.kw-control-box .kw-filter-header h5 { margin: 0; color: #eee; font-size: 1.0em; font-weight: bold; }
.kw-control-box .kw-filter-header .kw-collapse-indicator { font-size: 0.8em; margin-left: 5px; color: #aaa; }
/* Content area styles - ABSOLUTE POSITIONING */
.kw-control-box .kw-filter-content {
position: absolute;
top: ${COLLAPSED_BOX_HEIGHT}; /* Position below the header */
left: 0;
width: 100%; /* Match parent box width */
z-index: 50; /* Sit above page content below */
background-color: #2e2e2e; /* Match box background */
border: 1px solid #555;
border-top: none; /* Avoid double border with header */
border-radius: 0 0 5px 5px; /* Round bottom corners */
box-shadow: 0 4px 8px rgba(0,0,0,0.3); /* Add shadow for overlay effect */
padding: 8px 10px;
max-height: 450px; /* Still allow scroll */
overflow-y: auto;
scrollbar-width: thin; scrollbar-color: #666 #333;
box-sizing: border-box;
/* transition: padding 0.3s ease-out; */ /* Transition might look weird with absolute */
display: block; /* Ensure it's block */
}
.kw-control-box .kw-filter-content::-webkit-scrollbar { width: 8px; }
.kw-control-box .kw-filter-content::-webkit-scrollbar-track { background: #333; border-radius: 4px; }
.kw-control-box .kw-filter-content::-webkit-scrollbar-thumb { background-color: #666; border-radius: 4px; border: 2px solid #333; }
/* Collapsed state styles */
.kw-control-box.${COLLAPSED_CLASS} {
max-height: ${COLLAPSED_BOX_HEIGHT}; /* Limit height of container */
overflow: hidden; /* Hide the absolute content when container shrinks */
background-color: #3a3a3a;
}
.kw-control-box.${COLLAPSED_CLASS} .kw-filter-header {
border-bottom-color: #3a3a3a; /* Match background when collapsed */
}
/* Hide content using display:none still works and is efficient */
.kw-control-box.${COLLAPSED_CLASS} .kw-filter-content {
display: none;
}
/* Label/Checkbox styles */
.kw-control-box label {
display: flex;
align-items: center;
/* height: 100%; */ /* Removed fixed height for labels inside scrolling content */
margin-bottom: 6px; cursor: pointer; padding: 3px 5px;
border-radius: 3px; transition: background-color 0.2s ease;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis; font-size: 0.9em;
}
.kw-control-box label:hover { background-color: #484848; }
.kw-control-box input[type="checkbox"] { margin-right: 6px; vertical-align: middle; transform: scale(0.85); }
/* Specific ID for filter box */
#${CONTROL_BOX_ID} { /* Inherits .kw-control-box styles */ }
/* Specific ID and styles for audio alert box */
#${AUDIO_ALERT_BOX_ID} { /* Inherits .kw-control-box styles */ }
#${AUDIO_ALERT_BOX_ID} .kw-filter-content {
max-height: 300px; /* Potentially shorter list */
}
#${AUDIO_ALERT_BOX_ID} .kw-italic-placeholder {
font-style: italic;
color: #888;
padding: 5px;
height: auto; /* Override label height */
display: block; /* Override label display */
}
#${AUDIO_ALERT_BOX_ID} .kw-filter-content li { height: auto; } /* Override li height if needed */
#${AUDIO_ALERT_BOX_ID} .kw-filter-content label { height: auto; } /* Override label height */
/* Apply non-collapsible style */
#${MASTER_AUDIO_BOX_ID}, #${SIXFOLD_AUDIO_BOX_ID} { /* Updated ID */
/* Inherits .kw-control-box styles like border, bg, width, etc. */
/* Apply non-collapsible fixed height and centering */
max-height: ${COLLAPSED_BOX_HEIGHT} !important;
height: ${COLLAPSED_BOX_HEIGHT} !important;
padding: 0 10px !important; /* Adjusted padding */
display: flex !important;
align-items: center !important;
overflow: hidden; /* Ensure content doesn't overflow fixed height */
}
#${MASTER_AUDIO_BOX_ID} label, #${SIXFOLD_AUDIO_BOX_ID} label { /* Updated ID */
margin-bottom: 0 !important;
padding: 0 5px !important;
height: auto !important; /* Let height be natural */
flex-grow: 1; /* Allow label to take space */
}
/* Highlighted Targets Styling - Uses CSS Variables */
.${HIGHLIGHT_CLASS} > ${SEL_TARGET_ITEM_WRAPPER} > ${SEL_TARGET_OPTION_DIV} {
--highlight-color-start-r: ${COLOR_ORANGE.r}; --highlight-color-start-g: ${COLOR_ORANGE.g}; --highlight-color-start-b: ${COLOR_ORANGE.b};
--highlight-color-end-r: ${COLOR_RED.r}; --highlight-color-end-g: ${COLOR_RED.g}; --highlight-color-end-b: ${COLOR_RED.b};
--highlight-border-r: ${COLOR_ORANGE.r}; --highlight-border-g: ${COLOR_ORANGE.g}; --highlight-border-b: ${COLOR_ORANGE.b};
--highlight-shadow-r: ${COLOR_ORANGE.r}; --highlight-shadow-g: ${COLOR_ORANGE.g}; --highlight-shadow-b: ${COLOR_ORANGE.b};
background: linear-gradient(45deg,
rgba(var(--highlight-color-start-r), var(--highlight-color-start-g), var(--highlight-color-start-b), ${HIGHLIGHT_OPACITY}),
rgba(var(--highlight-color-end-r), var(--highlight-color-end-g), var(--highlight-color-end-b), ${HIGHLIGHT_OPACITY})
) !important;
border: 1px dashed rgba(var(--highlight-border-r), var(--highlight-border-g), var(--highlight-border-b), ${BORDER_OPACITY}) !important;
box-shadow: 0 0 8px rgba(var(--highlight-shadow-r), var(--highlight-shadow-g), var(--highlight-shadow-b), ${SHADOW_OPACITY}) !important;
border-radius: 4px;
transition: background 0.5s linear, border-color 0.5s linear, box-shadow 0.5s linear;
}
/* Filtered Out Targets Styling */
.${FILTERED_OUT_CLASS} {
opacity: 0.55; filter: grayscale(60%);
transition: opacity 0.3s ease, filter 0.3s ease;
}
.${FILTERED_OUT_CLASS}.${HIGHLIGHT_CLASS} { opacity: 1; filter: none; }
.${FILTERED_OUT_CLASS} ${SEL_COMMIT_BUTTON} { cursor: not-allowed !important; filter: grayscale(80%); }
.${HIGHLIGHT_CLASS} ${SEL_COMMIT_BUTTON} { cursor: pointer !important; filter: none; }
`);
// --- Storage Functions ---
/**
* Loads filter state from GM storage, ensuring lowercase keys.
* Defaults to all true if no state found.
*/
async function loadFilters() {
const savedState = await GM_getValue(STORAGE_KEY_FILTERS, null);
let newState = {};
if (savedState && typeof savedState === 'object') {
WIKI_TARGET_LIST_LC.forEach(lcTarget => {
let foundValue = true; // Default if not found
for (const savedKey in savedState) {
if (savedKey.toLowerCase() === lcTarget) {
foundValue = savedState[savedKey];
break;
}
}
newState[lcTarget] = foundValue;
});
} else {
console.log(`${SCRIPT_PREFIX}: No saved filters found. Defaulting all to checked.`);
WIKI_TARGET_LIST_LC.forEach(lcTarget => newState[lcTarget] = true);
}
filterState = newState;
await saveFilters(); // Save potentially migrated/defaulted state
}
/**
* Saves the current filter state (with lowercase keys) to GM storage.
*/
async function saveFilters() {
await GM_setValue(STORAGE_KEY_FILTERS, filterState);
}
/**
* Loads the collapsed state of the filter box. Defaults to false (expanded).
* @returns {Promise<boolean>} True if collapsed, false otherwise.
*/
async function loadCollapsedState() {
return await GM_getValue(STORAGE_KEY_COLLAPSED, false); // Default to expanded (false)
}
/**
* Saves the collapsed state of the filter box.
* @param {boolean} isCollapsed - True if the box is collapsed.
*/
async function saveCollapsedState(isCollapsed) {
await GM_setValue(STORAGE_KEY_COLLAPSED, isCollapsed);
}
/**
* Loads the audio alert state from GM storage. Defaults to all false.
*/
async function loadAudioAlertState() {
const savedState = await GM_getValue(STORAGE_KEY_AUDIO_ALERTS, null);
let newState = {};
if (savedState && typeof savedState === 'object') {
// Load saved state, ensuring keys are lowercase
WIKI_TARGET_LIST_LC.forEach(lcTarget => {
let foundValue = false; // Default to false (off)
for (const savedKey in savedState) {
if (savedKey.toLowerCase() === lcTarget) {
foundValue = savedState[savedKey];
break;
}
}
newState[lcTarget] = foundValue;
});
} else {
// Default all to false if nothing saved
WIKI_TARGET_LIST_LC.forEach(lcTarget => newState[lcTarget] = false);
}
audioAlertState = newState;
// No need to save defaults immediately unless required
}
/**
* Saves the current audio alert state to GM storage.
*/
async function saveAudioAlertState() {
await GM_setValue(STORAGE_KEY_AUDIO_ALERTS, audioAlertState);
}
/**
* Loads the collapsed state of the audio alert box. Defaults to true (collapsed).
* @returns {Promise<boolean>} True if collapsed, false otherwise.
*/
async function loadAudioCollapsedState() {
return await GM_getValue(STORAGE_KEY_AUDIO_COLLAPSED, true); // Default to collapsed (true)
}
/**
* Saves the collapsed state of the audio alert box.
* @param {boolean} isCollapsed - True if the box is collapsed.
*/
async function saveAudioCollapsedState(isCollapsed) {
await GM_setValue(STORAGE_KEY_AUDIO_COLLAPSED, isCollapsed);
}
/**
* Loads the sixfold audio state from GM storage. Defaults to false.
*/
async function loadSixfoldAudioState() {
sixfoldAudioEnabled = await GM_getValue(STORAGE_KEY_SIXFOLD_AUDIO, false); // Default to false
}
/**
* Saves the current sixfold audio state to GM storage.
*/
async function saveSixfoldAudioState() {
await GM_setValue(STORAGE_KEY_SIXFOLD_AUDIO, sixfoldAudioEnabled);
}
// --- UI Creation ---
/**
* Creates the filter control box element or returns it if it already exists.
* Applies the saved collapsed state.
* @returns {Promise<HTMLElement|null>} The control box element or null if creation fails.
*/
async function createControlBox() {
if (document.getElementById(CONTROL_BOX_ID)) {
controlBoxElement = document.getElementById(CONTROL_BOX_ID);
updateControlBoxCheckboxes(); // Update checkboxes with current filter state
const isCollapsed = await loadCollapsedState();
controlBoxElement.classList.toggle(COLLAPSED_CLASS, isCollapsed);
const indicator = controlBoxElement.querySelector('.kw-collapse-indicator');
if (indicator) indicator.textContent = isCollapsed ? '►' : '▼';
attachHeaderListener(controlBoxElement, saveCollapsedState); // Ensure listener is attached
return controlBoxElement;
}
console.log(`${SCRIPT_PREFIX}: Creating filter control box UI.`);
controlBoxElement = document.createElement('div');
controlBoxElement.id = CONTROL_BOX_ID;
controlBoxElement.className = 'kw-control-box'; // Use shared class
// Header
const header = document.createElement('div');
header.className = 'kw-filter-header';
const indicatorSpan = document.createElement('span');
indicatorSpan.className = 'kw-collapse-indicator';
header.innerHTML = `<h5>Filter Targets</h5>`;
header.appendChild(indicatorSpan);
// Content (Checkboxes)
const content = document.createElement('div');
content.className = 'kw-filter-content';
WIKI_TARGET_LIST.sort((a, b) => a.localeCompare(b)).forEach(target => {
const lcTarget = target.toLowerCase();
const label = document.createElement('label');
label.title = target;
const checkbox = document.createElement('input');
checkbox.type = 'checkbox';
checkbox.value = lcTarget;
checkbox.checked = filterState[lcTarget] ?? true;
checkbox.dataset.targetType = lcTarget;
checkbox.addEventListener('change', async (event) => { // Make async
filterState[event.target.value] = event.target.checked;
await saveFilters(); // Wait for save
processTargets(); // Trigger processing immediately on filter change
updateAudioAlertList(); // Update audio list when filter changes
});
label.appendChild(checkbox);
label.appendChild(document.createTextNode(` ${target}`)); // Display original case
content.appendChild(label);
});
controlBoxElement.appendChild(header);
controlBoxElement.appendChild(content);
// Apply initial collapse state
const isCollapsed = await loadCollapsedState();
controlBoxElement.classList.toggle(COLLAPSED_CLASS, isCollapsed);
indicatorSpan.textContent = isCollapsed ? '►' : '▼'; // Set initial indicator text
attachHeaderListener(controlBoxElement, saveCollapsedState); // Attach listener after elements are created
return controlBoxElement;
}
/**
* Creates the audio alert control box element or returns it if it already exists.
* Applies the saved collapsed state.
* @returns {Promise<HTMLElement|null>} The audio alert box element or null if creation fails.
*/
async function createAudioAlertBox() {
if (document.getElementById(AUDIO_ALERT_BOX_ID)) {
audioAlertBoxElement = document.getElementById(AUDIO_ALERT_BOX_ID);
await updateAudioAlertList(); // Update content based on current filter/audio state
const isCollapsed = await loadAudioCollapsedState();
audioAlertBoxElement.classList.toggle(COLLAPSED_CLASS, isCollapsed);
const indicator = audioAlertBoxElement.querySelector('.kw-collapse-indicator');
if (indicator) indicator.textContent = isCollapsed ? '►' : '▼';
attachHeaderListener(audioAlertBoxElement, saveAudioCollapsedState); // Ensure listener is attached
return audioAlertBoxElement;
}
console.log(`${SCRIPT_PREFIX}: Creating audio alert control box UI.`);
audioAlertBoxElement = document.createElement('div');
audioAlertBoxElement.id = AUDIO_ALERT_BOX_ID;
audioAlertBoxElement.className = 'kw-control-box'; // Use shared class
// Header
const header = document.createElement('div');
header.className = 'kw-filter-header';
const indicatorSpan = document.createElement('span');
indicatorSpan.className = 'kw-collapse-indicator';
header.innerHTML = `<h5>Audio Alert</h5>`;
header.appendChild(indicatorSpan);
// Content (Checkboxes for filtered items)
const content = document.createElement('div');
content.className = 'kw-filter-content';
content.innerHTML = `<ul id="kw-audio-alert-list" style="list-style: none; padding: 0; margin: 0;"></ul>`; // Add UL container
audioAlertBoxElement.appendChild(header);
audioAlertBoxElement.appendChild(content);
// Apply initial collapse state
const isCollapsed = await loadAudioCollapsedState();
audioAlertBoxElement.classList.toggle(COLLAPSED_CLASS, isCollapsed);
indicatorSpan.textContent = isCollapsed ? '►' : '▼';
attachHeaderListener(audioAlertBoxElement, saveAudioCollapsedState); // Attach listener
// Initial population of the list
await updateAudioAlertList();
return audioAlertBoxElement;
}
/**
* Updates the 'Audio Alert' list based on the *active* filters.
*/
async function updateAudioAlertList() {
if (!audioAlertBoxElement) {
return;
}
const listElement = audioAlertBoxElement.querySelector('#kw-audio-alert-list');
if (!listElement) {
return;
}
listElement.innerHTML = ''; // Clear existing list
let hasActiveFilters = false;
// Use original casing list for display, lowercase for keys
const sortedTargets = [...WIKI_TARGET_LIST].sort((a, b) => a.localeCompare(b));
sortedTargets.forEach(target => {
const lcTarget = target.toLowerCase();
// Only add victims that are CHECKED in the main filter list
if (filterState[lcTarget]) {
hasActiveFilters = true;
const li = document.createElement('li');
const label = document.createElement('label');
label.title = `Enable audio alert for ${target}`;
const checkbox = document.createElement('input');
checkbox.type = 'checkbox';
const checkboxId = `kw-audio-alert-${lcTarget}`; // Unique ID
checkbox.id = checkboxId;
checkbox.value = lcTarget;
// Set checked based on loaded/saved audio alert state
checkbox.checked = audioAlertState[lcTarget] || false;
checkbox.addEventListener('change', handleAudioAlertChange); // Add event listener
label.htmlFor = checkboxId;
label.appendChild(checkbox);
label.appendChild(document.createTextNode(` ${target}`)); // Display original case
li.appendChild(label);
listElement.appendChild(li);
}
});
if (!hasActiveFilters) {
listElement.innerHTML = '<li class="kw-italic-placeholder">No targets filtered.</li>';
}
}
/**
* Creates the master audio control box.
* @returns {HTMLElement|null} The master audio box element or null if creation fails.
*/
function createMasterAudioBox() {
if (document.getElementById(MASTER_AUDIO_BOX_ID)) {
masterAudioBoxElement = document.getElementById(MASTER_AUDIO_BOX_ID);
// Update checkbox state (although it defaults to false on load)
const checkbox = masterAudioBoxElement.querySelector(`#${MASTER_AUDIO_CHECKBOX_ID}`);
if (checkbox) checkbox.checked = masterAudioEnabled;
attachMasterAudioListener(); // Ensure listener attached
return masterAudioBoxElement;
}
console.log(`${SCRIPT_PREFIX}: Creating master audio control box UI.`);
masterAudioBoxElement = document.createElement('div');
masterAudioBoxElement.id = MASTER_AUDIO_BOX_ID;
masterAudioBoxElement.className = 'kw-control-box'; // Base style
const label = document.createElement('label');
label.htmlFor = MASTER_AUDIO_CHECKBOX_ID;
label.title = "Enable/Disable all audio alerts for this session";
const checkbox = document.createElement('input');
checkbox.type = 'checkbox';
checkbox.id = MASTER_AUDIO_CHECKBOX_ID;
checkbox.checked = masterAudioEnabled; // Should be false initially
label.appendChild(checkbox);
label.appendChild(document.createTextNode(' Enable Audio'));
masterAudioBoxElement.appendChild(label);
attachMasterAudioListener(); // Attach listener
return masterAudioBoxElement;
}
/** Attaches listener to the master audio checkbox */
function attachMasterAudioListener() {
if (!masterAudioBoxElement) return;
const checkbox = masterAudioBoxElement.querySelector(`#${MASTER_AUDIO_CHECKBOX_ID}`);
if (checkbox && !checkbox.dataset.listenerAttached) {
checkbox.addEventListener('change', handleMasterAudioChange);
checkbox.dataset.listenerAttached = 'true';
}
}
/**
* Creates the sixfold audio control box.
* @returns {HTMLElement|null} The sixfold audio box element or null if creation fails.
*/
function createSixfoldAudioBox() { // Renamed function
if (document.getElementById(SIXFOLD_AUDIO_BOX_ID)) {
sixfoldAudioBoxElement = document.getElementById(SIXFOLD_AUDIO_BOX_ID);
// Update checkbox state from loaded value
const checkbox = sixfoldAudioBoxElement.querySelector(`#${SIXFOLD_AUDIO_CHECKBOX_ID}`);
if (checkbox) checkbox.checked = sixfoldAudioEnabled;
attachSixfoldAudioListener(); // Ensure listener attached
return sixfoldAudioBoxElement;
}
console.log(`${SCRIPT_PREFIX}: Creating 6x audio control box UI.`); // Updated log
sixfoldAudioBoxElement = document.createElement('div');
sixfoldAudioBoxElement.id = SIXFOLD_AUDIO_BOX_ID; // Updated ID
sixfoldAudioBoxElement.className = 'kw-control-box'; // Base style
const label = document.createElement('label');
label.htmlFor = SIXFOLD_AUDIO_CHECKBOX_ID; // Updated ID
label.title = "Play audio alert 6 times instead of once"; // Updated title
const checkbox = document.createElement('input');
checkbox.type = 'checkbox';
checkbox.id = SIXFOLD_AUDIO_CHECKBOX_ID; // Updated ID
checkbox.checked = sixfoldAudioEnabled; // Use loaded state
label.appendChild(checkbox);
label.appendChild(document.createTextNode(' 6x Audio')); // Updated text
sixfoldAudioBoxElement.appendChild(label);
attachSixfoldAudioListener(); // Attach listener
return sixfoldAudioBoxElement;
}
/** Attaches listener to the sixfold audio checkbox */
function attachSixfoldAudioListener() { // Renamed function
if (!sixfoldAudioBoxElement) return;
const checkbox = sixfoldAudioBoxElement.querySelector(`#${SIXFOLD_AUDIO_CHECKBOX_ID}`);
if (checkbox && !checkbox.dataset.listenerAttached) {
checkbox.addEventListener('change', handleSixfoldAudioChange); // Renamed handler
checkbox.dataset.listenerAttached = 'true';
}
}
/**
* Attaches the click listener to a collapsible control box header.
* @param {HTMLElement} boxElement - The control box element (filter or audio).
* @param {Function} saveStateFunction - The function to call to save the collapsed state.
*/
function attachHeaderListener(boxElement, saveStateFunction) {
if (!boxElement || !boxElement.classList.contains('kw-control-box')) return; // Ensure it's a control box
const header = boxElement.querySelector('.kw-filter-header');
if (!header) return; // Only attach to boxes with headers (i.e., collapsible ones)
// Check if listener already attached to prevent duplicates
if (!header.dataset.listenerAttached) {
header.addEventListener('click', () => {
// Removed startTone() call from here
const isNowCollapsed = boxElement.classList.toggle(COLLAPSED_CLASS);
const indicator = header.querySelector('.kw-collapse-indicator');
if (indicator) {
indicator.textContent = isNowCollapsed ? '►' : '▼';
}
saveStateFunction(isNowCollapsed); // Save the new state using the provided function
});
header.dataset.listenerAttached = 'true'; // Mark listener as attached
}
}
// --- Time & Color Utilities ---
/**
* Parses a time string (e.g., "1m 5s", "30s", "0s") into seconds.
* @param {string} timeString - The time string from the target element.
* @returns {number|null} Total seconds, or null if parsing fails.
*/
function parseTimeToSeconds(timeString) {
if (!timeString || typeof timeString !== 'string') return null;
timeString = timeString.trim().toLowerCase();
if (timeString === '0s' || timeString === '') return 0;
let totalSeconds = 0;
const minuteMatch = timeString.match(/(\d+)\s*m/);
const secondMatch = timeString.match(/(\d+)\s*s/);
if (minuteMatch) { totalSeconds += parseInt(minuteMatch[1], 10) * 60; }
if (secondMatch) { totalSeconds += parseInt(secondMatch[1], 10); }
else if (!minuteMatch && /^\d+$/.test(timeString)) { totalSeconds = parseInt(timeString, 10); } // Handle plain number as seconds
else if (!secondMatch && timeString === 's') { return 0; } // Handle "s" alone
else if (!minuteMatch && !secondMatch) { return null; } // Invalid format
return totalSeconds;
}
/**
* Gets the remaining time in seconds for a target item element.
* @param {HTMLElement} itemElement - The target item element (div[class*="virtualItem"]).
* @returns {number|null} Remaining seconds, 0 if hidden/expired, null if not found.
*/
function getTargetTimeRemaining(itemElement) {
const activityDiv = itemElement.querySelector(SEL_ACTIVITY_DIV);
const clockElement = activityDiv ? activityDiv.querySelector(SEL_TIMER_CLOCK) : null;
// Check if clock element exists and is not hidden
if (!clockElement || clockElement.classList.contains('hidden___UI9Im') || clockElement.textContent === '') {
return 0; // Treat hidden or empty clock as 0 seconds
}
return parseTimeToSeconds(clockElement.textContent);
}
/**
* Linearly interpolates between two RGB colors.
* @param {{r: number, g: number, b: number}} color1 - Start color.
* @param {{r: number, g: number, b: number}} color2 - End color.
* @param {number} factor - Interpolation factor (0.0 to 1.0).
* @returns {{r: number, g: number, b: number}} Interpolated color.
*/
function interpolateColor(color1, color2, factor) {
factor = Math.max(0, Math.min(1, factor)); // Clamp factor
const r = Math.round(color1.r + factor * (color2.r - color1.r));
const g = Math.round(color1.g + factor * (color2.g - color1.g));
const b = Math.round(color1.b + factor * (color2.b - color1.b));
return { r, g, b };
}
// --- Audio Handling Functions ---
/**
* Initializes the Tone.js audio context if not already started.
* Should be called upon user interaction (checking the master audio box).
*/
async function startTone() {
const ToneRef = typeof Tone !== 'undefined' ? Tone : unsafeWindow.Tone;
if (!ToneRef) {
console.error(`${SCRIPT_PREFIX}: Tone.js not found! Audio alerts disabled.`);
return;
}
if (!toneStarted) {
try {
await ToneRef.start();
console.log(`${SCRIPT_PREFIX}: Audio context started via user interaction.`);
synth = new ToneRef.Synth().toDestination();
toneStarted = true;
} catch (err) {
console.error(`${SCRIPT_PREFIX}: Error starting Tone.js:`, err);
toneStarted = false; // Ensure it's false if start failed
}
}
}
/**
* Plays a simple alert sound using Tone.js, if master audio is enabled.
* Plays 6 times if sixfold audio is enabled.
*/
function playAlertSound() {
// Check master switch first
if (!masterAudioEnabled) {
return;
}
if (!toneStarted || !synth) {
console.warn(`${SCRIPT_PREFIX}: Tone.js not initialized or synth not ready. Cannot play sound.`);
return; // Don't play if not ready
}
try {
const now = Tone.now();
// ***** ADDED/MODIFIED START (v2.9.9 - Renamed 3x -> 6x) *****
if (sixfoldAudioEnabled) { // Check renamed flag
// Play 6 times with delay
for (let i = 0; i < 6; i++) { // Loop 6 times
synth.triggerAttackRelease("A4", "8n", now + i * MULTIPLE_AUDIO_DELAY);
}
} else {
// Play once
synth.triggerAttackRelease("A4", "8n", now);
}
// ***** ADDED/MODIFIED END (v2.9.9 - Renamed 3x -> 6x) *****
} catch (error) {
console.error(`${SCRIPT_PREFIX}: Error playing sound:`, error);
}
}
/**
* Handles changes in the specific audio alert checkboxes.
*/
async function handleAudioAlertChange(event) {
// Note: We no longer call startTone() here. It's handled by the master checkbox.
const lcTarget = event.target.value; // Value is lowercase target name
const isChecked = event.target.checked;
audioAlertState[lcTarget] = isChecked;
await saveAudioAlertState(); // Save the change
}
/**
* Handles changes in the master audio enable checkbox.
*/
async function handleMasterAudioChange(event) {
const isChecked = event.target.checked;
masterAudioEnabled = isChecked;
console.log(`${SCRIPT_PREFIX}: Master audio ${masterAudioEnabled ? 'enabled' : 'disabled'}.`);
if (masterAudioEnabled && !toneStarted) {
// If enabling audio and context isn't started, start it now.
console.log(`${SCRIPT_PREFIX}: Master audio checked, attempting to start Tone.js...`);
await startTone();
}
// No need to explicitly stop Tone.js when unchecked, playAlertSound checks the flag.
}
// ***** ADDED/MODIFIED START (v2.9.9 - Renamed 3x -> 6x) *****
/**
* Handles changes in the sixfold audio enable checkbox.
*/
async function handleSixfoldAudioChange(event) { // Renamed handler
sixfoldAudioEnabled = event.target.checked;
console.log(`${SCRIPT_PREFIX}: 6x audio ${sixfoldAudioEnabled ? 'enabled' : 'disabled'}.`);
await saveSixfoldAudioState(); // Renamed save function
}
// ***** ADDED/MODIFIED END (v2.9.9 - Renamed 3x -> 6x) *****
// --- Core Logic: Highlight, Disable, Color Update ---
/**
* Extracts the target type (e.g., "Businessman") from a target item element.
* @param {HTMLElement} targetElement - The target item element.
* @returns {string|null} The target type string or null.
*/
function getTargetTypeFromElement(targetElement) {
const mainSection = targetElement.querySelector(SEL_TARGET_MAIN_SECTION);
const titleProps = mainSection ? mainSection.querySelector(SEL_TARGET_TITLE_PROPS) : null;
const titleDiv = titleProps ? titleProps.querySelector(SEL_TARGET_TYPE_DIV) : null;
return titleDiv ? titleDiv.textContent.trim() : null;
}
let processTimeout = null;
/**
* Debounced function to process all visible targets, applying highlight/filter classes and disabling buttons.
*/
function processTargets() {
clearTimeout(processTimeout);
processTimeout = setTimeout(_processTargetsInternal, 50); // Short debounce
}
/**
* Internal function to process targets. Applies classes based on filter and locked state.
*/
function _processTargetsInternal() {
if (!targetListContainer || !isInitialized) return;
const items = Array.from(targetListContainer.querySelectorAll(`:scope > ${SEL_TARGET_ITEM}`));
if (items.length === 0 && targetListContainer.children.length === 0) return;
let needsColorUpdate = false; // Flag if any item newly highlighted
items.forEach((item) => {
const crimeOptionWrapper = item.querySelector(SEL_TARGET_ITEM_WRAPPER);
if (!crimeOptionWrapper) return; // Skip placeholders
const crimeOptionDiv = crimeOptionWrapper.querySelector(`:scope > ${SEL_TARGET_OPTION_DIV}`);
if (!crimeOptionDiv) return;
const targetType = getTargetTypeFromElement(item);
const lcTargetType = targetType ? targetType.toLowerCase() : null;
// Determine if item should be highlighted
const isLocked = crimeOptionDiv.matches(SEL_LOCKED_ITEM_MARKER); // Check if Torn marked it locked
const matchesFilter = lcTargetType && filterState[lcTargetType] === true;
const shouldHighlight = matchesFilter && !isLocked; // Highlight ONLY if matches filter AND is NOT locked
// Apply/Remove classes
const hadHighlight = item.classList.contains(HIGHLIGHT_CLASS);
item.classList.toggle(HIGHLIGHT_CLASS, shouldHighlight);
item.classList.toggle(FILTERED_OUT_CLASS, !shouldHighlight);
if (shouldHighlight && !hadHighlight) {
needsColorUpdate = true; // Need to set initial color
// Play sound ONLY when target becomes highlighted and audio alert is enabled
if (lcTargetType && audioAlertState[lcTargetType]) {
// playAlertSound function now checks masterAudioEnabled and toneStarted internally
playAlertSound();
}
}
// Disable Button
const button = item.querySelector(SEL_COMMIT_BUTTON);
if (button) {
button.disabled = !shouldHighlight; // Disable if filtered out OR locked
}
// Clear styles if NOT highlighted (handles filter changes and locking)
if (!shouldHighlight) {
clearHighlightStyles(crimeOptionDiv);
}
});
// Update colors immediately if any item was newly highlighted
if (needsColorUpdate) {
updateHighlightColors();
}
}
/**
* Updates the highlight color of currently highlighted items based on their timers.
* Also removes highlight if item becomes locked. Runs periodically.
*/
function updateHighlightColors() {
if (!isInitialized) return;
const highlightedItems = document.querySelectorAll(`${SEL_TARGET_LIST_CONTAINER} > ${SEL_TARGET_ITEM}.${HIGHLIGHT_CLASS}`);
highlightedItems.forEach(item => {
const crimeOptionDiv = item.querySelector(`${SEL_TARGET_ITEM_WRAPPER} > ${SEL_TARGET_OPTION_DIV}`);
if (!crimeOptionDiv) return;
// --- Check if item became locked ---
const isLocked = crimeOptionDiv.matches(SEL_LOCKED_ITEM_MARKER);
const time = getTargetTimeRemaining(item); // Still get time for color logic if not locked
if (isLocked || time === null) {
// Remove highlight immediately if locked or time is invalid
item.classList.remove(HIGHLIGHT_CLASS);
item.classList.remove(FILTERED_OUT_CLASS); // Ensure filter class is also removed
clearHighlightStyles(crimeOptionDiv);
const button = item.querySelector(SEL_COMMIT_BUTTON);
if (button) button.disabled = true; // Ensure button is disabled when locked
return; // Stop processing this item
}
// --- End lock check ---
// If not locked and time is valid, proceed with color setting
let colorStart, colorEnd, borderColor, shadowColor;
if (time <= 0) {
// Time is 0 or less, set final RED color.
colorStart = COLOR_RED; colorEnd = COLOR_RED;
borderColor = COLOR_RED; shadowColor = COLOR_RED;
} else if (time > URGENCY_THRESHOLD) {
// Green for > 10 seconds
colorStart = COLOR_GREEN; colorEnd = COLOR_GREEN;
borderColor = COLOR_GREEN; shadowColor = COLOR_GREEN;
} else {
// Interpolate Orange -> Red for <= 10 seconds
const factor = Math.min(1, Math.max(0, (URGENCY_THRESHOLD - time) / URGENCY_THRESHOLD));
const interpolated = interpolateColor(COLOR_ORANGE, COLOR_RED, factor);
colorStart = interpolated; colorEnd = interpolated;
borderColor = interpolated; shadowColor = interpolated;
}
// Set CSS Variables for styling
setHighlightStyles(crimeOptionDiv, colorStart, colorEnd, borderColor, shadowColor);
});
}
/** Helper to set CSS Variables for highlight colors */
function setHighlightStyles(element, start, end, border, shadow) {
element.style.setProperty('--highlight-color-start-r', start.r);
element.style.setProperty('--highlight-color-start-g', start.g);
element.style.setProperty('--highlight-color-start-b', start.b);
element.style.setProperty('--highlight-color-end-r', end.r);
element.style.setProperty('--highlight-color-end-g', end.g);
element.style.setProperty('--highlight-color-end-b', end.b);
element.style.setProperty('--highlight-border-r', border.r);
element.style.setProperty('--highlight-border-g', border.g);
element.style.setProperty('--highlight-border-b', border.b);
element.style.setProperty('--highlight-shadow-r', shadow.r);
element.style.setProperty('--highlight-shadow-g', shadow.g);
element.style.setProperty('--highlight-shadow-b', shadow.b);
}
/** Helper to clear CSS Variables */
function clearHighlightStyles(element) {
element.style.removeProperty('--highlight-color-start-r');
element.style.removeProperty('--highlight-color-start-g');
element.style.removeProperty('--highlight-color-start-b');
element.style.removeProperty('--highlight-color-end-r');
element.style.removeProperty('--highlight-color-end-g');
element.style.removeProperty('--highlight-color-end-b');
element.style.removeProperty('--highlight-border-r');
element.style.removeProperty('--highlight-border-g');
element.style.removeProperty('--highlight-border-b');
element.style.removeProperty('--highlight-shadow-r');
element.style.removeProperty('--highlight-shadow-g');
element.style.removeProperty('--highlight-shadow-b');
}
// --- Initialization and Observation ---
/** Stop observing the crime list container */
function stopCrimeListObserver() {
if (crimeListObserver) {
crimeListObserver.disconnect();
crimeListObserver = null;
}
}
/** Start observing the crime list container for item changes */
function startCrimeListObserver() {
if (crimeListObserver || !targetListContainer) return;
crimeListObserver = new MutationObserver((mutationsList) => {
let relevantChange = mutationsList.some(mutation =>
mutation.type === 'childList' &&
[...mutation.addedNodes, ...mutation.removedNodes].some(node =>
node.nodeType === 1 && node.matches(SEL_TARGET_ITEM)
)
);
if (relevantChange) processTargets(); // Re-run checks when list items change
});
crimeListObserver.observe(targetListContainer, { childList: true });
}
/** Initialize the script, find elements, set up UI and observers */
async function initializeScript(retryCount = 0) {
const MAX_RETRIES = 30; const RETRY_DELAY = 300;
if (retryCount === 0) { isInitializing = true; }
stopCrimeListObserver(); // Stop previous observers first
if(highlightUpdateIntervalId) clearInterval(highlightUpdateIntervalId); // Clear previous interval
// Find elements using robust selectors
crimeRootElement = document.querySelector(SEL_CRIME_ROOT);
const crimeContentContainer = crimeRootElement ? crimeRootElement.querySelector(SEL_CURRENT_CRIME_CONTAINER) : null;
targetListContainer = crimeContentContainer ? crimeContentContainer.querySelector(SEL_TARGET_LIST_CONTAINER) : null;
// Check if core elements are found
if (!crimeRootElement || !crimeContentContainer || !targetListContainer) {
if (retryCount < MAX_RETRIES) {
if (retryCount % 5 === 0) { console.log(`${SCRIPT_PREFIX}: Core elements not found, retrying...`); }
setTimeout(() => initializeScript(retryCount + 1), RETRY_DELAY);
} else { console.error(`${SCRIPT_PREFIX}: Initialization FAILED after ${MAX_RETRIES} retries. Could not find core elements.`); isInitialized = false; isInitializing = false; }
return;
}
console.log(`${SCRIPT_PREFIX}: Core containers found. Proceeding with setup.`);
await loadFilters(); // Load filters first
await loadAudioAlertState(); // Load audio alert state
// ***** ADDED/MODIFIED START (v2.9.9 - Renamed 3x -> 6x) *****
await loadSixfoldAudioState(); // Load sixfold audio state
// ***** ADDED/MODIFIED END (v2.9.9 - Renamed 3x -> 6x) *****
// Create or update the control boxes UI
const filterBox = await createControlBox();
const audioBox = await createAudioAlertBox();
const masterAudioBox = createMasterAudioBox();
// ***** ADDED/MODIFIED START (v2.9.9 - Renamed 3x -> 6x) *****
const sixfoldAudioBox = createSixfoldAudioBox(); // Renamed create function
// ***** ADDED/MODIFIED END (v2.9.9 - Renamed 3x -> 6x) *****
if (filterBox && audioBox && masterAudioBox && sixfoldAudioBox && crimeRootElement && crimeRootElement.parentNode) { // Added sixfoldAudioBox check
// Create a wrapper for the control boxes if it doesn't exist
let controlsWrapper = document.getElementById('kw-control-boxes-wrapper'); // Check document globally first
if (!controlsWrapper) {
controlsWrapper = document.createElement('div');
controlsWrapper.id = 'kw-control-boxes-wrapper';
// Insert the wrapper *before* the crimeRootElement
crimeRootElement.parentNode.insertBefore(controlsWrapper, crimeRootElement);
console.log(`${SCRIPT_PREFIX}: Control boxes wrapper injected.`);
}
// Append the boxes to the wrapper if they aren't already there
if (!controlsWrapper.contains(filterBox)) {
controlsWrapper.appendChild(filterBox);
console.log(`${SCRIPT_PREFIX}: Filter box appended to wrapper.`);
}
if (!controlsWrapper.contains(audioBox)) {
controlsWrapper.appendChild(audioBox);
console.log(`${SCRIPT_PREFIX}: Audio alert box appended to wrapper.`);
}
if (!controlsWrapper.contains(masterAudioBox)) {
controlsWrapper.appendChild(masterAudioBox);
console.log(`${SCRIPT_PREFIX}: Master audio box appended to wrapper.`);
}
// ***** ADDED/MODIFIED START (v2.9.9 - Renamed 3x -> 6x) *****
if (!controlsWrapper.contains(sixfoldAudioBox)) {
controlsWrapper.appendChild(sixfoldAudioBox);
console.log(`${SCRIPT_PREFIX}: 6x audio box appended to wrapper.`); // Updated log
}
// ***** ADDED/MODIFIED END (v2.9.9 - Renamed 3x -> 6x) *****
isInitialized = true; isInitializing = false; // Mark initialization complete
} else {
console.error(`${SCRIPT_PREFIX}: Failed to create or inject control boxes. FilterBox valid: ${!!filterBox}, AudioBox valid: ${!!audioBox}, MasterAudioBox valid: ${!!masterAudioBox}, SixfoldAudioBox valid: ${!!sixfoldAudioBox}, CrimeRoot valid: ${!!crimeRootElement}, CrimeRoot Parent valid: ${!!crimeRootElement?.parentNode}`); // Updated check
isInitialized = false; isInitializing = false; return; // Stop if UI fails
}
processTargets(); // Initial apply of classes/styles
startCrimeListObserver(); // Start observing list changes
highlightUpdateIntervalId = setInterval(updateHighlightColors, 1000); // Start color updates (check every second)
console.log(`${SCRIPT_PREFIX}: Highlight color update interval started.`);
}
/** Updates the check state of checkboxes in the filter control box */
function updateControlBoxCheckboxes() {
if (!controlBoxElement) controlBoxElement = document.getElementById(CONTROL_BOX_ID); if (!controlBoxElement) return;
const checkboxes = controlBoxElement.querySelectorAll('input[type="checkbox"]');
checkboxes.forEach(cb => { cb.checked = filterState[cb.value] ?? true; }); // value is lowercase
}
/** Cleans up intervals, observers, and removes the UI */
function cleanupScript() {
console.log(`${SCRIPT_PREFIX}: Cleaning up script.`);
stopCrimeListObserver();
if (highlightUpdateIntervalId) { clearInterval(highlightUpdateIntervalId); highlightUpdateIntervalId = null; }
const wrapper = document.getElementById('kw-control-boxes-wrapper');
if (wrapper) wrapper.remove(); // Remove the wrapper, taking all boxes with it
// Reset audio state
audioAlertState = {};
synth = null;
toneStarted = false;
audioAlertBoxElement = null; // Clear reference
masterAudioBoxElement = null; // Clear reference
masterAudioEnabled = false; // Reset master switch
// ***** ADDED/MODIFIED START (v2.9.9 - Renamed 3x -> 6x) *****
sixfoldAudioBoxElement = null; // Clear reference
sixfoldAudioEnabled = false; // Reset sixfold switch
// ***** ADDED/MODIFIED END (v2.9.9 - Renamed 3x -> 6x) *****
controlBoxElement = null; // Clear reference
targetListContainer = null;
crimeRootElement = null;
clearTimeout(processTimeout);
isInitialized = false;
isInitializing = false;
}
// --- Run Script Logic ---
/** Starts the observer that watches for the main crime page content */
function startPageLoadObserver() {
const mainContentArea = document.querySelector('#mainContainer .content-wrapper');
if (!mainContentArea) { console.error(`${SCRIPT_PREFIX}: Cannot find observer target (#mainContainer .content-wrapper). Retrying...`); setTimeout(startPageLoadObserver, 1000); return; }
pageLoadObserver = new MutationObserver((mutationsList) => {
const pickpocketRoot = mainContentArea.querySelector(SEL_CRIME_ROOT);
const isOnPickpocketing = window.location.hash === '#/pickpocketing';
// Initialize if on correct page, root exists, and not already running/initializing
if (isOnPickpocketing && pickpocketRoot && !isInitialized && !isInitializing) {
initializeScript();
}
// Cleanup if initialized but page changed or root disappeared
else if (isInitialized && (!isOnPickpocketing || !pickpocketRoot)) {
cleanupScript();
}
});
pageLoadObserver.observe(mainContentArea, { childList: true, subtree: true });
console.log(`${SCRIPT_PREFIX}: Page load observer is now active.`);
// Initial check in case already on the page
setTimeout(() => {
const pickpocketRoot = mainContentArea.querySelector(SEL_CRIME_ROOT);
if (window.location.hash === '#/pickpocketing' && pickpocketRoot && !isInitialized && !isInitializing) {
console.log(`${SCRIPT_PREFIX}: Triggering init from initial check.`);
initializeScript();
} else if (window.location.hash !== '#/pickpocketing' && isInitialized) {
console.log(`${SCRIPT_PREFIX}: Triggering cleanup from initial check (not on pickpocketing page).`);
cleanupScript(); // Cleanup if loaded on wrong page but script was active
}
}, 300);
}
// Start the page observer to monitor page changes
startPageLoadObserver();
})();