Universal Video Split

Универсальный сплит по времени для VK, Rutube, YouTube (v1.9). Панель управления, статистика, оверлей, звук, таймер

当前为 2025-04-28 提交的版本,查看 最新版本

  1. // ==UserScript==
  2. // @name Universal Video Split
  3. // @namespace http://tampermonkey.net/
  4. // @version 1.9
  5. // @author Gemini (Combined by AI)
  6. // @match *://vk.com/video_ext.php*
  7. // @match *://vkvideo.ru/video_ext.php*
  8. // @match *://rutube.ru/*
  9. // @match *://www.youtube.com/*
  10. // @grant GM_addStyle
  11. // @grant GM_log
  12. // @description Универсальный сплит по времени для VK, Rutube, YouTube (v1.9). Панель управления, статистика, оверлей, звук, таймер
  13. // ==/UserScript==
  14.  
  15. (function() {
  16. 'use strict';
  17.  
  18. // --- КОНФИГУРАЦИЯ (Общая) ---
  19. let splitMinutes = null;
  20. let totalVideoMinutes = null;
  21. const extendCost = 300;
  22. // The sound URL seems to be causing a 'Format error' or 'ERR_BLOCKED_BY_CLIENT' specifically on VK.
  23. // This indicates an issue with VK's environment blocking loading from raw.githubusercontent.com
  24. // or a format incompatibility there.
  25. // If sound doesn't work on VK, try hosting a different, simple MP3 file elsewhere (e.g., a file hosting service or your own server) and replace this URL.
  26. const splitSoundUrl = 'https://github.com/lardan099/donat/raw/refs/heads/main/mix_06s.mp3';
  27. const overlayGifUrl = 'https://i.imgur.com/SS5Nfff.gif';
  28. const localStorageVolumeKey = 'universalSplitAlertVolume';
  29. const localStorageTimerKey = 'universalSplitOverlayTimer';
  30. const defaultOverlayTimerDuration = 360;
  31. const defaultAlertVolume = '1.0';
  32. const setupIntervalDelay = 500; // How often to try setting up the script if elements aren't found
  33.  
  34. // --- ГЛОБАЛЬНЫЕ ПЕРЕМЕННЫЕ СОСТОЯНИЯ ---
  35. let currentPlatform = 'unknown';
  36. let platformConfig = null;
  37. let video = null;
  38. let overlay = null;
  39. let splitTriggered = false; // Flag to ensure split actions only happen once per trigger event
  40. let audioPlayer = null;
  41. let audioPrimed = false; // Flag to track if we've attempted to prime the audio context for the *current* audioPlayer instance
  42. let splitCheckIntervalId = null;
  43. let setupIntervalId = null; // Interval to poll for video/insertion elements on complex pages
  44. let panelAdded = false;
  45. let panelElement = null;
  46. let controlsElement = null; // Specific for VK visibility check
  47. let visibilityCheckIntervalId = null; // Specific for VK visibility check
  48. let navigationObserver = null; // Observer to detect URL changes for SPAs
  49. let lastUrl = location.href; // Track last URL for navigation observer
  50. let videoPlayListenerAdded = false; // Flag to ensure we only add the video 'play' listener once per video element instance
  51.  
  52. let overlayTimerDuration = parseInt(localStorage.getItem(localStorageTimerKey), 10);
  53. if (isNaN(overlayTimerDuration) || overlayTimerDuration < 0) {
  54. overlayTimerDuration = defaultOverlayTimerDuration;
  55. }
  56. let overlayTimerIntervalId = null;
  57. let overlayCountdownRemaining = overlayTimerDuration;
  58.  
  59. // --- КОНФИГУРАЦИЯ ПЛАТФОРМ ---
  60. const platformConfigs = {
  61. vk: {
  62. idPrefix: 'vk',
  63. controlPanelSelector: '#split-control-panel-vk',
  64. videoSelector: 'video',
  65. insertionSelector: '.videoplayer_title', // Insert panel after this
  66. insertionMethod: 'afterend',
  67. controlsElementSelector: '.videoplayer_controls', // Element to watch for visibility (VK only)
  68. needsVisibilityCheck: true,
  69. // --- CORRECTED VK TITLE SELECTOR based on provided HTML ---
  70. videoTitleSelector: '.videoplayer_title a.videoplayer_title_link', // Selects the <a> tag containing the title text
  71. // --- END CORRECTED VK TITLE SELECTOR ---
  72. styles: `
  73. #split-control-panel-vk { background: #f0f2f5; border: 1px solid #dce1e6; color: #333; display: none; opacity: 0; transition: opacity 0.2s ease-in-out; position: relative; z-index: 10; }
  74. #split-control-panel-vk.visible { display: flex; opacity: 1; }
  75. #split-control-panel-vk label { color: #656565; } #split-control-panel-vk label i { color: #828282; }
  76. #split-control-panel-vk input[type="number"] { background: #fff; color: #000; border: 1px solid #c5d0db; }
  77. #split-control-panel-vk input[type="number"]:focus { border-color: #aebdcb; }
  78. #split-control-panel-vk button { background: #e5ebf1; color: #333; border: 1px solid #dce1e6; }
  79. #split-control-panel-vk button:hover { background: #dae2ea; } #split-control-panel-vk button:active { background: #ccd5e0; border-color: #c5d0db; }
  80. #split-control-panel-vk .split-input-group button { background: #f0f2f5; border: 1px solid #dce1e6; } #split-control-panel-vk .split-input-group button:hover { background: #e5ebf1; }
  81. #split-control-panel-vk .set-split-button { background: #5181b8; color: #fff; border: none; } #split-control-panel-vk .set-split-button:hover { background: #4a76a8; }
  82. #split-control-panel-vk .set-split-button.active { background: #6a9e42; } #split-control-panel-vk .set-split-button.active:hover { background: #5c8c38; }
  83. #split-control-panel-vk .split-volume-control label { color: #656565; }
  84. #split-control-panel-vk .split-volume-control input[type="range"] { background: #dae2ea; }
  85. #split-control-panel-vk .split-volume-control input[type="range"]::-webkit-slider-thumb { background: #5181b8; } #split-control-panel-vk .split-volume-control input[type="range"]::-moz-range-thumb { background: #5181b8; }
  86. #split-control-panel-vk .split-stats { color: #333; }
  87. `
  88. },
  89. rutube: {
  90. idPrefix: 'rutube',
  91. controlPanelSelector: '#split-control-panel-rutube',
  92. videoSelector: 'video',
  93. insertionSelector: '.video-pageinfo-container-module__pageInfoContainer', // Insert panel into this
  94. insertionMethod: 'prepend',
  95. needsVisibilityCheck: false,
  96. // --- RUTUBE TITLE SELECTOR based on provided HTML ---
  97. videoTitleSelector: 'h1.video-pageinfo-container-module__videoTitleSectionHeader', // Selector for video title
  98. // --- END RUTUBE TITLE SELECTOR ---
  99. styles: `
  100. #split-control-panel-rutube { background: #222; border: 1px solid #444; color: #eee; }
  101. #split-control-panel-rutube label { color: #aaa; } #split-control-panel-rutube label i { color: #888; }
  102. #split-control-panel-rutube input[type="number"] { background: #333; color: #eee; border: 1px solid #555; }
  103. #split-control-panel-rutube button { background: #444; color: #eee; border: none; } #split-control-panel-rutube button:hover { background: #555; }
  104. #split-control-panel-rutube .split-input-group button { background: #333; border: 1px solid #555; } #split-control-panel-rutube .split-input-group button:hover { background: #444; }
  105. #split-control-panel-rutube .set-split-button { background: #007bff; color: white; } #split-control-panel-rutube .set-split-button:hover { background: #0056b3; }
  106. #split-control-panel-rutube .set-split-button.active { background: #28a745; } #split-control-panel-rutube .set-split-button.active:hover { background: #1e7e34; }
  107. #split-control-panel-rutube .split-volume-control label { color: #aaa; }
  108. #split-control-panel-rutube .split-volume-control input[type="range"] { background: #444; }
  109. #split-control-panel-rutube .split-volume-control input[type="range"]::-webkit-slider-thumb { background: #007bff; } #split-control-panel-rutube .split-volume-control input[type="range"]::-moz-range-thumb { background: #007bff; }
  110. #split-control-panel-rutube .split-stats { color: #eee; }
  111. `
  112. },
  113. youtube: {
  114. idPrefix: 'youtube',
  115. controlPanelSelector: '#split-control-panel-youtube',
  116. videoSelector: 'video',
  117. insertionSelector: 'ytd-watch-flexy #primary', // Insert panel into this
  118. insertionMethod: 'prepend',
  119. needsVisibilityCheck: false,
  120. videoTitleSelector: '#title h1', // Selector for video title
  121. styles: `
  122. #split-control-panel-youtube { background: var(--yt-spec-badge-chip-background); border: 1px solid var(--yt-spec-border-div); color: var(--yt-spec-text-primary); max-width: var(--ytd-watch-flexy-width); }
  123. ytd-watch-flexy:not([theater]) #primary #split-control-panel-youtube { margin-left: auto; margin-right: auto; }
  124. ytd-watch-flexy[theater] #primary #split-control-panel-youtube { max-width: 100%; }
  125. #split-control-panel-youtube label { color: var(--yt-spec-text-secondary); } #split-control-panel-youtube label i { color: var(--yt-spec-text-disabled); }
  126. #split-control-panel-youtube input[type="number"] { background: var(--yt-spec-filled-button-background); color: var(--yt-spec-text-primary); border: 1px solid var(--yt-spec-action-simulate-border); }
  127. #split-control-panel-youtube button { background: var(--yt-spec-grey-1); color: var(--yt-spec-text-primary); border: none; } #split-control-panel-youtube button:hover { background: var(--yt-spec-grey-2); }
  128. #split-control-panel-youtube .split-input-group button { background: var(--yt-spec-filled-button-background); border: 1px solid var(--yt-spec-action-simulate-border); } #split-control-panel-youtube .split-input-group button:hover { background: var(--yt-spec-grey-2); }
  129. #split-control-panel-youtube .set-split-button { background: var(--yt-spec-brand-suggested-action); color: var(--yt-spec-text-reverse); } #split-control-panel-youtube .set-split-button:hover { background: var(--yt-spec-brand-suggested-action-hover); }
  130. #split-control-panel-youtube .set-split-button.active { background: var(--yt-spec-call-to-action); } #split-control-panel-youtube .set-split-button.active:hover { background: var(--yt-spec-call-to-action-hover); }
  131. #split-control-panel-youtube .split-volume-control label { color: var(--yt-spec-text-secondary); }
  132. #split-control-panel-youtube .split-volume-control input[type="range"] { background: var(--yt-spec-grey-1); }
  133. #split-control-panel-youtube .split-volume-control input[type="range"]::-webkit-slider-thumb { background: var(--yt-spec-brand-button-background); } #split-control-panel-youtube .split-volume-control input[type="range"]::-moz-range-thumb { background: var(--yt-spec-brand-button-background); }
  134. #split-control-panel-youtube .split-stats { color: var(--yt-spec-text-primary); }
  135. `
  136. }
  137. };
  138.  
  139. // --- ОБЩИЕ СТИЛИ (Панель + Оверлей) ---
  140. function injectGlobalStyles() {
  141. let platformStyles = platformConfig ? platformConfig.styles : '';
  142.  
  143. GM_addStyle(`
  144. /* --- Общие стили панели управления --- */
  145. .split-control-panel-universal { margin-top: 10px; margin-bottom: 15px; padding: 10px 15px; border-radius: 8px; display: flex; flex-direction: column; align-items: flex-start; gap: 8px; font-family: -apple-system, BlinkMacSystemFont, "Roboto", "Helvetica Neue", Geneva, "Noto Sans Armenian", "Noto Sans Bengali", "Noto Sans Cherokee", "Noto Sans Devanagari", "Noto Sans Ethiopic", "Noto Sans Georgian", "Noto Sans Hebrew", "Noto Sans Kannada", "Noto Sans Khmer", "Noto Sans Lao", "Noto Sans Osmanya", "Noto Sans Tamil", "Noto Sans Telugu", "Noto Sans Thai", sans-serif,arial,Tahoma,verdana; font-size: 13px; width: 100%; box-sizing: border-box; line-height: 1.4; }
  146. /* Reduced margin-bottom on the title */
  147. .split-control-panel-universal .panel-video-title { font-size: 14px; font-weight: bold; margin-bottom: 0; max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } /* Style for panel title */
  148. .split-control-panel-universal .panel-controls-row { display: flex; flex-wrap: wrap; align-items: center; gap: 10px 15px; width: 100%; }
  149. .split-control-panel-universal label { font-weight: 500; flex-shrink: 0; }
  150. .split-control-panel-universal label i { font-style: normal; font-size: 11px; display: block; }
  151. .split-input-group { display: flex; align-items: center; gap: 4px; }
  152. .split-control-panel-universal input[type="number"] { width: 55px; padding: 6px 8px; border-radius: 4px; text-align: center; font-size: 14px; -moz-appearance: textfield; }
  153. .split-control-panel-universal input[type="number"]::-webkit-outer-spin-button, .split-control-panel-universal input[type="number"]::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; }
  154. .split-control-panel-universal button { padding: 6px 12px; font-size: 13px; cursor: pointer; border-radius: 4px; transition: background 0.15s ease-in-out; font-weight: 500; flex-shrink: 0; line-height: normal; }
  155. .split-input-group button { padding: 6px 8px; }
  156. .set-split-button { order: -1; margin-right: auto; } /* Keep set button to the left */
  157. .split-volume-control { display: flex; align-items: center; gap: 5px; margin-left: auto; }
  158. .split-volume-control label { flex-shrink: 0; }
  159. .split-volume-control input[type="range"] { flex-grow: 1; min-width: 70px; -webkit-appearance: none; appearance: none; height: 6px; outline: none; opacity: 0.8; transition: opacity .2s; border-radius: 3px; cursor: pointer; }
  160. .split-volume-control input[type="range"]:hover { opacity: 1; }
  161. .split-control-panel-universal input[type="range"]::-webkit-slider-thumb { -webkit-appearance: none; appearance: none; width: 12px; height: 12px; cursor: pointer; border-radius: 50%; }
  162. .split-control-panel-universal input[type="range"]::-moz-range-thumb { width: 12px; height: 12px; cursor: pointer; border-radius: 50%; border: none; }
  163. .split-stats { font-size: 14px; font-weight: 500; white-space: nowrap; }
  164.  
  165. /* --- Общие стили оверлея --- */
  166. .split-overlay-universal { position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0, 0, 0, 0.95); color: white; display: flex; flex-direction: column; justify-content: center; align-items: center; z-index: 999999 !important; font-family: -apple-system, BlinkMacSystemFont, "Roboto", "Helvetica Neue", Geneva, "Noto Sans Armenian", "Noto Sans Bengali", "Noto Sans Cherokee", "Noto Sans Devanagari", "Noto Sans Ethiopic", "Noto Sans Georgian", "Noto Sans Hebrew", "Noto Sans Kannada", "Noto Sans Khmer", "Noto Sans Lao", "Noto Sans Osmanya", "Noto Sans Tamil", "Noto Sans Telugu", "Noto Sans Thai", sans-serif,arial,Tahoma,verdana; text-align: center; padding: 20px; box-sizing: border-box; }
  167. .split-overlay-universal .overlay-video-title { font-size: clamp(18px, 3vw, 28px); margin-bottom: 15px; color: #ccc; font-weight: normal; max-width: 90%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
  168. .split-overlay-universal .warning-message { font-size: clamp(24px, 4vw, 36px); margin-bottom: 15px; color: yellow; font-weight: bold; text-shadow: 0 0 8px rgba(255, 255, 0, 0.5); }
  169. .split-overlay-universal .main-message { font-size: clamp(40px, 8vw, 72px); font-weight: bold; margin-bottom: 20px; color: red; text-shadow: 0 0 15px rgba(255, 0, 0, 0.7); }
  170. .split-overlay-universal .overlay-timer, .split-overlay-universal .overlay-remaining-minutes { font-size: clamp(28px, 5vw, 48px); font-weight: bold; margin-bottom: 20px; }
  171. .split-overlay-universal .overlay-timer { color: orange; } .split-overlay-universal .overlay-remaining-minutes { color: cyan; }
  172. .split-overlay-universal .overlay-timer-control { margin-bottom: 20px; display: flex; align-items: center; gap: 10px; flex-wrap: wrap; justify-content: center; color: white; font-size: 18px; }
  173. .split-overlay-universal .overlay-timer-control label { font-weight: 500; }
  174. .split-overlay-universal .overlay-timer-control input[type="number"] { width: 70px; padding: 8px 10px; background: rgba(255, 255, 255, 0.1); color: white; border: 1px solid rgba(255, 255, 255, 0.3); border-radius: 4px; text-align: center; font-size: 18px; -moz-appearance: textfield; }
  175. .split-overlay-universal .overlay-timer-control input[type="number"]::-webkit-outer-spin-button, .split-overlay-universal .overlay-timer-control input[type="number"]::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; }
  176. .split-overlay-universal .overlay-timer-control button { padding: 8px 12px; font-size: 16px; cursor: pointer; background: rgba(255, 255, 255, 0.1); color: white; border: 1px solid rgba(255, 255, 255, 0.3); border-radius: 4px; transition: background 0.2s ease-in-out; font-weight: 500; }
  177. .split-overlay-universal .overlay-timer-control button:hover { background: rgba(255, 255, 255, 0.2); }
  178. .split-overlay-universal .extend-buttons { display: flex; gap: 15px; flex-wrap: wrap; justify-content: center; margin-bottom: 40px; }
  179. .split-overlay-universal .extend-buttons button { padding: 12px 25px; font-size: clamp(18px, 3vw, 24px); cursor: pointer; background: #dc3545; border: none; color: white; border-radius: 4px; font-weight: bold; transition: background 0.2s ease-in-out; }
  180. .split-overlay-universal .extend-buttons button:hover { background: #c82333; }
  181. .split-overlay-universal img { max-width: 90%; max-height: 45vh; height: auto; border-radius: 8px; margin-top: 20px; }
  182.  
  183. /* --- Стили конкретных платформ --- */
  184. ${platformStyles}
  185. `);
  186. }
  187.  
  188. // --- ФУНКЦИИ ЯДРА ---
  189.  
  190. function getElementId(baseId) {
  191. return platformConfig ? `${baseId}-${platformConfig.idPrefix}` : baseId;
  192. }
  193.  
  194. // Helper function to get the video title from the DOM
  195. function getVideoTitle() {
  196. if (!platformConfig?.videoTitleSelector) return "Неизвестное видео";
  197. const titleElement = document.querySelector(platformConfig.videoTitleSelector);
  198. let title = "Неизвестное видео";
  199. if (titleElement) {
  200. // Use textContent and trim for clean text
  201. title = titleElement.textContent ? titleElement.textContent.trim() : "Неизвестное видео";
  202. if (!title) title = "Неизвестное видео"; // Fallback if trim results in empty string
  203. } else {
  204. // GM_log(`[${currentPlatform}] Video title element not found with selector: ${platformConfig.videoTitleSelector}`);
  205. }
  206. return title;
  207. }
  208.  
  209. function updateSplitDisplay() {
  210. const inputField = document.getElementById(getElementId("split-input"));
  211. if (inputField) inputField.valueAsNumber = splitMinutes === null ? 0 : splitMinutes;
  212. updateSplitStatsDisplay();
  213. // Also update the panel title text if the panel exists
  214. const panelTitleElement = panelElement?.querySelector('.panel-video-title');
  215. if (panelTitleElement) {
  216. panelTitleElement.textContent = getVideoTitle();
  217. }
  218. }
  219.  
  220. function updateSplitStatsDisplay() {
  221. const statsElement = document.getElementById(getElementId("split-stats"));
  222. if (statsElement) {
  223. const boughtMinutes = splitMinutes === null ? 0 : splitMinutes;
  224. statsElement.textContent = `Выкуплено: ${boughtMinutes} / Всего: ${totalVideoMinutes !== null ? totalVideoMinutes : '?'} минут`;
  225. }
  226. if (overlay) updateOverlayRemainingMinutes();
  227. }
  228.  
  229. function modifySplitInput(minutesToModify) {
  230. const inputField = document.getElementById(getElementId("split-input"));
  231. if (!inputField) return;
  232. let currentVal = inputField.valueAsNumber;
  233. if (isNaN(currentVal)) currentVal = 0;
  234. let newVal = currentVal + minutesToModify;
  235. if (newVal < 0) newVal = 0;
  236. inputField.valueAsNumber = newVal;
  237. }
  238.  
  239. function modifyTimerInputOverlay(secondsToModify) {
  240. const inputField = document.getElementById(getElementId("overlay-timer-input"));
  241. if (!inputField) return;
  242. let currentVal = inputField.valueAsNumber;
  243. if (isNaN(currentVal)) currentVal = 0;
  244. let newVal = currentVal + secondsToModify;
  245. if (newVal < 0) newVal = 0;
  246. inputField.valueAsNumber = newVal;
  247.  
  248. overlayTimerDuration = newVal;
  249. localStorage.setItem(localStorageTimerKey, overlayTimerDuration.toString());
  250. overlayCountdownRemaining = overlayTimerDuration;
  251. if (overlayCountdownRemaining < 0) overlayCountdownRemaining = 0;
  252.  
  253. if (overlayTimerIntervalId) { clearInterval(overlayTimerIntervalId); overlayTimerIntervalId = null; }
  254. updateOverlayTimer();
  255. if (overlayTimerDuration > 0) {
  256. overlayTimerIntervalId = setInterval(updateOverlayTimer, 1000);
  257. }
  258. }
  259.  
  260. function startSplitCheckInterval() {
  261. if (!splitCheckIntervalId) {
  262. splitCheckIntervalId = setInterval(checkSplitCondition, 500);
  263. GM_log(`[${currentPlatform || 'unknown'}] Split check interval started.`);
  264. }
  265. }
  266.  
  267. function stopSplitCheckInterval() {
  268. if (splitCheckIntervalId) {
  269. clearInterval(splitCheckIntervalId);
  270. splitCheckIntervalId = null;
  271. GM_log(`[${currentPlatform || 'unknown'}] Split check interval stopped.`);
  272. }
  273. }
  274.  
  275. function addMinutesToActiveSplit(minutesToAdd) {
  276. if (splitMinutes === null) return;
  277. splitMinutes += minutesToAdd;
  278. updateSplitDisplay();
  279.  
  280. const thresholdSeconds = splitMinutes * 60;
  281. // If video is before the new threshold AND overlay was active, remove overlay
  282. if (video && isFinite(video.currentTime) && video.currentTime < thresholdSeconds && splitTriggered) {
  283. GM_log(`[${currentPlatform}] Added minutes, new split threshold ${thresholdSeconds}s. Current time ${video.currentTime.toFixed(1)}s is before threshold. Removing overlay.`);
  284. removeOverlay();
  285. splitTriggered = false; // Reset the flag
  286.  
  287. // Resume video if it was paused by the split, UNLESS the user paused it manually
  288. // This is tricky - simply attempting play is the most straightforward approach
  289. if (video.paused) {
  290. GM_log(`[${currentPlatform}] Attempting to play video after adding minutes.`);
  291. video.play().catch(e => GM_log(`[${currentPlatform}] Error playing video after adding minutes: ${e.message}`));
  292. }
  293. }
  294. GM_log(`[${currentPlatform}] Added ${minutesToAdd} minutes. New split: ${splitMinutes} minutes (${splitMinutes*60}s).`);
  295. }
  296.  
  297.  
  298. function updateOverlayTimer() {
  299. const timerElement = document.getElementById(getElementId('overlay-timer'));
  300. if (!timerElement) {
  301. if (overlayTimerIntervalId) { clearInterval(overlayTimerIntervalId); overlayTimerIntervalId = null; }
  302. return;
  303. }
  304. if (overlayCountdownRemaining > 0) {
  305. const minutes = Math.floor(overlayCountdownRemaining / 60);
  306. const seconds = overlayCountdownRemaining % 60;
  307. timerElement.textContent = `ЖДЕМ ${minutes}:${seconds < 10 ? '0' : ''}${seconds}, ИНАЧЕ СКИП`;
  308. overlayCountdownRemaining--;
  309. } else {
  310. timerElement.textContent = `ЖДЕМ 0:00, ИНАЧЕ СКИП`;
  311. // Timer reached 0. Decide what to do? Maybe jump ahead or just stay paused?
  312. // Current logic: stay paused and wait for user interaction (add minutes) or manual resume.
  313. if (overlayTimerIntervalId) { clearInterval(overlayTimerIntervalId); overlayTimerIntervalId = null; }
  314. }
  315. }
  316.  
  317. function updateOverlayRemainingMinutes() {
  318. const remainingElement = document.getElementById(getElementId('overlay-remaining-minutes'));
  319. if (remainingElement) {
  320. const remainingMinutes = totalVideoMinutes !== null && splitMinutes !== null ? Math.max(0, totalVideoMinutes - splitMinutes) : '?';
  321. remainingElement.textContent = `ОСТАЛОСЬ ${remainingMinutes} минут выкупить`;
  322. }
  323. }
  324.  
  325. // Function to try and prime the audio context (help bypass autoplay)
  326. // This attempts to play a muted sound on the video's first 'play' event.
  327. function primeAudio() {
  328. // Only attempt if audio player exists, video exists, and we haven't added the listener for this video instance yet
  329. if (audioPlayer && video && !videoPlayListenerAdded) {
  330. GM_log(`[${currentPlatform}] Adding 'play' listener to video for audio priming.`);
  331. // Store the listener function to be able to remove it later if needed (e.g. on reset)
  332. const listener = function _listener() {
  333. GM_log(`[${currentPlatform}] Video started playing. Attempting to prime audio...`);
  334. // Check if audioPlayer is still valid when the event fires
  335. if (!audioPlayer) {
  336. GM_log(`[${currentPlatform}] Audio player became null before priming attempt. (Likely due to load error)`);
  337. return; // Cannot prime if player is null
  338. }
  339.  
  340. // Try to play a muted sound immediately
  341. const originalVolume = audioPlayer.volume;
  342. audioPlayer.volume = 0; // Play muted
  343. audioPlayer.play().then(() => {
  344. GM_log(`[${currentPlatform}] Audio priming successful (muted playback started).`);
  345. audioPrimed = true;
  346. // Pause the muted sound immediately, unless it finished already (very short sound)
  347. if (!audioPlayer.paused) {
  348. audioPlayer.pause();
  349. }
  350. audioPlayer.currentTime = 0; // Reset sound position
  351. audioPlayer.volume = originalVolume; // Restore volume
  352. }).catch(e => {
  353. GM_log(`[${currentPlatform}] Audio priming play() Promise rejected: ${e.message}. This may be due to browser autoplay policies preventing even muted playback without stronger user gesture.`);
  354. audioPrimed = false; // Priming failed
  355. audioPlayer.volume = originalVolume; // Restore volume
  356. });
  357. // Remove the listener after it triggers once.
  358. // Using { once: true } is the modern way.
  359. videoPlayListenerAdded = true; // Mark the listener as added for this video instance
  360. };
  361.  
  362. // Add the listener using { once: true } for automatic removal after first trigger
  363. video.addEventListener('play', listener, { once: true });
  364. // Also set the flag immediately to prevent adding multiple listeners via the interval
  365. videoPlayListenerAdded = true;
  366.  
  367. } else if (!audioPlayer) {
  368. // GM_log(`[${currentPlatform}] Audio player not initialized, cannot prime audio.`);
  369. } else if (!video) {
  370. // GM_log(`[${currentPlatform}] Video element not found yet, cannot prime audio.`);
  371. } else if (videoPlayListenerAdded) {
  372. // GM_log(`[${currentPlatform}] 'play' listener already added for this video.`);
  373. }
  374. }
  375.  
  376.  
  377. function checkSplitCondition() {
  378. if (!platformConfig) return;
  379.  
  380. // Ensure video element is found and audio player is initialized/primed
  381. if (!video) {
  382. video = document.querySelector(platformConfig.videoSelector);
  383. if (!video) return; // Exit if video element isn't available yet
  384. GM_log(`[${currentPlatform}] Video element found during split check.`);
  385. // Initialize audio player and attempt priming immediately upon finding video
  386. initAudioPlayer();
  387. primeAudio();
  388. const volumeSlider = document.getElementById(getElementId('split-volume-slider'));
  389. if (audioPlayer && volumeSlider) { try { audioPlayer.volume = parseFloat(volumeSlider.value); } catch(e){} }
  390. }
  391.  
  392. // Get total video duration if available and not already set
  393. if (video && totalVideoMinutes === null && isFinite(video.duration) && video.duration > 0) {
  394. totalVideoMinutes = Math.ceil(video.duration / 60);
  395. GM_log(`[${currentPlatform}] Total video duration found: ${totalVideoMinutes} minutes.`);
  396. if (panelAdded) updateSplitStatsDisplay();
  397. }
  398. // Note: Handling live streams or cases where duration changes might require more logic.
  399. // For now, totalVideoMinutes is set once if a valid duration is found.
  400.  
  401. // Check the split condition only if splitMinutes is set and greater than 0, and video exists
  402. if (video && splitMinutes !== null && splitMinutes > 0) {
  403. const thresholdSeconds = splitMinutes * 60;
  404.  
  405. // Trigger split if current time is at or past the threshold AND split hasn't been triggered yet for this segment
  406. if (isFinite(video.currentTime) && video.currentTime >= thresholdSeconds && !splitTriggered) {
  407. GM_log(`[${currentPlatform}] Split condition met at ${video.currentTime.toFixed(1)}s (threshold: ${thresholdSeconds}s). Triggering split...`);
  408. video.pause(); // Pause the video
  409. splitTriggered = true; // Set the flag to prevent re-triggering
  410. showOverlay(); // Show the overlay
  411.  
  412. // Play the alert sound ONLY if audioPlayer exists (i.e., it loaded successfully)
  413. if (audioPlayer) {
  414. try {
  415. // Pause and reset *before* playing to ensure it starts from the beginning each time
  416. audioPlayer.pause();
  417. audioPlayer.currentTime = 0;
  418. GM_log(`[${currentPlatform}] Attempting to play split sound.`); // Log before playing
  419. // Play the sound and log the promise result
  420. audioPlayer.play().then(() => {
  421. GM_log(`[${currentPlatform}] Split sound play() Promise resolved successfully.`);
  422. }).catch(e => {
  423. // This catch block specifically handles rejections from the play() promise
  424. GM_log(`[${currentPlatform}] Split sound play() Promise rejected: ${e.message}. This may be due to browser autoplay policies.`);
  425. console.error("Universal Split: Ошибка воспроизведения звука (вероятно, политика автоплея):", e);
  426. });
  427. } catch(e) {
  428. // This catch block handles synchronous errors (e.g., audioPlayer became null unexpectedly)
  429. GM_log(`[${currentPlatform}] Error during audio playback attempt (sync error?): ${e.message}`);
  430. console.error("Universal Split: Синхронная ошибка при попытке воспроизведения звука:", e);
  431. }
  432. } else {
  433. GM_log(`[${currentPlatform}] audioPlayer is null, cannot play split sound. (Likely due to load error)`);
  434. // If audioPlayer is null here, it means initAudioPlayer failed earlier (due to format or blocked error)
  435. // We don't try to re-initialize immediately here, as it might hit the same error.
  436. }
  437. }
  438.  
  439. // Handle the case where the user *manually seeks back* before the split point while the overlay is active
  440. if (splitTriggered && isFinite(video.currentTime) && video.currentTime < thresholdSeconds) {
  441. GM_log(`[${currentPlatform}] Video time (${video.currentTime.toFixed(1)}s) is now before split threshold (${thresholdSeconds}s) after seeking back. Removing overlay.`);
  442. removeOverlay(); // Remove the overlay
  443. splitTriggered = false; // Reset the flag so it can trigger again if the user seeks forward
  444.  
  445. // Resume video if it was paused by the split
  446. // This assumes if video.paused is true AND splitTriggered was true, the split paused it.
  447. // It's not perfect but works in most cases.
  448. if (video.paused) {
  449. GM_log(`[${currentPlatform}] Attempting to play video after seeking back past split point.`);
  450. video.play().catch(e => GM_log(`[${currentPlatform}] Attempted to play video after seeking back past split point failed: ${e.message}`));
  451. }
  452. }
  453. } else if (splitTriggered) {
  454. // If splitMinutes is 0 or null, and the split was previously triggered, clean up
  455. GM_log(`[${currentPlatform}] Split cancelled (splitMinutes is ${splitMinutes}). Removing overlay.`);
  456. removeOverlay(); // Remove the overlay
  457. splitTriggered = false; // Reset the flag
  458. // No need to resume video here, as it would only be paused if splitMinutes > 0 initially.
  459. }
  460. }
  461.  
  462. function showOverlay() {
  463. if (overlay) return; // Only show if not already visible
  464. if (!platformConfig) return; // Need config to proceed
  465.  
  466. GM_log(`[${currentPlatform}] Showing split overlay.`);
  467.  
  468. // Get video title for the overlay
  469. const videoTitle = getVideoTitle();
  470.  
  471. overlay = document.createElement("div");
  472. overlay.id = getElementId("split-overlay");
  473. overlay.className = 'split-overlay-universal';
  474.  
  475. // --- ADD VIDEO TITLE TO OVERLAY ---
  476. const titleElement = document.createElement("div");
  477. titleElement.className = "overlay-video-title";
  478. titleElement.textContent = videoTitle;
  479. overlay.appendChild(titleElement); // Add title at the top of the overlay
  480. // --- END ADD VIDEO TITLE TO OVERLAY ---
  481.  
  482.  
  483. const warningMessage = document.createElement("div");
  484. warningMessage.className = "warning-message";
  485. warningMessage.textContent = "⚠️ НУЖНО ДОНАТНОЕ ТОПЛИВО ⚠️";
  486. overlay.appendChild(warningMessage);
  487.  
  488.  
  489. const mainMessage = document.createElement("div");
  490. mainMessage.className = "main-message";
  491. mainMessage.textContent = "СПЛИТ НЕ ОПЛАЧЕН";
  492. overlay.appendChild(mainMessage);
  493.  
  494.  
  495. const timerElement = document.createElement("div");
  496. timerElement.id = getElementId('overlay-timer');
  497. timerElement.className = "overlay-timer";
  498. overlay.appendChild(timerElement);
  499.  
  500.  
  501. const remainingMinutesElement = document.createElement("div");
  502. remainingMinutesElement.id = getElementId('overlay-remaining-minutes');
  503. remainingMinutesElement.className = "overlay-remaining-minutes";
  504. overlay.appendChild(remainingMinutesElement);
  505.  
  506.  
  507. const overlayTimerControlGroup = document.createElement("div");
  508. overlayTimerControlGroup.id = getElementId('overlay-timer-control');
  509. overlayTimerControlGroup.className = "overlay-timer-control";
  510.  
  511. const timerLabel = document.createElement("label");
  512. timerLabel.setAttribute("for", getElementId('overlay-timer-input'));
  513. timerLabel.textContent = "Таймер (сек):";
  514. overlayTimerControlGroup.appendChild(timerLabel);
  515.  
  516. const timerInputField = document.createElement("input");
  517. timerInputField.type = "number";
  518. timerInputField.id = getElementId('overlay-timer-input');
  519. timerInputField.min = "0";
  520. timerInputField.value = overlayTimerDuration; // Initialize with saved duration
  521. overlayTimerControlGroup.appendChild(timerInputField);
  522.  
  523.  
  524. const timerButtons = [
  525. { text: '-60', seconds: -60 }, { text: '-10', seconds: -10 }, { text: '-5', seconds: -5 },
  526. { text: '+5', seconds: 5 }, { text: '+10', seconds: 10 }, { text: '+60', seconds: 60 }
  527. ];
  528. timerButtons.forEach(btnInfo => {
  529. const button = document.createElement("button");
  530. button.textContent = btnInfo.text;
  531. button.dataset.seconds = btnInfo.seconds;
  532. overlayTimerControlGroup.appendChild(button);
  533. });
  534. overlay.appendChild(overlayTimerControlGroup);
  535.  
  536.  
  537. const extendButtonsContainer = document.createElement("div");
  538. extendButtonsContainer.id = getElementId('split-extend-buttons');
  539. extendButtonsContainer.className = "extend-buttons";
  540. const extendButtonConfigs = [
  541. { minutes: 1, cost: extendCost }, { minutes: 5, cost: extendCost * 5 },
  542. { minutes: 10, cost: extendCost * 10 }, { minutes: 20, cost: extendCost * 20 }
  543. ];
  544. extendButtonConfigs.forEach(config => {
  545. const button = document.createElement("button");
  546. button.textContent = `+ ${config.minutes} минут${getMinuteEnding(config.minutes)} - ${config.cost} рублей`;
  547. button.addEventListener("click", () => addMinutesToActiveSplit(config.minutes));
  548. extendButtonsContainer.appendChild(button);
  549. });
  550. overlay.appendChild(extendButtonsContainer);
  551.  
  552.  
  553. const gifElement = document.createElement("img");
  554. gifElement.src = overlayGifUrl;
  555. gifElement.alt = "Split GIF";
  556. overlay.appendChild(gifElement);
  557.  
  558.  
  559. document.body.appendChild(overlay); // Add overlay to the DOM
  560.  
  561. // Add event listeners for timer controls *after* adding to DOM
  562. overlay.querySelector(`#${getElementId('overlay-timer-input')}`).addEventListener('input', function() {
  563. const val = this.valueAsNumber;
  564. if (!isNaN(val) && val >= 0) {
  565. overlayTimerDuration = val;
  566. localStorage.setItem(localStorageTimerKey, overlayTimerDuration.toString());
  567. overlayCountdownRemaining = overlayTimerDuration; // Reset countdown when duration changes
  568. if (overlayTimerIntervalId) { clearInterval(overlayTimerIntervalId); overlayTimerIntervalId = null; }
  569. if (overlayTimerDuration > 0) overlayTimerIntervalId = setInterval(updateOverlayTimer, 1000);
  570. updateOverlayTimer(); // Update immediately
  571. } else { /* Optional: revert to last valid value on invalid input */ }
  572. });
  573. overlay.querySelectorAll(`#${getElementId('overlay-timer-control')} button`).forEach(button => {
  574. button.addEventListener("click", () => modifyTimerInputOverlay(parseInt(button.dataset.seconds, 10)));
  575. });
  576.  
  577. // Initialize and start the countdown timer
  578. overlayCountdownRemaining = overlayTimerDuration; // Start countdown from the set duration
  579. if (overlayCountdownRemaining < 0) overlayCountdownRemaining = 0; // Ensure non-negative
  580. updateOverlayTimer(); // Initial display update
  581. updateOverlayRemainingMinutes(); // Update remaining minutes display
  582.  
  583. // Start timer interval if duration is positive, otherwise ensure it's stopped
  584. if (overlayTimerDuration > 0 && !overlayTimerIntervalId) {
  585. overlayTimerIntervalId = setInterval(updateOverlayTimer, 1000);
  586. } else if (overlayTimerDuration <= 0 && overlayTimerIntervalId) {
  587. clearInterval(overlayTimerIntervalId); overlayTimerIntervalId = null;
  588. }
  589. }
  590.  
  591. function getMinuteEnding(count) {
  592. count = Math.abs(count); const d1 = count % 10; const d2 = count % 100;
  593. if (d2 >= 11 && d2 <= 19) return ''; if (d1 === 1) return 'а'; if (d1 >= 2 && d1 <= 4) return 'ы'; return '';
  594. }
  595.  
  596. function removeOverlay() {
  597. if (overlay) {
  598. GM_log(`[${currentPlatform || 'unknown'}] Removing split overlay.`);
  599. overlay.remove(); // Remove element from DOM
  600. overlay = null; // Clear the variable
  601.  
  602. // Stop the timer interval if it's running
  603. if (overlayTimerIntervalId) { clearInterval(overlayTimerIntervalId); overlayTimerIntervalId = null; }
  604.  
  605. // Pause the audio player if it's playing the alert sound
  606. if (audioPlayer) {
  607. try { audioPlayer.pause(); } catch(e){}
  608. }
  609. }
  610. }
  611.  
  612. function initAudioPlayer() {
  613. // Only create a new Audio player if one doesn't exist AND we have a valid sound URL
  614. // The check `audioPlayer === null` is crucial here if a previous attempt failed.
  615. if (audioPlayer === null && splitSoundUrl && !splitSoundUrl.includes('YOUR_DIRECT_URL_HERE')) {
  616. GM_log(`[${currentPlatform || 'unknown'}] Initializing audio player with URL: ${splitSoundUrl}`);
  617. try {
  618. audioPlayer = new Audio(splitSoundUrl);
  619. audioPlayer.preload = 'auto'; // Start loading the audio early
  620. audioPlayer.loop = true; // IMPORTANT: Ensure the sound does NOT loop
  621. // Handle errors during loading/decoding
  622. audioPlayer.onerror = (e) => {
  623. const errorMsg = e.message || e.target.error?.message || 'Unknown error';
  624. GM_log(`[${currentPlatform || 'unknown'}] ERROR loading audio: ${errorMsg}. Audio player will be null.`);
  625. console.error("Universal Split: Ошибка загрузки звука:", e);
  626. audioPlayer = null; // Explicitly set player to null on error
  627. audioPrimed = false; // Reset priming state
  628. videoPlayListenerAdded = false; // Reset listener flag
  629. // You might want to visually inform the user that sound won't work here.
  630. // alert("Ошибка загрузки звука для сплита. Проверьте URL звука или настройки браузера.");
  631. };
  632.  
  633. // Set volume from local storage or use the default
  634. let savedVolume = localStorage.getItem(localStorageVolumeKey) ?? defaultAlertVolume;
  635. audioPlayer.volume = parseFloat(savedVolume);
  636.  
  637. // Add event listener to log when sound finishes playing (for debugging)
  638. audioPlayer.onended = () => {
  639. GM_log(`[${currentPlatform}] Split sound finished playing.`);
  640. };
  641.  
  642. audioPrimed = false; // Reset priming state for the new player instance
  643. videoPlayListenerAdded = false; // Reset video listener flag for the new player instance
  644.  
  645. GM_log(`[${currentPlatform || 'unknown'}] Audio player object created.`);
  646.  
  647.  
  648. } catch (error) {
  649. // Handle errors that occur *during* the 'new Audio()' call itself
  650. GM_log(`[${currentPlatform || 'unknown'}] ERROR creating Audio object: ${error.message}`);
  651. console.error("Universal Split: Ошибка создания Audio:", error);
  652. audioPlayer = null; // Ensure player is null on creation error
  653. audioPrimed = false;
  654. videoPlayListenerAdded = false;
  655. }
  656. } else if (audioPlayer !== null) { // If audioPlayer exists AND is not null (meaning it didn't fail loading earlier)
  657. // If player already exists, just ensure settings are correct (e.g., after navigation)
  658. let savedVolume = localStorage.getItem(localStorageVolumeKey) ?? defaultAlertVolume;
  659. try { audioPlayer.volume = parseFloat(savedVolume); } catch(e){}
  660. audioPlayer.loop = true; // Double-check loop is false
  661. // Re-add onended listener if needed (usually persists unless player was null)
  662. if (!audioPlayer.onended) {
  663. audioPlayer.onended = () => { GM_log(`[${currentPlatform}] Split sound finished playing.`); };
  664. }
  665. // audioPrimed and videoPlayListenerAdded state persist with the existing audioPlayer instance
  666. }
  667. // If audioPlayer is still null here, it means init failed and we don't have a player.
  668. }
  669.  
  670. // --- ФУНКЦИИ УПРАВЛЕНИЯ ПАНЕЛЬЮ ---
  671.  
  672. function addControlPanel() {
  673. // Prevent adding the panel if it's already added or if no config is available
  674. if (panelAdded || !platformConfig) return;
  675. const insertionElement = document.querySelector(platformConfig.insertionSelector);
  676. // We need the insertion element to place the panel
  677. if (!insertionElement) { GM_log(`[${currentPlatform}] Insertion element (${platformConfig.insertionSelector}) not found.`); return; }
  678.  
  679. GM_log(`[${currentPlatform}] Adding control panel...`);
  680. panelElement = document.createElement("div");
  681. panelElement.id = getElementId("split-control-panel");
  682. panelElement.className = 'split-control-panel-universal'; // Apply universal styles + platform-specific via id
  683.  
  684. // --- GET VIDEO TITLE FOR PANEL AND ADD TO PANEL ---
  685. const videoTitle = getVideoTitle(); // Use the helper function
  686. const panelTitleElement = document.createElement("div");
  687. panelTitleElement.className = "panel-video-title";
  688. panelTitleElement.textContent = videoTitle;
  689. panelElement.appendChild(panelTitleElement); // Add title at the top
  690. // --- END ADD VIDEO TITLE TO PANEL ---
  691.  
  692.  
  693. // --- Create a row for controls and stats to manage layout ---
  694. const controlsRow = document.createElement("div");
  695. controlsRow.className = "panel-controls-row";
  696.  
  697. const setButton = document.createElement("button");
  698. setButton.id = getElementId('set-split-button');
  699. setButton.className = 'set-split-button';
  700. setButton.textContent = "НАЧАТЬ СПЛИТ";
  701. // Set button text and class based on current split state
  702. if (splitMinutes !== null && splitMinutes > 0) {
  703. setButton.textContent = "СПЛИТ НАЧАТ";
  704. setButton.classList.add("active");
  705. }
  706. controlsRow.appendChild(setButton); // Add set button to the row
  707.  
  708.  
  709. const splitLabel = document.createElement("label");
  710. splitLabel.setAttribute("for", getElementId('split-input'));
  711. splitLabel.appendChild(document.createTextNode("Сплит (мин):"));
  712. const splitLabelInstruction = document.createElement("i");
  713. splitLabelInstruction.textContent = "(уст. перед \"Начать\")";
  714. splitLabel.appendChild(splitLabelInstruction);
  715. controlsRow.appendChild(splitLabel); // Add label to the row
  716.  
  717. const splitInputGroup = document.createElement("div");
  718. splitInputGroup.id = getElementId('split-input-group');
  719. splitInputGroup.className = 'split-input-group';
  720.  
  721. const splitInputField = document.createElement("input");
  722. splitInputField.type = "number";
  723. splitInputField.id = getElementId('split-input');
  724. splitInputField.min = "0";
  725. splitInputField.value = splitMinutes === null ? 0 : splitMinutes; // Initialize input with current split state
  726. splitInputGroup.appendChild(splitInputField);
  727.  
  728. const splitModifyButtons = [ { text: '+1', minutes: 1 }, { text: '+5', minutes: 5 }, { text: '+10', minutes: 10 }, { text: '+20', minutes: 20 } ];
  729. splitModifyButtons.forEach(btnInfo => {
  730. const button = document.createElement("button");
  731. button.textContent = btnInfo.text; button.dataset.minutes = btnInfo.minutes;
  732. splitInputGroup.appendChild(button);
  733. });
  734. controlsRow.appendChild(splitInputGroup); // Add input group to the row
  735.  
  736.  
  737. const volumeControlGroup = document.createElement("div");
  738. volumeControlGroup.id = getElementId('split-volume-control');
  739. volumeControlGroup.className = 'split-volume-control';
  740. const volumeLabel = document.createElement("label");
  741. volumeLabel.setAttribute("for", getElementId('split-volume-slider'));
  742. volumeLabel.textContent = "Громк. алерта:";
  743. const volumeSlider = document.createElement("input");
  744. volumeSlider.type = "range"; volumeSlider.id = getElementId('split-volume-slider');
  745. volumeSlider.min = "0"; volumeSlider.max = "1"; volumeSlider.step = "0.05";
  746. volumeSlider.value = localStorage.getItem(localStorageVolumeKey) ?? defaultAlertVolume; // Initialize slider with saved volume or default
  747. volumeControlGroup.appendChild(volumeLabel); volumeControlGroup.appendChild(volumeSlider);
  748. controlsRow.appendChild(volumeControlGroup); // Add volume control to the row
  749.  
  750.  
  751. const statsElement = document.createElement("span");
  752. statsElement.id = getElementId('split-stats'); statsElement.className = 'split-stats';
  753. statsElement.textContent = "Выкуплено: 0 / Всего: ? минут"; // Initial text, updated by updateSplitDisplay/Stats
  754.  
  755. panelElement.appendChild(controlsRow); // Add the row containing controls (button, input, volume)
  756. panelElement.appendChild(statsElement); // Add stats element below the controls row
  757.  
  758.  
  759. // Insert the panel into the DOM according to platform config
  760. switch (platformConfig.insertionMethod) {
  761. case 'prepend': insertionElement.insertBefore(panelElement, insertionElement.firstChild); break;
  762. case 'appendChild': insertionElement.appendChild(panelElement); break;
  763. case 'before': insertionElement.parentNode.insertBefore(panelElement, insertionElement); break;
  764. case 'afterend': insertionElement.parentNode.insertBefore(panelElement, insertionElement.nextSibling); break;
  765. default: insertionElement.insertBefore(panelElement, insertionElement.firstChild); // Default to prepend
  766. }
  767. panelAdded = true; // Mark panel as added
  768.  
  769. // --- Event Listeners ---
  770. setButton.addEventListener("click", () => {
  771. const inputVal = parseInt(splitInputField.value, 10);
  772. if (!isNaN(inputVal) && inputVal >= 0) {
  773. splitMinutes = inputVal; // Set the global splitMinutes state
  774. if (splitMinutes > 0) {
  775. startSplitCheckInterval(); // Start the interval if split is active
  776. setButton.textContent = "СПЛИТ НАЧАТ";
  777. setButton.classList.add("active");
  778. // Attempt autoplay if video is paused when split starts
  779. if (video && video.paused) {
  780. GM_log(`[${currentPlatform}] Attempting to play video on split start...`);
  781. video.play().catch(e => GM_log(`[${currentPlatform}] Autoplay on split start failed (likely autoplay policy): ${e.message}`));
  782. }
  783. } else { // splitMinutes is 0 or set to 0
  784. stopSplitCheckInterval(); // Stop the interval if split is disabled
  785. splitTriggered = false; // Ensure the triggered flag is false
  786. removeOverlay(); // Remove overlay if it was visible
  787. setButton.textContent = "НАЧАТЬ СПЛИТ";
  788. setButton.classList.remove("active");
  789. }
  790. updateSplitDisplay(); // Update stats/input field display
  791. } else {
  792. alert("Введите корректное число минут.");
  793. splitInputField.valueAsNumber = splitMinutes === null ? 0 : splitMinutes; // Revert input to last valid value
  794. }
  795. });
  796.  
  797. splitInputGroup.querySelectorAll("button").forEach(button => {
  798. button.addEventListener("click", () => modifySplitInput(parseInt(button.dataset.minutes, 10)));
  799. });
  800.  
  801. volumeSlider.addEventListener("input", function() {
  802. const newVolume = parseFloat(this.value);
  803. if (audioPlayer) audioPlayer.volume = newVolume; // Update audio player volume
  804. localStorage.setItem(localStorageVolumeKey, newVolume.toString()); // Save volume preference
  805. });
  806.  
  807. updateSplitDisplay(); // Initial update for stats and input field (in case splitMinutes was restored)
  808.  
  809. // Initialize visibility check for VK if required by the platform
  810. if (platformConfig.needsVisibilityCheck) {
  811. controlsElement = document.querySelector(platformConfig.controlsElementSelector);
  812. if (controlsElement) startVisibilityCheckInterval();
  813. else GM_log(`[${currentPlatform}] Native controls element (${platformConfig.controlsElementSelector}) not found for visibility check.`);
  814. }
  815.  
  816. // Start the split check interval if split was already active (e.g., after page reload/navigation)
  817. if (splitMinutes !== null && splitMinutes > 0) {
  818. startSplitCheckInterval();
  819. } else {
  820. stopSplitCheckInterval(); // Ensure it's stopped if split is off
  821. }
  822.  
  823. GM_log(`[${currentPlatform}] Control panel added successfully.`);
  824. }
  825.  
  826. // Ensures the panel stays in the correct place on sites with dynamic layouts
  827. function ensurePanelPosition() {
  828. if (!panelAdded || !panelElement || !platformConfig) return;
  829. const insertionElement = document.querySelector(platformConfig.insertionSelector);
  830. // If the element we attach to is gone, remove the panel
  831. if (!insertionElement) { GM_log(`[${currentPlatform}] Insertion element (${platformConfig.insertionSelector}) disappeared. Removing panel.`); removeControlPanel(); return; }
  832.  
  833. // Check if the panel element is currently positioned correctly relative to the insertion element
  834. let currentPositionCorrect = false;
  835. switch(platformConfig.insertionMethod) {
  836. case 'prepend': currentPositionCorrect = (insertionElement.firstChild === panelElement); break;
  837. case 'appendChild': currentPositionCorrect = (insertionElement.lastChild === panelElement); break;
  838. case 'before': currentPositionCorrect = (insertionElement.previousSibling === panelElement); break;
  839. case 'afterend': currentPositionCorrect = (insertionElement.nextSibling === panelElement); break;
  840. default: currentPositionCorrect = (insertionElement.firstChild === panelElement); // Assume prepend if method is undefined
  841. }
  842.  
  843. // Also check if the panel is still a child of *any* element (it might have been removed by site JS)
  844. if (!panelElement.parentNode) {
  845. currentPositionCorrect = false; // It's definitely not in the correct position if it's not attached anywhere
  846. GM_log(`[${currentPlatform}] Panel element detached from DOM. Attempting re-insertion.`);
  847. }
  848.  
  849.  
  850. if (!currentPositionCorrect) {
  851. GM_log(`[${currentPlatform}] Panel position incorrect or detached. Attempting re-insertion...`);
  852. try {
  853. // Remove the element from its current parent before re-inserting
  854. if (panelElement.parentNode) {
  855. panelElement.parentNode.removeChild(panelElement);
  856. }
  857. // Re-insert according to the configured method
  858. switch(platformConfig.insertionMethod) {
  859. case 'prepend': insertionElement.insertBefore(panelElement, insertionElement.firstChild); break;
  860. case 'appendChild': insertionElement.appendChild(panelElement); break;
  861. case 'before': insertionElement.parentNode.insertBefore(panelElement, insertionElement); break;
  862. case 'afterend': insertionElement.parentNode.insertBefore(panelElement, insertionElement.nextSibling); break;
  863. default: insertionElement.insertBefore(panelElement, insertionElement.firstChild);
  864. }
  865. GM_log(`[${currentPlatform}] Panel re-inserted successfully.`);
  866. // Update the title text after re-insertion, as it might have been reset
  867. const panelTitleElement = panelElement?.querySelector('.panel-video-title');
  868. if (panelTitleElement) {
  869. panelTitleElement.textContent = getVideoTitle();
  870. }
  871.  
  872. } catch (e) {
  873. GM_log(`[${currentPlatform}] Failed to re-insert panel: ${e.message}`);
  874. removeControlPanel(); // If re-insertion fails, remove the panel to avoid further errors
  875. }
  876. } else {
  877. // If position is correct, just update the title text in case the video changed without a full navigation
  878. const panelTitleElement = panelElement?.querySelector('.panel-video-title');
  879. if (panelTitleElement) {
  880. const currentTitle = panelTitleElement.textContent.trim();
  881. const latestTitle = getVideoTitle();
  882. if (currentTitle !== latestTitle) {
  883. GM_log(`[${currentPlatform}] Panel title out of sync. Updating title.`);
  884. panelTitleElement.textContent = latestTitle;
  885. }
  886. }
  887. }
  888. }
  889.  
  890. function removeControlPanel() {
  891. if (panelElement) {
  892. try {
  893. panelElement.remove(); // Remove element from DOM
  894. GM_log(`[${currentPlatform || 'unknown'}] Control panel element removed.`);
  895. } catch (e) {
  896. GM_log(`[${currentPlatform || 'unknown'}] Error removing panel element: ${e.message}`);
  897. }
  898. panelElement = null; // Clear the variable
  899. }
  900. panelAdded = false; // Mark panel as removed
  901. stopVisibilityCheckInterval(); // Stop the VK visibility check
  902. controlsElement = null; // Clear the controls element reference
  903. // Note: splitMinutes and totalVideoMinutes state are *not* reset here, allowing them to persist across navigation.
  904. }
  905.  
  906. // --- СПЕЦИФИЧНЫЕ ФУНКЦИИ ДЛЯ VK (Видимость панели) ---
  907. function checkControlsVisibility() {
  908. // Only run if VK, and if both the panel and controls elements exist
  909. if (!panelElement || !controlsElement || currentPlatform !== 'vk') {
  910. // If VK, but elements are missing, stop check and clean up
  911. if (currentPlatform === 'vk' && (!controlsElement || !panelElement)) {
  912. GM_log(`[${currentPlatform}] Controls or panel element missing during visibility check. Stopping check and potentially removing panel.`);
  913. stopVisibilityCheckInterval();
  914. if (!panelElement) removeControlPanel(); // Only remove if panel itself is gone
  915. }
  916. return;
  917. }
  918. // Check VK's common methods for hiding controls
  919. const isHiddenByStyle = controlsElement.style.display === 'none';
  920. const isHiddenByClass = controlsElement.classList.contains('hidden') || controlsElement.classList.contains('videoplayer_controls_hide');
  921. const controlsAreVisible = !isHiddenByStyle && !isHiddenByClass;
  922.  
  923. // Toggle the 'visible' class on the panel based on native controls visibility
  924. if (controlsAreVisible && !panelElement.classList.contains('visible')) {
  925. panelElement.classList.add('visible');
  926. } else if (!controlsAreVisible && panelElement.classList.contains('visible')) {
  927. panelElement.classList.remove('visible');
  928. }
  929. }
  930.  
  931. function startVisibilityCheckInterval() {
  932. // Only start if VK, required, interval not running, and controls element found
  933. if (!visibilityCheckIntervalId && platformConfig?.needsVisibilityCheck && currentPlatform === 'vk' && controlsElement) {
  934. GM_log(`[${currentPlatform}] Starting controls visibility check interval.`);
  935. visibilityCheckIntervalId = setInterval(checkControlsVisibility, 300);
  936. checkControlsVisibility(); // Run immediately on start
  937. }
  938. }
  939.  
  940. function stopVisibilityCheckInterval() {
  941. if (visibilityCheckIntervalId) {
  942. GM_log(`[${currentPlatform}] Stopping controls visibility check interval.`);
  943. clearInterval(visibilityCheckIntervalId);
  944. visibilityCheckIntervalId = null;
  945. }
  946. }
  947.  
  948. // --- ОПРЕДЕЛЕНИЕ ТЕКУЩЕЙ ПЛАТФОРМЫ ---
  949. function getCurrentPlatform() {
  950. const hostname = location.hostname; const pathname = location.pathname;
  951. if (hostname.includes('vk.com')) { if (pathname.includes('/video_ext.php')) return 'vk'; }
  952. else if (hostname.includes('vkvideo.ru')) { if (pathname.includes('/video_ext.php')) return 'vk'; } // Treat vkvideo.ru the same as vk.com
  953. else if (hostname.includes('rutube.ru')) { if (pathname.startsWith('/video/')) return 'rutube'; }
  954. else if (hostname.includes('youtube.com')) { if (pathname.startsWith('/watch')) return 'youtube'; }
  955. return 'unknown'; // Return 'unknown' if none match
  956. }
  957.  
  958. // --- СБРОС СОСТОЯНИЯ ПРИ СМЕНЕ ВИДЕО ИЛИ ПЛАТФОРМЫ ---
  959. function resetState() {
  960. const platformToLog = currentPlatform !== 'unknown' ? currentPlatform : 'Previous';
  961. GM_log(`[${platformToLog}] Resetting state...`);
  962.  
  963. // Stop all active intervals and observers managed by the script
  964. stopSplitCheckInterval();
  965. stopVisibilityCheckInterval();
  966. if (overlayTimerIntervalId) { clearInterval(overlayTimerIntervalId); overlayTimerIntervalId = null; }
  967. // Note: setupIntervalId is *not* cleared here, it's needed to re-setup on the new page
  968.  
  969. // Remove UI elements from the DOM
  970. removeOverlay();
  971. removeControlPanel(); // This also stops the VK visibility check
  972.  
  973. // Reset state variables specific to the current video/split
  974. splitMinutes = null; // Clear the configured split time
  975. totalVideoMinutes = null; // Clear the total duration
  976. video = null; // Clear the video element reference
  977. splitTriggered = false; // Reset the split triggered flag
  978. audioPrimed = false; // Reset audio priming state for the new context
  979. videoPlayListenerAdded = false; // Reset the flag for adding the play listener for the new video element
  980.  
  981. // Pause and reset audio player, but keep the player object for reuse unless it was null/errored
  982. if (audioPlayer) {
  983. try { audioPlayer.pause(); audioPlayer.currentTime = 0; } catch(e){ GM_log(`[${platformToLog}] Error resetting audio player: ${e.message}`); }
  984. }
  985. // Do NOT set audioPlayer to null here, as it might be reused for the next video unless load failed.
  986.  
  987. // Reset overlay timer countdown, but keep the duration setting from local storage
  988. overlayCountdownRemaining = overlayTimerDuration; // Reset countdown using the saved duration
  989. }
  990.  
  991. // --- ГЛАВНАЯ ФУНКЦИЯ НАСТРОЙКИ СКРИПТА НА СТРАНИЦЕ ---
  992. // This function is called periodically by setupIntervalId and by the MutationObserver on URL change.
  993. // It detects the platform, resets state if needed, and attempts to set up the UI and intervals.
  994. function setupPlatformSplit() {
  995. const detectedPlatform = getCurrentPlatform();
  996.  
  997. // Step 1: Detect platform change and reset if necessary
  998. if (detectedPlatform !== currentPlatform) {
  999. if (currentPlatform !== 'unknown') { GM_log(`Platform or video likely changed from ${currentPlatform} to ${detectedPlatform}. Resetting state for previous page...`); resetState(); }
  1000. currentPlatform = detectedPlatform; // Update current platform state
  1001.  
  1002. if (currentPlatform !== 'unknown') {
  1003. platformConfig = platformConfigs[currentPlatform]; // Get configuration for the new platform
  1004. GM_log(`[${currentPlatform}] Initializing for new platform...`);
  1005. injectGlobalStyles(); // Re-inject styles (important for new platform styles)
  1006. // initAudioPlayer() will be called below once video is found, if audioPlayer is null
  1007. } else {
  1008. platformConfig = null; // No config for unknown platform
  1009. GM_log(`[Universal] Unknown platform detected (${location.href}), stopping setup interval.`);
  1010. // If platform becomes unknown, stop the setup interval as we can't proceed
  1011. if (setupIntervalId) { clearInterval(setupIntervalId); setupIntervalId = null; }
  1012. return; // Exit if platform is unknown
  1013. }
  1014. }
  1015.  
  1016. // Step 2: If we are on a recognized platform...
  1017. if (currentPlatform !== 'unknown' && platformConfig) {
  1018. // Always try to find the video element
  1019. if (!video) {
  1020. video = document.querySelector(platformConfig.videoSelector);
  1021. if (video) {
  1022. GM_log(`[${currentPlatform}] Video element found during setup.`);
  1023. // When a *new* video element is found, try to initialize audio player if it's not already valid
  1024. if (audioPlayer === null) { // Only try initializing if it's null (e.g., first load or previous load error)
  1025. initAudioPlayer();
  1026. }
  1027. }
  1028. }
  1029.  
  1030. // Attempt audio priming if we have a video and a valid audio player instance
  1031. if (video && audioPlayer !== null) {
  1032. primeAudio(); // Attempt to prime audio for this video/player instance
  1033. } else if (video && audioPlayer === null) {
  1034. // Video found, but audio player is null (likely due to previous load error)
  1035. // Log message already happens in initAudioPlayer's onerror
  1036. }
  1037.  
  1038.  
  1039. // Step 3: If the panel is already added, ensure its position and sync state
  1040. if (panelAdded) {
  1041. ensurePanelPosition(); // Check and fix panel position; updates title
  1042. // Re-check controls visibility for VK if necessary
  1043. if (platformConfig.needsVisibilityCheck && currentPlatform === 'vk' && !visibilityCheckIntervalId) {
  1044. controlsElement = document.querySelector(platformConfig.controlsElementSelector); // Re-find element if needed
  1045. if (controlsElement) startVisibilityCheckInterval();
  1046. }
  1047. // Re-start split check interval if split was active across navigation
  1048. if (splitMinutes !== null && splitMinutes > 0 && !splitCheckIntervalId) {
  1049. startSplitCheckInterval();
  1050. checkSplitCondition(); // Check condition immediately after restarting the interval
  1051. } else if (splitCheckIntervalId && (splitMinutes === null || splitMinutes <= 0)) {
  1052. stopSplitCheckInterval(); // Ensure interval is stopped if split is inactive
  1053. }
  1054. // The setupIntervalId remains active.
  1055.  
  1056. } else {
  1057. // Step 4: If panel is not yet added, try to find elements and add it
  1058. const insertionElement = document.querySelector(platformConfig.insertionSelector);
  1059. controlsElement = platformConfig.needsVisibilityCheck && currentPlatform === 'vk' ? document.querySelector(platformConfig.controlsElementSelector) : null; // Find controls for VK
  1060.  
  1061. // If necessary elements are found (video, insertion point, and optionally VK controls), add the control panel
  1062. // Check if video is not null as it's crucial for duration/current time
  1063. if (video && insertionElement && (!platformConfig.needsVisibilityCheck || controlsElement)) {
  1064. addControlPanel(); // This function handles adding the panel, listeners, and starting related intervals
  1065. } else {
  1066. // Elements not found yet. The setupIntervalId remains active and will call setupPlatformSplit again.
  1067. // This is normal for SPAs or pages where elements load dynamically.
  1068. // GM_log(`[${currentPlatform}] Needed elements not found yet for panel. Video: ${!!video}, Insertion: ${!!insertionElement}, VK Controls: ${platformConfig.needsVisibilityCheck ? !!controlsElement : 'N/A'}. Retrying...`);
  1069. }
  1070. }
  1071. }
  1072. // If platform is unknown, the interval was stopped earlier in this function.
  1073. }
  1074.  
  1075. // --- ИНИЦИАЛИЗАЦИЯ СКРИПТА И ОТСЛЕЖИВАНИЕ НАВИГАЦИИ ---
  1076. function initialize() {
  1077. GM_log(`Universal Video Split: Initializing (v${GM_info.script.version})...`); // Use GM_info for version
  1078. lastUrl = location.href; // Store the initial URL
  1079.  
  1080. // Start a persistent polling interval to repeatedly try setting up the script.
  1081. // This handles pages where elements load asynchronously or are delayed, and acts
  1082. // as a failsafe if the MutationObserver misses an event.
  1083. // This interval will *not* stop itself after initial setup, ensuring continuous checks.
  1084. if (!setupIntervalId) {
  1085. setupIntervalId = setInterval(setupPlatformSplit, setupIntervalDelay);
  1086. GM_log(`Setup interval started with ${setupIntervalDelay}ms delay.`);
  1087. }
  1088.  
  1089.  
  1090. // Use a MutationObserver to detect significant changes in the DOM, often indicative of SPA navigation.
  1091. // This is more efficient than polling for URL changes constantly, but the interval provides a backup.
  1092. // We observe the entire document body for childList and subtree changes.
  1093. if (!navigationObserver) {
  1094. navigationObserver = new MutationObserver((mutations) => {
  1095. // Check if the URL has changed. This is the primary trigger for a "new page" event in an SPA.
  1096. // We check location.href against our stored lastUrl.
  1097. if (location.href !== lastUrl) {
  1098. GM_log(`URL change detected by observer from ${lastUrl} to ${location.href}`);
  1099. lastUrl = location.href; // Update the last known URL
  1100. // When URL changes, reset the state related to the *previous* video/platform.
  1101. // The persistent setupInterval will automatically detect the new platform/URL
  1102. // on its next tick and run setupPlatformSplit accordingly.
  1103. resetState(); // This stops specific intervals/observers but leaves setupIntervalId active.
  1104. // No need to restart setupIntervalId here, as it's designed to be persistent.
  1105. } else {
  1106. // Optional: Add logic here to check for *other* significant DOM changes
  1107. // if needed, but the current setupPlatformSplit should handle most cases
  1108. // where elements appear late, even without a URL change.
  1109. }
  1110. });
  1111. // Observe changes in the body's subtree. childList and subtree are needed to catch element additions/removals during navigation.
  1112. navigationObserver.observe(document.body, { childList: true, subtree: true });
  1113. GM_log("MutationObserver for navigation started.");
  1114. }
  1115.  
  1116. // Run setup once immediately on script load
  1117. setupPlatformSplit();
  1118. }
  1119.  
  1120. // --- Очистка при выходе со страницы ---
  1121. window.addEventListener('beforeunload', () => {
  1122. GM_log("Universal Video Split: Unloading, cleaning up...");
  1123. resetState(); // Perform a full reset of intervals, observers, and UI elements
  1124.  
  1125. // Explicitly clear the setup interval on page unload
  1126. if (setupIntervalId) { clearInterval(setupIntervalId); setupIntervalId = null; GM_log("Setup interval cleared on unload."); }
  1127.  
  1128. // Disconnect the navigation observer on page unload
  1129. if (navigationObserver) { navigationObserver.disconnect(); navigationObserver = null; GM_log("MutationObserver disconnected on unload."); }
  1130.  
  1131. // Explicitly pause and nullify audioPlayer (though browser might handle this)
  1132. if (audioPlayer) { try { audioPlayer.pause(); } catch(e){} audioPlayer = null; GM_log("Audio player cleared on unload."); }
  1133. });
  1134.  
  1135. // --- Запуск скрипта ---
  1136. initialize();
  1137.  
  1138. })();