Torn Pickpocketing Target Filter

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.

  1. // ==UserScript==
  2. // @name Torn Pickpocketing Target Filter
  3. // @namespace http://tampermonkey.net/
  4. // @version 2.9.10
  5. // @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.
  6. // @author Elaine [2047176]
  7. // @match https://www.torn.com/loader.php?sid=crimes*
  8. // @require https://cdnjs.cloudflare.com/ajax/libs/tone/14.8.49/Tone.min.js
  9. // @grant GM_setValue
  10. // @grant GM_getValue
  11. // @grant GM_addStyle
  12. // @grant unsafeWindow
  13. // @run-at document-idle
  14. // @license MIT
  15. // ==/UserScript==
  16.  
  17.  
  18. (function() {
  19. 'use strict';
  20.  
  21. // --- Configuration ---
  22. const SCRIPT_PREFIX = "PickpocketFilter";
  23. const WIKI_TARGET_LIST = [ // Original casing for display
  24. 'Businessman', 'Businesswoman', 'Classy Lady', 'Cyclist', 'Drunk Man',
  25. 'Drunk Woman', 'Elderly Man', 'Elderly Woman', 'Gang Member', 'Homeless Person',
  26. 'Jogger', 'Junkie', 'Laborer', 'Mobster', 'Police Officer', 'Postal Worker',
  27. 'Rich Kid', 'Sex Worker', 'Student', 'Thug', 'Young Man', 'Young Woman'
  28. ];
  29. const WIKI_TARGET_LIST_LC = WIKI_TARGET_LIST.map(t => t.toLowerCase()); // Lowercase for keys
  30.  
  31. const STORAGE_KEY_FILTERS = 'pickpocketingFilterState_v1_lc';
  32. const STORAGE_KEY_COLLAPSED = 'pickpocketingFilterCollapsedState_v1';
  33. const HIGHLIGHT_CLASS = 'kw-target-highlighted';
  34. const FILTERED_OUT_CLASS = 'kw-target-filtered-out';
  35. const CONTROL_BOX_ID = 'pickpocket-filter-box-Gemini';
  36. const COLLAPSED_CLASS = 'kw-collapsed';
  37. const COLLAPSED_BOX_HEIGHT = '38px'; // Define collapsed height
  38.  
  39. const AUDIO_ALERT_BOX_ID = 'pickpocket-audio-alert-box-Gemini';
  40. const STORAGE_KEY_AUDIO_ALERTS = 'pickpocketingAudioAlertState_v1';
  41. const STORAGE_KEY_AUDIO_COLLAPSED = 'pickpocketingAudioCollapsedState_v1';
  42.  
  43. const MASTER_AUDIO_BOX_ID = 'pickpocket-master-audio-box-Gemini';
  44. const MASTER_AUDIO_CHECKBOX_ID = 'kw-master-audio-enable';
  45.  
  46. const SIXFOLD_AUDIO_BOX_ID = 'pickpocket-sixfold-audio-box-Gemini';
  47. const SIXFOLD_AUDIO_CHECKBOX_ID = 'kw-sixfold-audio-enable';
  48. const STORAGE_KEY_SIXFOLD_AUDIO = 'pickpocketingSixfoldAudioState_v1';
  49. const MULTIPLE_AUDIO_DELAY = 0.18; // Delay between multiple sounds in seconds (used for 6x)
  50.  
  51. // --- Robust Selectors ---
  52. const SEL_CRIME_ROOT = 'div[class*="crime-root"][class*="pickpocketing-root"]';
  53. const SEL_CURRENT_CRIME_CONTAINER = 'div[class*="currentCrime"]'; // Container for the target list
  54. const SEL_TARGET_LIST_CONTAINER = 'div[class*="virtualList"]';
  55. const SEL_TARGET_ITEM = 'div[class*="virtualItem"]';
  56. const SEL_TARGET_ITEM_WRAPPER = 'div[class*="crimeOptionWrapper"]';
  57. const SEL_TARGET_OPTION_DIV = 'div[class*="crimeOption___"]'; // The div holding crime info inside wrapper
  58. const SEL_TARGET_MAIN_SECTION = 'div[class*="mainSection"]';
  59. const SEL_TARGET_TITLE_PROPS = 'div[class*="titleAndProps"]';
  60. const SEL_TARGET_TYPE_DIV = ':scope > div:first-child';
  61. const SEL_COMMIT_BUTTON = 'button[class*="commit-button"]';
  62. const SEL_ACTIVITY_DIV = 'div[class*="activity"]';
  63. const SEL_TIMER_CLOCK = 'div[class*="clock"]';
  64. const SEL_LOCKED_ITEM_MARKER = '[class*="locked___"]'; // Class indicating the item is locked/expired
  65.  
  66. // --- Color Config ---
  67. const COLOR_GREEN = { r: 50, g: 180, b: 50 };
  68. const COLOR_ORANGE = { r: 255, g: 165, b: 0 };
  69. const COLOR_RED = { r: 200, g: 0, b: 0 };
  70. const HIGHLIGHT_OPACITY = 0.4;
  71. const BORDER_OPACITY = 0.9;
  72. const SHADOW_OPACITY = 0.7;
  73. const URGENCY_THRESHOLD = 10; // Seconds
  74.  
  75. // --- State ---
  76. let filterState = {};
  77. let targetListContainer = null;
  78. let controlBoxElement = null;
  79. let crimeListObserver = null;
  80. let pageLoadObserver = null;
  81. let crimeRootElement = null;
  82. let isInitialized = false;
  83. let isInitializing = false;
  84. let highlightUpdateIntervalId = null;
  85.  
  86. let audioAlertState = {};
  87. let audioAlertBoxElement = null;
  88. let synth; // Tone.js synthesizer instance
  89. let toneStarted = false; // Flag to check if Tone.js context is started
  90.  
  91. let masterAudioEnabled = false; // Master switch for all audio alerts
  92. let masterAudioBoxElement = null;
  93.  
  94. let sixfoldAudioEnabled = false; // Flag for playing sound 6 times
  95. let sixfoldAudioBoxElement = null;
  96.  
  97.  
  98. console.log(`${SCRIPT_PREFIX}: Script loaded (v2.9.10).`); // Version updated
  99.  
  100. // --- Styles ---
  101. GM_addStyle(`
  102. /* Keep target list container as default block */
  103. ${SEL_CRIME_ROOT} > ${SEL_CURRENT_CRIME_CONTAINER} {
  104. display: block;
  105. min-width: 0;
  106. }
  107.  
  108. /* Wrapper for control boxes */
  109. #kw-control-boxes-wrapper {
  110. display: flex;
  111. flex-direction: row;
  112. gap: 10px; /* Reduced gap */
  113. margin-bottom: 15px; /* Add space below the boxes */
  114. align-items: flex-start; /* Align boxes to the top */
  115. flex-wrap: wrap; /* Allow boxes to wrap on very narrow screens */
  116. position: relative; /* Needed for z-index context if children use it */
  117. z-index: 100; /* Ensure wrapper is generally above crime content */
  118. }
  119. /* Shared styles for control boxes */
  120. .kw-control-box {
  121. border: 1px solid #555; background-color: #2e2e2e; color: #ccc;
  122. border-radius: 5px;
  123. box-sizing: border-box; transition: max-height 0.3s ease-out, background-color 0.3s ease-out;
  124. overflow: visible; /* Allow absolute content to overflow */
  125. /* max-height: 600px; */ /* Max height now controlled by content */
  126. width: 165px; /* Reduced width */
  127. flex-shrink: 0; /* Prevent boxes from shrinking */
  128. position: relative; /* Crucial for absolute positioning of content */
  129. }
  130. /* Collapsible box header styles */
  131. .kw-control-box .kw-filter-header {
  132. display: flex; justify-content: space-between; align-items: center;
  133. padding: 8px 10px;
  134. cursor: pointer; background-color: #3a3a3a;
  135. /* border-bottom: 1px solid #555; */ /* Border moved to content */
  136. transition: background-color 0.2s ease;
  137. height: ${COLLAPSED_BOX_HEIGHT};
  138. box-sizing: border-box;
  139. position: relative; /* Keep header in flow */
  140. z-index: 1; /* Header above content */
  141. border-radius: 5px; /* Round corners when collapsed */
  142. }
  143. .kw-control-box .kw-filter-header:hover { background-color: #454545; }
  144. .kw-control-box .kw-filter-header h5 { margin: 0; color: #eee; font-size: 1.0em; font-weight: bold; }
  145. .kw-control-box .kw-filter-header .kw-collapse-indicator { font-size: 0.8em; margin-left: 5px; color: #aaa; }
  146.  
  147. /* Content area styles - ABSOLUTE POSITIONING */
  148. .kw-control-box .kw-filter-content {
  149. position: absolute;
  150. top: ${COLLAPSED_BOX_HEIGHT}; /* Position below the header */
  151. left: 0;
  152. width: 100%; /* Match parent box width */
  153. z-index: 50; /* Sit above page content below */
  154. background-color: #2e2e2e; /* Match box background */
  155. border: 1px solid #555;
  156. border-top: none; /* Avoid double border with header */
  157. border-radius: 0 0 5px 5px; /* Round bottom corners */
  158. box-shadow: 0 4px 8px rgba(0,0,0,0.3); /* Add shadow for overlay effect */
  159.  
  160. padding: 8px 10px;
  161. max-height: 450px; /* Still allow scroll */
  162. overflow-y: auto;
  163. scrollbar-width: thin; scrollbar-color: #666 #333;
  164. box-sizing: border-box;
  165. /* transition: padding 0.3s ease-out; */ /* Transition might look weird with absolute */
  166. display: block; /* Ensure it's block */
  167. }
  168. .kw-control-box .kw-filter-content::-webkit-scrollbar { width: 8px; }
  169. .kw-control-box .kw-filter-content::-webkit-scrollbar-track { background: #333; border-radius: 4px; }
  170. .kw-control-box .kw-filter-content::-webkit-scrollbar-thumb { background-color: #666; border-radius: 4px; border: 2px solid #333; }
  171.  
  172. /* Collapsed state styles */
  173. .kw-control-box.${COLLAPSED_CLASS} {
  174. max-height: ${COLLAPSED_BOX_HEIGHT}; /* Limit height of container */
  175. overflow: hidden; /* Hide the absolute content when container shrinks */
  176. background-color: #3a3a3a;
  177. }
  178. .kw-control-box.${COLLAPSED_CLASS} .kw-filter-header {
  179. border-bottom-color: #3a3a3a; /* Match background when collapsed */
  180. }
  181. /* Hide content using display:none still works and is efficient */
  182. .kw-control-box.${COLLAPSED_CLASS} .kw-filter-content {
  183. display: none;
  184. }
  185.  
  186. /* Label/Checkbox styles */
  187. .kw-control-box label {
  188. display: flex;
  189. align-items: center;
  190. /* height: 100%; */ /* Removed fixed height for labels inside scrolling content */
  191. margin-bottom: 6px; cursor: pointer; padding: 3px 5px;
  192. border-radius: 3px; transition: background-color 0.2s ease;
  193. white-space: nowrap; overflow: hidden; text-overflow: ellipsis; font-size: 0.9em;
  194. }
  195. .kw-control-box label:hover { background-color: #484848; }
  196. .kw-control-box input[type="checkbox"] { margin-right: 6px; vertical-align: middle; transform: scale(0.85); }
  197.  
  198. /* Specific ID for filter box */
  199. #${CONTROL_BOX_ID} { /* Inherits .kw-control-box styles */ }
  200.  
  201. /* Specific ID and styles for audio alert box */
  202. #${AUDIO_ALERT_BOX_ID} { /* Inherits .kw-control-box styles */ }
  203. #${AUDIO_ALERT_BOX_ID} .kw-filter-content {
  204. max-height: 300px; /* Potentially shorter list */
  205. }
  206. #${AUDIO_ALERT_BOX_ID} .kw-italic-placeholder {
  207. font-style: italic;
  208. color: #888;
  209. padding: 5px;
  210. height: auto; /* Override label height */
  211. display: block; /* Override label display */
  212. }
  213. #${AUDIO_ALERT_BOX_ID} .kw-filter-content li { height: auto; } /* Override li height if needed */
  214. #${AUDIO_ALERT_BOX_ID} .kw-filter-content label { height: auto; } /* Override label height */
  215.  
  216.  
  217. /* Apply non-collapsible style */
  218. #${MASTER_AUDIO_BOX_ID}, #${SIXFOLD_AUDIO_BOX_ID} { /* Updated ID */
  219. /* Inherits .kw-control-box styles like border, bg, width, etc. */
  220. /* Apply non-collapsible fixed height and centering */
  221. max-height: ${COLLAPSED_BOX_HEIGHT} !important;
  222. height: ${COLLAPSED_BOX_HEIGHT} !important;
  223. padding: 0 10px !important; /* Adjusted padding */
  224. display: flex !important;
  225. align-items: center !important;
  226. overflow: hidden; /* Ensure content doesn't overflow fixed height */
  227. }
  228. #${MASTER_AUDIO_BOX_ID} label, #${SIXFOLD_AUDIO_BOX_ID} label { /* Updated ID */
  229. margin-bottom: 0 !important;
  230. padding: 0 5px !important;
  231. height: auto !important; /* Let height be natural */
  232. flex-grow: 1; /* Allow label to take space */
  233. }
  234.  
  235.  
  236. /* Highlighted Targets Styling - Uses CSS Variables */
  237. .${HIGHLIGHT_CLASS} > ${SEL_TARGET_ITEM_WRAPPER} > ${SEL_TARGET_OPTION_DIV} {
  238. --highlight-color-start-r: ${COLOR_ORANGE.r}; --highlight-color-start-g: ${COLOR_ORANGE.g}; --highlight-color-start-b: ${COLOR_ORANGE.b};
  239. --highlight-color-end-r: ${COLOR_RED.r}; --highlight-color-end-g: ${COLOR_RED.g}; --highlight-color-end-b: ${COLOR_RED.b};
  240. --highlight-border-r: ${COLOR_ORANGE.r}; --highlight-border-g: ${COLOR_ORANGE.g}; --highlight-border-b: ${COLOR_ORANGE.b};
  241. --highlight-shadow-r: ${COLOR_ORANGE.r}; --highlight-shadow-g: ${COLOR_ORANGE.g}; --highlight-shadow-b: ${COLOR_ORANGE.b};
  242.  
  243. background: linear-gradient(45deg,
  244. rgba(var(--highlight-color-start-r), var(--highlight-color-start-g), var(--highlight-color-start-b), ${HIGHLIGHT_OPACITY}),
  245. rgba(var(--highlight-color-end-r), var(--highlight-color-end-g), var(--highlight-color-end-b), ${HIGHLIGHT_OPACITY})
  246. ) !important;
  247. border: 1px dashed rgba(var(--highlight-border-r), var(--highlight-border-g), var(--highlight-border-b), ${BORDER_OPACITY}) !important;
  248. box-shadow: 0 0 8px rgba(var(--highlight-shadow-r), var(--highlight-shadow-g), var(--highlight-shadow-b), ${SHADOW_OPACITY}) !important;
  249. border-radius: 4px;
  250. transition: background 0.5s linear, border-color 0.5s linear, box-shadow 0.5s linear;
  251. }
  252.  
  253. /* Filtered Out Targets Styling */
  254. .${FILTERED_OUT_CLASS} {
  255. opacity: 0.55; filter: grayscale(60%);
  256. transition: opacity 0.3s ease, filter 0.3s ease;
  257. }
  258. .${FILTERED_OUT_CLASS}.${HIGHLIGHT_CLASS} { opacity: 1; filter: none; }
  259. .${FILTERED_OUT_CLASS} ${SEL_COMMIT_BUTTON} { cursor: not-allowed !important; filter: grayscale(80%); }
  260. .${HIGHLIGHT_CLASS} ${SEL_COMMIT_BUTTON} { cursor: pointer !important; filter: none; }
  261. `);
  262.  
  263. // --- Storage Functions ---
  264. /**
  265. * Loads filter state from GM storage, ensuring lowercase keys.
  266. * Defaults to all true if no state found.
  267. */
  268. async function loadFilters() {
  269. const savedState = await GM_getValue(STORAGE_KEY_FILTERS, null);
  270. let newState = {};
  271. if (savedState && typeof savedState === 'object') {
  272. WIKI_TARGET_LIST_LC.forEach(lcTarget => {
  273. let foundValue = true; // Default if not found
  274. for (const savedKey in savedState) {
  275. if (savedKey.toLowerCase() === lcTarget) {
  276. foundValue = savedState[savedKey];
  277. break;
  278. }
  279. }
  280. newState[lcTarget] = foundValue;
  281. });
  282. } else {
  283. console.log(`${SCRIPT_PREFIX}: No saved filters found. Defaulting all to checked.`);
  284. WIKI_TARGET_LIST_LC.forEach(lcTarget => newState[lcTarget] = true);
  285. }
  286. filterState = newState;
  287. await saveFilters(); // Save potentially migrated/defaulted state
  288. }
  289.  
  290. /**
  291. * Saves the current filter state (with lowercase keys) to GM storage.
  292. */
  293. async function saveFilters() {
  294. await GM_setValue(STORAGE_KEY_FILTERS, filterState);
  295. }
  296.  
  297. /**
  298. * Loads the collapsed state of the filter box. Defaults to false (expanded).
  299. * @returns {Promise<boolean>} True if collapsed, false otherwise.
  300. */
  301. async function loadCollapsedState() {
  302. return await GM_getValue(STORAGE_KEY_COLLAPSED, false); // Default to expanded (false)
  303. }
  304.  
  305. /**
  306. * Saves the collapsed state of the filter box.
  307. * @param {boolean} isCollapsed - True if the box is collapsed.
  308. */
  309. async function saveCollapsedState(isCollapsed) {
  310. await GM_setValue(STORAGE_KEY_COLLAPSED, isCollapsed);
  311. }
  312.  
  313. /**
  314. * Loads the audio alert state from GM storage. Defaults to all false.
  315. */
  316. async function loadAudioAlertState() {
  317. const savedState = await GM_getValue(STORAGE_KEY_AUDIO_ALERTS, null);
  318. let newState = {};
  319. if (savedState && typeof savedState === 'object') {
  320. // Load saved state, ensuring keys are lowercase
  321. WIKI_TARGET_LIST_LC.forEach(lcTarget => {
  322. let foundValue = false; // Default to false (off)
  323. for (const savedKey in savedState) {
  324. if (savedKey.toLowerCase() === lcTarget) {
  325. foundValue = savedState[savedKey];
  326. break;
  327. }
  328. }
  329. newState[lcTarget] = foundValue;
  330. });
  331. } else {
  332. // Default all to false if nothing saved
  333. WIKI_TARGET_LIST_LC.forEach(lcTarget => newState[lcTarget] = false);
  334. }
  335. audioAlertState = newState;
  336. // No need to save defaults immediately unless required
  337. }
  338.  
  339. /**
  340. * Saves the current audio alert state to GM storage.
  341. */
  342. async function saveAudioAlertState() {
  343. await GM_setValue(STORAGE_KEY_AUDIO_ALERTS, audioAlertState);
  344. }
  345.  
  346. /**
  347. * Loads the collapsed state of the audio alert box. Defaults to true (collapsed).
  348. * @returns {Promise<boolean>} True if collapsed, false otherwise.
  349. */
  350. async function loadAudioCollapsedState() {
  351. return await GM_getValue(STORAGE_KEY_AUDIO_COLLAPSED, true); // Default to collapsed (true)
  352. }
  353.  
  354. /**
  355. * Saves the collapsed state of the audio alert box.
  356. * @param {boolean} isCollapsed - True if the box is collapsed.
  357. */
  358. async function saveAudioCollapsedState(isCollapsed) {
  359. await GM_setValue(STORAGE_KEY_AUDIO_COLLAPSED, isCollapsed);
  360. }
  361.  
  362. /**
  363. * Loads the sixfold audio state from GM storage. Defaults to false.
  364. */
  365. async function loadSixfoldAudioState() {
  366. sixfoldAudioEnabled = await GM_getValue(STORAGE_KEY_SIXFOLD_AUDIO, false); // Default to false
  367. }
  368.  
  369. /**
  370. * Saves the current sixfold audio state to GM storage.
  371. */
  372. async function saveSixfoldAudioState() {
  373. await GM_setValue(STORAGE_KEY_SIXFOLD_AUDIO, sixfoldAudioEnabled);
  374. }
  375.  
  376.  
  377. // --- UI Creation ---
  378. /**
  379. * Creates the filter control box element or returns it if it already exists.
  380. * Applies the saved collapsed state.
  381. * @returns {Promise<HTMLElement|null>} The control box element or null if creation fails.
  382. */
  383. async function createControlBox() {
  384. if (document.getElementById(CONTROL_BOX_ID)) {
  385. controlBoxElement = document.getElementById(CONTROL_BOX_ID);
  386. updateControlBoxCheckboxes(); // Update checkboxes with current filter state
  387. const isCollapsed = await loadCollapsedState();
  388. controlBoxElement.classList.toggle(COLLAPSED_CLASS, isCollapsed);
  389. const indicator = controlBoxElement.querySelector('.kw-collapse-indicator');
  390. if (indicator) indicator.textContent = isCollapsed ? '►' : '▼';
  391. attachHeaderListener(controlBoxElement, saveCollapsedState); // Ensure listener is attached
  392. return controlBoxElement;
  393. }
  394.  
  395. console.log(`${SCRIPT_PREFIX}: Creating filter control box UI.`);
  396. controlBoxElement = document.createElement('div');
  397. controlBoxElement.id = CONTROL_BOX_ID;
  398. controlBoxElement.className = 'kw-control-box'; // Use shared class
  399.  
  400. // Header
  401. const header = document.createElement('div');
  402. header.className = 'kw-filter-header';
  403. const indicatorSpan = document.createElement('span');
  404. indicatorSpan.className = 'kw-collapse-indicator';
  405. header.innerHTML = `<h5>Filter Targets</h5>`;
  406. header.appendChild(indicatorSpan);
  407.  
  408. // Content (Checkboxes)
  409. const content = document.createElement('div');
  410. content.className = 'kw-filter-content';
  411. WIKI_TARGET_LIST.sort((a, b) => a.localeCompare(b)).forEach(target => {
  412. const lcTarget = target.toLowerCase();
  413. const label = document.createElement('label');
  414. label.title = target;
  415. const checkbox = document.createElement('input');
  416. checkbox.type = 'checkbox';
  417. checkbox.value = lcTarget;
  418. checkbox.checked = filterState[lcTarget] ?? true;
  419. checkbox.dataset.targetType = lcTarget;
  420. checkbox.addEventListener('change', async (event) => { // Make async
  421. filterState[event.target.value] = event.target.checked;
  422. await saveFilters(); // Wait for save
  423. processTargets(); // Trigger processing immediately on filter change
  424. updateAudioAlertList(); // Update audio list when filter changes
  425. });
  426. label.appendChild(checkbox);
  427. label.appendChild(document.createTextNode(` ${target}`)); // Display original case
  428. content.appendChild(label);
  429. });
  430.  
  431. controlBoxElement.appendChild(header);
  432. controlBoxElement.appendChild(content);
  433.  
  434. // Apply initial collapse state
  435. const isCollapsed = await loadCollapsedState();
  436. controlBoxElement.classList.toggle(COLLAPSED_CLASS, isCollapsed);
  437. indicatorSpan.textContent = isCollapsed ? '►' : '▼'; // Set initial indicator text
  438.  
  439. attachHeaderListener(controlBoxElement, saveCollapsedState); // Attach listener after elements are created
  440. return controlBoxElement;
  441. }
  442.  
  443. /**
  444. * Creates the audio alert control box element or returns it if it already exists.
  445. * Applies the saved collapsed state.
  446. * @returns {Promise<HTMLElement|null>} The audio alert box element or null if creation fails.
  447. */
  448. async function createAudioAlertBox() {
  449. if (document.getElementById(AUDIO_ALERT_BOX_ID)) {
  450. audioAlertBoxElement = document.getElementById(AUDIO_ALERT_BOX_ID);
  451. await updateAudioAlertList(); // Update content based on current filter/audio state
  452. const isCollapsed = await loadAudioCollapsedState();
  453. audioAlertBoxElement.classList.toggle(COLLAPSED_CLASS, isCollapsed);
  454. const indicator = audioAlertBoxElement.querySelector('.kw-collapse-indicator');
  455. if (indicator) indicator.textContent = isCollapsed ? '►' : '▼';
  456. attachHeaderListener(audioAlertBoxElement, saveAudioCollapsedState); // Ensure listener is attached
  457. return audioAlertBoxElement;
  458. }
  459.  
  460. console.log(`${SCRIPT_PREFIX}: Creating audio alert control box UI.`);
  461. audioAlertBoxElement = document.createElement('div');
  462. audioAlertBoxElement.id = AUDIO_ALERT_BOX_ID;
  463. audioAlertBoxElement.className = 'kw-control-box'; // Use shared class
  464.  
  465. // Header
  466. const header = document.createElement('div');
  467. header.className = 'kw-filter-header';
  468. const indicatorSpan = document.createElement('span');
  469. indicatorSpan.className = 'kw-collapse-indicator';
  470. header.innerHTML = `<h5>Audio Alert</h5>`;
  471. header.appendChild(indicatorSpan);
  472.  
  473. // Content (Checkboxes for filtered items)
  474. const content = document.createElement('div');
  475. content.className = 'kw-filter-content';
  476. content.innerHTML = `<ul id="kw-audio-alert-list" style="list-style: none; padding: 0; margin: 0;"></ul>`; // Add UL container
  477.  
  478. audioAlertBoxElement.appendChild(header);
  479. audioAlertBoxElement.appendChild(content);
  480.  
  481. // Apply initial collapse state
  482. const isCollapsed = await loadAudioCollapsedState();
  483. audioAlertBoxElement.classList.toggle(COLLAPSED_CLASS, isCollapsed);
  484. indicatorSpan.textContent = isCollapsed ? '►' : '▼';
  485.  
  486. attachHeaderListener(audioAlertBoxElement, saveAudioCollapsedState); // Attach listener
  487.  
  488. // Initial population of the list
  489. await updateAudioAlertList();
  490.  
  491. return audioAlertBoxElement;
  492. }
  493.  
  494. /**
  495. * Updates the 'Audio Alert' list based on the *active* filters.
  496. */
  497. async function updateAudioAlertList() {
  498. if (!audioAlertBoxElement) {
  499. return;
  500. }
  501. const listElement = audioAlertBoxElement.querySelector('#kw-audio-alert-list');
  502. if (!listElement) {
  503. return;
  504. }
  505.  
  506. listElement.innerHTML = ''; // Clear existing list
  507. let hasActiveFilters = false;
  508.  
  509. // Use original casing list for display, lowercase for keys
  510. const sortedTargets = [...WIKI_TARGET_LIST].sort((a, b) => a.localeCompare(b));
  511.  
  512. sortedTargets.forEach(target => {
  513. const lcTarget = target.toLowerCase();
  514. // Only add victims that are CHECKED in the main filter list
  515. if (filterState[lcTarget]) {
  516. hasActiveFilters = true;
  517. const li = document.createElement('li');
  518. const label = document.createElement('label');
  519. label.title = `Enable audio alert for ${target}`;
  520. const checkbox = document.createElement('input');
  521. checkbox.type = 'checkbox';
  522. const checkboxId = `kw-audio-alert-${lcTarget}`; // Unique ID
  523. checkbox.id = checkboxId;
  524. checkbox.value = lcTarget;
  525. // Set checked based on loaded/saved audio alert state
  526. checkbox.checked = audioAlertState[lcTarget] || false;
  527. checkbox.addEventListener('change', handleAudioAlertChange); // Add event listener
  528.  
  529. label.htmlFor = checkboxId;
  530. label.appendChild(checkbox);
  531. label.appendChild(document.createTextNode(` ${target}`)); // Display original case
  532.  
  533. li.appendChild(label);
  534. listElement.appendChild(li);
  535. }
  536. });
  537.  
  538. if (!hasActiveFilters) {
  539. listElement.innerHTML = '<li class="kw-italic-placeholder">No targets filtered.</li>';
  540. }
  541. }
  542.  
  543. /**
  544. * Creates the master audio control box.
  545. * @returns {HTMLElement|null} The master audio box element or null if creation fails.
  546. */
  547. function createMasterAudioBox() {
  548. if (document.getElementById(MASTER_AUDIO_BOX_ID)) {
  549. masterAudioBoxElement = document.getElementById(MASTER_AUDIO_BOX_ID);
  550. // Update checkbox state (although it defaults to false on load)
  551. const checkbox = masterAudioBoxElement.querySelector(`#${MASTER_AUDIO_CHECKBOX_ID}`);
  552. if (checkbox) checkbox.checked = masterAudioEnabled;
  553. attachMasterAudioListener(); // Ensure listener attached
  554. return masterAudioBoxElement;
  555. }
  556.  
  557. console.log(`${SCRIPT_PREFIX}: Creating master audio control box UI.`);
  558. masterAudioBoxElement = document.createElement('div');
  559. masterAudioBoxElement.id = MASTER_AUDIO_BOX_ID;
  560. masterAudioBoxElement.className = 'kw-control-box'; // Base style
  561.  
  562. const label = document.createElement('label');
  563. label.htmlFor = MASTER_AUDIO_CHECKBOX_ID;
  564. label.title = "Enable/Disable all audio alerts for this session";
  565.  
  566. const checkbox = document.createElement('input');
  567. checkbox.type = 'checkbox';
  568. checkbox.id = MASTER_AUDIO_CHECKBOX_ID;
  569. checkbox.checked = masterAudioEnabled; // Should be false initially
  570.  
  571. label.appendChild(checkbox);
  572. label.appendChild(document.createTextNode(' Enable Audio'));
  573.  
  574. masterAudioBoxElement.appendChild(label);
  575.  
  576. attachMasterAudioListener(); // Attach listener
  577.  
  578. return masterAudioBoxElement;
  579. }
  580.  
  581. /** Attaches listener to the master audio checkbox */
  582. function attachMasterAudioListener() {
  583. if (!masterAudioBoxElement) return;
  584. const checkbox = masterAudioBoxElement.querySelector(`#${MASTER_AUDIO_CHECKBOX_ID}`);
  585. if (checkbox && !checkbox.dataset.listenerAttached) {
  586. checkbox.addEventListener('change', handleMasterAudioChange);
  587. checkbox.dataset.listenerAttached = 'true';
  588. }
  589. }
  590.  
  591. /**
  592. * Creates the sixfold audio control box.
  593. * @returns {HTMLElement|null} The sixfold audio box element or null if creation fails.
  594. */
  595. function createSixfoldAudioBox() { // Renamed function
  596. if (document.getElementById(SIXFOLD_AUDIO_BOX_ID)) {
  597. sixfoldAudioBoxElement = document.getElementById(SIXFOLD_AUDIO_BOX_ID);
  598. // Update checkbox state from loaded value
  599. const checkbox = sixfoldAudioBoxElement.querySelector(`#${SIXFOLD_AUDIO_CHECKBOX_ID}`);
  600. if (checkbox) checkbox.checked = sixfoldAudioEnabled;
  601. attachSixfoldAudioListener(); // Ensure listener attached
  602. return sixfoldAudioBoxElement;
  603. }
  604.  
  605. console.log(`${SCRIPT_PREFIX}: Creating 6x audio control box UI.`); // Updated log
  606. sixfoldAudioBoxElement = document.createElement('div');
  607. sixfoldAudioBoxElement.id = SIXFOLD_AUDIO_BOX_ID; // Updated ID
  608. sixfoldAudioBoxElement.className = 'kw-control-box'; // Base style
  609.  
  610. const label = document.createElement('label');
  611. label.htmlFor = SIXFOLD_AUDIO_CHECKBOX_ID; // Updated ID
  612. label.title = "Play audio alert 6 times instead of once"; // Updated title
  613.  
  614. const checkbox = document.createElement('input');
  615. checkbox.type = 'checkbox';
  616. checkbox.id = SIXFOLD_AUDIO_CHECKBOX_ID; // Updated ID
  617. checkbox.checked = sixfoldAudioEnabled; // Use loaded state
  618.  
  619. label.appendChild(checkbox);
  620. label.appendChild(document.createTextNode(' 6x Audio')); // Updated text
  621.  
  622. sixfoldAudioBoxElement.appendChild(label);
  623.  
  624. attachSixfoldAudioListener(); // Attach listener
  625.  
  626. return sixfoldAudioBoxElement;
  627. }
  628.  
  629. /** Attaches listener to the sixfold audio checkbox */
  630. function attachSixfoldAudioListener() { // Renamed function
  631. if (!sixfoldAudioBoxElement) return;
  632. const checkbox = sixfoldAudioBoxElement.querySelector(`#${SIXFOLD_AUDIO_CHECKBOX_ID}`);
  633. if (checkbox && !checkbox.dataset.listenerAttached) {
  634. checkbox.addEventListener('change', handleSixfoldAudioChange); // Renamed handler
  635. checkbox.dataset.listenerAttached = 'true';
  636. }
  637. }
  638.  
  639.  
  640. /**
  641. * Attaches the click listener to a collapsible control box header.
  642. * @param {HTMLElement} boxElement - The control box element (filter or audio).
  643. * @param {Function} saveStateFunction - The function to call to save the collapsed state.
  644. */
  645. function attachHeaderListener(boxElement, saveStateFunction) {
  646. if (!boxElement || !boxElement.classList.contains('kw-control-box')) return; // Ensure it's a control box
  647. const header = boxElement.querySelector('.kw-filter-header');
  648. if (!header) return; // Only attach to boxes with headers (i.e., collapsible ones)
  649.  
  650. // Check if listener already attached to prevent duplicates
  651. if (!header.dataset.listenerAttached) {
  652. header.addEventListener('click', () => {
  653. // Removed startTone() call from here
  654. const isNowCollapsed = boxElement.classList.toggle(COLLAPSED_CLASS);
  655. const indicator = header.querySelector('.kw-collapse-indicator');
  656. if (indicator) {
  657. indicator.textContent = isNowCollapsed ? '►' : '▼';
  658. }
  659. saveStateFunction(isNowCollapsed); // Save the new state using the provided function
  660. });
  661. header.dataset.listenerAttached = 'true'; // Mark listener as attached
  662. }
  663. }
  664.  
  665. // --- Time & Color Utilities ---
  666. /**
  667. * Parses a time string (e.g., "1m 5s", "30s", "0s") into seconds.
  668. * @param {string} timeString - The time string from the target element.
  669. * @returns {number|null} Total seconds, or null if parsing fails.
  670. */
  671. function parseTimeToSeconds(timeString) {
  672. if (!timeString || typeof timeString !== 'string') return null;
  673. timeString = timeString.trim().toLowerCase();
  674. if (timeString === '0s' || timeString === '') return 0;
  675.  
  676. let totalSeconds = 0;
  677. const minuteMatch = timeString.match(/(\d+)\s*m/);
  678. const secondMatch = timeString.match(/(\d+)\s*s/);
  679.  
  680. if (minuteMatch) { totalSeconds += parseInt(minuteMatch[1], 10) * 60; }
  681. if (secondMatch) { totalSeconds += parseInt(secondMatch[1], 10); }
  682. else if (!minuteMatch && /^\d+$/.test(timeString)) { totalSeconds = parseInt(timeString, 10); } // Handle plain number as seconds
  683. else if (!secondMatch && timeString === 's') { return 0; } // Handle "s" alone
  684. else if (!minuteMatch && !secondMatch) { return null; } // Invalid format
  685.  
  686. return totalSeconds;
  687. }
  688.  
  689. /**
  690. * Gets the remaining time in seconds for a target item element.
  691. * @param {HTMLElement} itemElement - The target item element (div[class*="virtualItem"]).
  692. * @returns {number|null} Remaining seconds, 0 if hidden/expired, null if not found.
  693. */
  694. function getTargetTimeRemaining(itemElement) {
  695. const activityDiv = itemElement.querySelector(SEL_ACTIVITY_DIV);
  696. const clockElement = activityDiv ? activityDiv.querySelector(SEL_TIMER_CLOCK) : null;
  697. // Check if clock element exists and is not hidden
  698. if (!clockElement || clockElement.classList.contains('hidden___UI9Im') || clockElement.textContent === '') {
  699. return 0; // Treat hidden or empty clock as 0 seconds
  700. }
  701. return parseTimeToSeconds(clockElement.textContent);
  702. }
  703.  
  704. /**
  705. * Linearly interpolates between two RGB colors.
  706. * @param {{r: number, g: number, b: number}} color1 - Start color.
  707. * @param {{r: number, g: number, b: number}} color2 - End color.
  708. * @param {number} factor - Interpolation factor (0.0 to 1.0).
  709. * @returns {{r: number, g: number, b: number}} Interpolated color.
  710. */
  711. function interpolateColor(color1, color2, factor) {
  712. factor = Math.max(0, Math.min(1, factor)); // Clamp factor
  713. const r = Math.round(color1.r + factor * (color2.r - color1.r));
  714. const g = Math.round(color1.g + factor * (color2.g - color1.g));
  715. const b = Math.round(color1.b + factor * (color2.b - color1.b));
  716. return { r, g, b };
  717. }
  718.  
  719. // --- Audio Handling Functions ---
  720. /**
  721. * Initializes the Tone.js audio context if not already started.
  722. * Should be called upon user interaction (checking the master audio box).
  723. */
  724. async function startTone() {
  725. const ToneRef = typeof Tone !== 'undefined' ? Tone : unsafeWindow.Tone;
  726. if (!ToneRef) {
  727. console.error(`${SCRIPT_PREFIX}: Tone.js not found! Audio alerts disabled.`);
  728. return;
  729. }
  730. if (!toneStarted) {
  731. try {
  732. await ToneRef.start();
  733. console.log(`${SCRIPT_PREFIX}: Audio context started via user interaction.`);
  734. synth = new ToneRef.Synth().toDestination();
  735. toneStarted = true;
  736. } catch (err) {
  737. console.error(`${SCRIPT_PREFIX}: Error starting Tone.js:`, err);
  738. toneStarted = false; // Ensure it's false if start failed
  739. }
  740. }
  741. }
  742.  
  743. /**
  744. * Plays a simple alert sound using Tone.js, if master audio is enabled.
  745. * Plays 6 times if sixfold audio is enabled.
  746. */
  747. function playAlertSound() {
  748. // Check master switch first
  749. if (!masterAudioEnabled) {
  750. return;
  751. }
  752.  
  753. if (!toneStarted || !synth) {
  754. console.warn(`${SCRIPT_PREFIX}: Tone.js not initialized or synth not ready. Cannot play sound.`);
  755. return; // Don't play if not ready
  756. }
  757. try {
  758. const now = Tone.now();
  759. // ***** ADDED/MODIFIED START (v2.9.9 - Renamed 3x -> 6x) *****
  760. if (sixfoldAudioEnabled) { // Check renamed flag
  761. // Play 6 times with delay
  762. for (let i = 0; i < 6; i++) { // Loop 6 times
  763. synth.triggerAttackRelease("A4", "8n", now + i * MULTIPLE_AUDIO_DELAY);
  764. }
  765. } else {
  766. // Play once
  767. synth.triggerAttackRelease("A4", "8n", now);
  768. }
  769. // ***** ADDED/MODIFIED END (v2.9.9 - Renamed 3x -> 6x) *****
  770. } catch (error) {
  771. console.error(`${SCRIPT_PREFIX}: Error playing sound:`, error);
  772. }
  773. }
  774.  
  775. /**
  776. * Handles changes in the specific audio alert checkboxes.
  777. */
  778. async function handleAudioAlertChange(event) {
  779. // Note: We no longer call startTone() here. It's handled by the master checkbox.
  780. const lcTarget = event.target.value; // Value is lowercase target name
  781. const isChecked = event.target.checked;
  782. audioAlertState[lcTarget] = isChecked;
  783. await saveAudioAlertState(); // Save the change
  784. }
  785.  
  786. /**
  787. * Handles changes in the master audio enable checkbox.
  788. */
  789. async function handleMasterAudioChange(event) {
  790. const isChecked = event.target.checked;
  791. masterAudioEnabled = isChecked;
  792. console.log(`${SCRIPT_PREFIX}: Master audio ${masterAudioEnabled ? 'enabled' : 'disabled'}.`);
  793.  
  794. if (masterAudioEnabled && !toneStarted) {
  795. // If enabling audio and context isn't started, start it now.
  796. console.log(`${SCRIPT_PREFIX}: Master audio checked, attempting to start Tone.js...`);
  797. await startTone();
  798. }
  799. // No need to explicitly stop Tone.js when unchecked, playAlertSound checks the flag.
  800. }
  801.  
  802. // ***** ADDED/MODIFIED START (v2.9.9 - Renamed 3x -> 6x) *****
  803. /**
  804. * Handles changes in the sixfold audio enable checkbox.
  805. */
  806. async function handleSixfoldAudioChange(event) { // Renamed handler
  807. sixfoldAudioEnabled = event.target.checked;
  808. console.log(`${SCRIPT_PREFIX}: 6x audio ${sixfoldAudioEnabled ? 'enabled' : 'disabled'}.`);
  809. await saveSixfoldAudioState(); // Renamed save function
  810. }
  811. // ***** ADDED/MODIFIED END (v2.9.9 - Renamed 3x -> 6x) *****
  812.  
  813.  
  814. // --- Core Logic: Highlight, Disable, Color Update ---
  815. /**
  816. * Extracts the target type (e.g., "Businessman") from a target item element.
  817. * @param {HTMLElement} targetElement - The target item element.
  818. * @returns {string|null} The target type string or null.
  819. */
  820. function getTargetTypeFromElement(targetElement) {
  821. const mainSection = targetElement.querySelector(SEL_TARGET_MAIN_SECTION);
  822. const titleProps = mainSection ? mainSection.querySelector(SEL_TARGET_TITLE_PROPS) : null;
  823. const titleDiv = titleProps ? titleProps.querySelector(SEL_TARGET_TYPE_DIV) : null;
  824. return titleDiv ? titleDiv.textContent.trim() : null;
  825. }
  826.  
  827. let processTimeout = null;
  828. /**
  829. * Debounced function to process all visible targets, applying highlight/filter classes and disabling buttons.
  830. */
  831. function processTargets() {
  832. clearTimeout(processTimeout);
  833. processTimeout = setTimeout(_processTargetsInternal, 50); // Short debounce
  834. }
  835.  
  836. /**
  837. * Internal function to process targets. Applies classes based on filter and locked state.
  838. */
  839. function _processTargetsInternal() {
  840. if (!targetListContainer || !isInitialized) return;
  841.  
  842. const items = Array.from(targetListContainer.querySelectorAll(`:scope > ${SEL_TARGET_ITEM}`));
  843. if (items.length === 0 && targetListContainer.children.length === 0) return;
  844.  
  845. let needsColorUpdate = false; // Flag if any item newly highlighted
  846.  
  847. items.forEach((item) => {
  848. const crimeOptionWrapper = item.querySelector(SEL_TARGET_ITEM_WRAPPER);
  849. if (!crimeOptionWrapper) return; // Skip placeholders
  850.  
  851. const crimeOptionDiv = crimeOptionWrapper.querySelector(`:scope > ${SEL_TARGET_OPTION_DIV}`);
  852. if (!crimeOptionDiv) return;
  853.  
  854. const targetType = getTargetTypeFromElement(item);
  855. const lcTargetType = targetType ? targetType.toLowerCase() : null;
  856.  
  857. // Determine if item should be highlighted
  858. const isLocked = crimeOptionDiv.matches(SEL_LOCKED_ITEM_MARKER); // Check if Torn marked it locked
  859. const matchesFilter = lcTargetType && filterState[lcTargetType] === true;
  860. const shouldHighlight = matchesFilter && !isLocked; // Highlight ONLY if matches filter AND is NOT locked
  861.  
  862. // Apply/Remove classes
  863. const hadHighlight = item.classList.contains(HIGHLIGHT_CLASS);
  864. item.classList.toggle(HIGHLIGHT_CLASS, shouldHighlight);
  865. item.classList.toggle(FILTERED_OUT_CLASS, !shouldHighlight);
  866.  
  867. if (shouldHighlight && !hadHighlight) {
  868. needsColorUpdate = true; // Need to set initial color
  869.  
  870. // Play sound ONLY when target becomes highlighted and audio alert is enabled
  871. if (lcTargetType && audioAlertState[lcTargetType]) {
  872. // playAlertSound function now checks masterAudioEnabled and toneStarted internally
  873. playAlertSound();
  874. }
  875. }
  876.  
  877. // Disable Button
  878. const button = item.querySelector(SEL_COMMIT_BUTTON);
  879. if (button) {
  880. button.disabled = !shouldHighlight; // Disable if filtered out OR locked
  881. }
  882.  
  883. // Clear styles if NOT highlighted (handles filter changes and locking)
  884. if (!shouldHighlight) {
  885. clearHighlightStyles(crimeOptionDiv);
  886. }
  887. });
  888.  
  889. // Update colors immediately if any item was newly highlighted
  890. if (needsColorUpdate) {
  891. updateHighlightColors();
  892. }
  893. }
  894.  
  895. /**
  896. * Updates the highlight color of currently highlighted items based on their timers.
  897. * Also removes highlight if item becomes locked. Runs periodically.
  898. */
  899. function updateHighlightColors() {
  900. if (!isInitialized) return;
  901.  
  902. const highlightedItems = document.querySelectorAll(`${SEL_TARGET_LIST_CONTAINER} > ${SEL_TARGET_ITEM}.${HIGHLIGHT_CLASS}`);
  903.  
  904. highlightedItems.forEach(item => {
  905. const crimeOptionDiv = item.querySelector(`${SEL_TARGET_ITEM_WRAPPER} > ${SEL_TARGET_OPTION_DIV}`);
  906. if (!crimeOptionDiv) return;
  907.  
  908. // --- Check if item became locked ---
  909. const isLocked = crimeOptionDiv.matches(SEL_LOCKED_ITEM_MARKER);
  910. const time = getTargetTimeRemaining(item); // Still get time for color logic if not locked
  911.  
  912. if (isLocked || time === null) {
  913. // Remove highlight immediately if locked or time is invalid
  914. item.classList.remove(HIGHLIGHT_CLASS);
  915. item.classList.remove(FILTERED_OUT_CLASS); // Ensure filter class is also removed
  916. clearHighlightStyles(crimeOptionDiv);
  917. const button = item.querySelector(SEL_COMMIT_BUTTON);
  918. if (button) button.disabled = true; // Ensure button is disabled when locked
  919. return; // Stop processing this item
  920. }
  921. // --- End lock check ---
  922.  
  923. // If not locked and time is valid, proceed with color setting
  924. let colorStart, colorEnd, borderColor, shadowColor;
  925.  
  926. if (time <= 0) {
  927. // Time is 0 or less, set final RED color.
  928. colorStart = COLOR_RED; colorEnd = COLOR_RED;
  929. borderColor = COLOR_RED; shadowColor = COLOR_RED;
  930. } else if (time > URGENCY_THRESHOLD) {
  931. // Green for > 10 seconds
  932. colorStart = COLOR_GREEN; colorEnd = COLOR_GREEN;
  933. borderColor = COLOR_GREEN; shadowColor = COLOR_GREEN;
  934. } else {
  935. // Interpolate Orange -> Red for <= 10 seconds
  936. const factor = Math.min(1, Math.max(0, (URGENCY_THRESHOLD - time) / URGENCY_THRESHOLD));
  937. const interpolated = interpolateColor(COLOR_ORANGE, COLOR_RED, factor);
  938. colorStart = interpolated; colorEnd = interpolated;
  939. borderColor = interpolated; shadowColor = interpolated;
  940. }
  941.  
  942. // Set CSS Variables for styling
  943. setHighlightStyles(crimeOptionDiv, colorStart, colorEnd, borderColor, shadowColor);
  944. });
  945. }
  946.  
  947. /** Helper to set CSS Variables for highlight colors */
  948. function setHighlightStyles(element, start, end, border, shadow) {
  949. element.style.setProperty('--highlight-color-start-r', start.r);
  950. element.style.setProperty('--highlight-color-start-g', start.g);
  951. element.style.setProperty('--highlight-color-start-b', start.b);
  952. element.style.setProperty('--highlight-color-end-r', end.r);
  953. element.style.setProperty('--highlight-color-end-g', end.g);
  954. element.style.setProperty('--highlight-color-end-b', end.b);
  955. element.style.setProperty('--highlight-border-r', border.r);
  956. element.style.setProperty('--highlight-border-g', border.g);
  957. element.style.setProperty('--highlight-border-b', border.b);
  958. element.style.setProperty('--highlight-shadow-r', shadow.r);
  959. element.style.setProperty('--highlight-shadow-g', shadow.g);
  960. element.style.setProperty('--highlight-shadow-b', shadow.b);
  961. }
  962.  
  963. /** Helper to clear CSS Variables */
  964. function clearHighlightStyles(element) {
  965. element.style.removeProperty('--highlight-color-start-r');
  966. element.style.removeProperty('--highlight-color-start-g');
  967. element.style.removeProperty('--highlight-color-start-b');
  968. element.style.removeProperty('--highlight-color-end-r');
  969. element.style.removeProperty('--highlight-color-end-g');
  970. element.style.removeProperty('--highlight-color-end-b');
  971. element.style.removeProperty('--highlight-border-r');
  972. element.style.removeProperty('--highlight-border-g');
  973. element.style.removeProperty('--highlight-border-b');
  974. element.style.removeProperty('--highlight-shadow-r');
  975. element.style.removeProperty('--highlight-shadow-g');
  976. element.style.removeProperty('--highlight-shadow-b');
  977. }
  978.  
  979.  
  980. // --- Initialization and Observation ---
  981. /** Stop observing the crime list container */
  982. function stopCrimeListObserver() {
  983. if (crimeListObserver) {
  984. crimeListObserver.disconnect();
  985. crimeListObserver = null;
  986. }
  987. }
  988.  
  989. /** Start observing the crime list container for item changes */
  990. function startCrimeListObserver() {
  991. if (crimeListObserver || !targetListContainer) return;
  992. crimeListObserver = new MutationObserver((mutationsList) => {
  993. let relevantChange = mutationsList.some(mutation =>
  994. mutation.type === 'childList' &&
  995. [...mutation.addedNodes, ...mutation.removedNodes].some(node =>
  996. node.nodeType === 1 && node.matches(SEL_TARGET_ITEM)
  997. )
  998. );
  999. if (relevantChange) processTargets(); // Re-run checks when list items change
  1000. });
  1001. crimeListObserver.observe(targetListContainer, { childList: true });
  1002. }
  1003.  
  1004. /** Initialize the script, find elements, set up UI and observers */
  1005. async function initializeScript(retryCount = 0) {
  1006. const MAX_RETRIES = 30; const RETRY_DELAY = 300;
  1007. if (retryCount === 0) { isInitializing = true; }
  1008.  
  1009. stopCrimeListObserver(); // Stop previous observers first
  1010. if(highlightUpdateIntervalId) clearInterval(highlightUpdateIntervalId); // Clear previous interval
  1011.  
  1012. // Find elements using robust selectors
  1013. crimeRootElement = document.querySelector(SEL_CRIME_ROOT);
  1014. const crimeContentContainer = crimeRootElement ? crimeRootElement.querySelector(SEL_CURRENT_CRIME_CONTAINER) : null;
  1015. targetListContainer = crimeContentContainer ? crimeContentContainer.querySelector(SEL_TARGET_LIST_CONTAINER) : null;
  1016.  
  1017. // Check if core elements are found
  1018. if (!crimeRootElement || !crimeContentContainer || !targetListContainer) {
  1019. if (retryCount < MAX_RETRIES) {
  1020. if (retryCount % 5 === 0) { console.log(`${SCRIPT_PREFIX}: Core elements not found, retrying...`); }
  1021. setTimeout(() => initializeScript(retryCount + 1), RETRY_DELAY);
  1022. } else { console.error(`${SCRIPT_PREFIX}: Initialization FAILED after ${MAX_RETRIES} retries. Could not find core elements.`); isInitialized = false; isInitializing = false; }
  1023. return;
  1024. }
  1025.  
  1026. console.log(`${SCRIPT_PREFIX}: Core containers found. Proceeding with setup.`);
  1027. await loadFilters(); // Load filters first
  1028. await loadAudioAlertState(); // Load audio alert state
  1029. // ***** ADDED/MODIFIED START (v2.9.9 - Renamed 3x -> 6x) *****
  1030. await loadSixfoldAudioState(); // Load sixfold audio state
  1031. // ***** ADDED/MODIFIED END (v2.9.9 - Renamed 3x -> 6x) *****
  1032.  
  1033. // Create or update the control boxes UI
  1034. const filterBox = await createControlBox();
  1035. const audioBox = await createAudioAlertBox();
  1036. const masterAudioBox = createMasterAudioBox();
  1037. // ***** ADDED/MODIFIED START (v2.9.9 - Renamed 3x -> 6x) *****
  1038. const sixfoldAudioBox = createSixfoldAudioBox(); // Renamed create function
  1039. // ***** ADDED/MODIFIED END (v2.9.9 - Renamed 3x -> 6x) *****
  1040.  
  1041. if (filterBox && audioBox && masterAudioBox && sixfoldAudioBox && crimeRootElement && crimeRootElement.parentNode) { // Added sixfoldAudioBox check
  1042. // Create a wrapper for the control boxes if it doesn't exist
  1043. let controlsWrapper = document.getElementById('kw-control-boxes-wrapper'); // Check document globally first
  1044. if (!controlsWrapper) {
  1045. controlsWrapper = document.createElement('div');
  1046. controlsWrapper.id = 'kw-control-boxes-wrapper';
  1047. // Insert the wrapper *before* the crimeRootElement
  1048. crimeRootElement.parentNode.insertBefore(controlsWrapper, crimeRootElement);
  1049. console.log(`${SCRIPT_PREFIX}: Control boxes wrapper injected.`);
  1050. }
  1051.  
  1052. // Append the boxes to the wrapper if they aren't already there
  1053. if (!controlsWrapper.contains(filterBox)) {
  1054. controlsWrapper.appendChild(filterBox);
  1055. console.log(`${SCRIPT_PREFIX}: Filter box appended to wrapper.`);
  1056. }
  1057. if (!controlsWrapper.contains(audioBox)) {
  1058. controlsWrapper.appendChild(audioBox);
  1059. console.log(`${SCRIPT_PREFIX}: Audio alert box appended to wrapper.`);
  1060. }
  1061. if (!controlsWrapper.contains(masterAudioBox)) {
  1062. controlsWrapper.appendChild(masterAudioBox);
  1063. console.log(`${SCRIPT_PREFIX}: Master audio box appended to wrapper.`);
  1064. }
  1065. // ***** ADDED/MODIFIED START (v2.9.9 - Renamed 3x -> 6x) *****
  1066. if (!controlsWrapper.contains(sixfoldAudioBox)) {
  1067. controlsWrapper.appendChild(sixfoldAudioBox);
  1068. console.log(`${SCRIPT_PREFIX}: 6x audio box appended to wrapper.`); // Updated log
  1069. }
  1070. // ***** ADDED/MODIFIED END (v2.9.9 - Renamed 3x -> 6x) *****
  1071.  
  1072. isInitialized = true; isInitializing = false; // Mark initialization complete
  1073. } else {
  1074. 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
  1075. isInitialized = false; isInitializing = false; return; // Stop if UI fails
  1076. }
  1077.  
  1078. processTargets(); // Initial apply of classes/styles
  1079. startCrimeListObserver(); // Start observing list changes
  1080. highlightUpdateIntervalId = setInterval(updateHighlightColors, 1000); // Start color updates (check every second)
  1081. console.log(`${SCRIPT_PREFIX}: Highlight color update interval started.`);
  1082. }
  1083.  
  1084. /** Updates the check state of checkboxes in the filter control box */
  1085. function updateControlBoxCheckboxes() {
  1086. if (!controlBoxElement) controlBoxElement = document.getElementById(CONTROL_BOX_ID); if (!controlBoxElement) return;
  1087. const checkboxes = controlBoxElement.querySelectorAll('input[type="checkbox"]');
  1088. checkboxes.forEach(cb => { cb.checked = filterState[cb.value] ?? true; }); // value is lowercase
  1089. }
  1090.  
  1091. /** Cleans up intervals, observers, and removes the UI */
  1092. function cleanupScript() {
  1093. console.log(`${SCRIPT_PREFIX}: Cleaning up script.`);
  1094. stopCrimeListObserver();
  1095. if (highlightUpdateIntervalId) { clearInterval(highlightUpdateIntervalId); highlightUpdateIntervalId = null; }
  1096.  
  1097. const wrapper = document.getElementById('kw-control-boxes-wrapper');
  1098. if (wrapper) wrapper.remove(); // Remove the wrapper, taking all boxes with it
  1099.  
  1100. // Reset audio state
  1101. audioAlertState = {};
  1102. synth = null;
  1103. toneStarted = false;
  1104. audioAlertBoxElement = null; // Clear reference
  1105. masterAudioBoxElement = null; // Clear reference
  1106. masterAudioEnabled = false; // Reset master switch
  1107. // ***** ADDED/MODIFIED START (v2.9.9 - Renamed 3x -> 6x) *****
  1108. sixfoldAudioBoxElement = null; // Clear reference
  1109. sixfoldAudioEnabled = false; // Reset sixfold switch
  1110. // ***** ADDED/MODIFIED END (v2.9.9 - Renamed 3x -> 6x) *****
  1111.  
  1112. controlBoxElement = null; // Clear reference
  1113. targetListContainer = null;
  1114. crimeRootElement = null;
  1115. clearTimeout(processTimeout);
  1116. isInitialized = false;
  1117. isInitializing = false;
  1118. }
  1119.  
  1120. // --- Run Script Logic ---
  1121. /** Starts the observer that watches for the main crime page content */
  1122. function startPageLoadObserver() {
  1123. const mainContentArea = document.querySelector('#mainContainer .content-wrapper');
  1124. if (!mainContentArea) { console.error(`${SCRIPT_PREFIX}: Cannot find observer target (#mainContainer .content-wrapper). Retrying...`); setTimeout(startPageLoadObserver, 1000); return; }
  1125.  
  1126. pageLoadObserver = new MutationObserver((mutationsList) => {
  1127. const pickpocketRoot = mainContentArea.querySelector(SEL_CRIME_ROOT);
  1128. const isOnPickpocketing = window.location.hash === '#/pickpocketing';
  1129.  
  1130. // Initialize if on correct page, root exists, and not already running/initializing
  1131. if (isOnPickpocketing && pickpocketRoot && !isInitialized && !isInitializing) {
  1132. initializeScript();
  1133. }
  1134. // Cleanup if initialized but page changed or root disappeared
  1135. else if (isInitialized && (!isOnPickpocketing || !pickpocketRoot)) {
  1136. cleanupScript();
  1137. }
  1138. });
  1139. pageLoadObserver.observe(mainContentArea, { childList: true, subtree: true });
  1140. console.log(`${SCRIPT_PREFIX}: Page load observer is now active.`);
  1141.  
  1142. // Initial check in case already on the page
  1143. setTimeout(() => {
  1144. const pickpocketRoot = mainContentArea.querySelector(SEL_CRIME_ROOT);
  1145. if (window.location.hash === '#/pickpocketing' && pickpocketRoot && !isInitialized && !isInitializing) {
  1146. console.log(`${SCRIPT_PREFIX}: Triggering init from initial check.`);
  1147. initializeScript();
  1148. } else if (window.location.hash !== '#/pickpocketing' && isInitialized) {
  1149. console.log(`${SCRIPT_PREFIX}: Triggering cleanup from initial check (not on pickpocketing page).`);
  1150. cleanupScript(); // Cleanup if loaded on wrong page but script was active
  1151. }
  1152. }, 300);
  1153. }
  1154.  
  1155. // Start the page observer to monitor page changes
  1156. startPageLoadObserver();
  1157.  
  1158. })();