// ==UserScript== // @name Netflix - Watch Time Tracker // @namespace https://github.com/Silverarmor/Userscripts // @version 1.1.0 // @description Tracks real (wall-clock) time spent watching Netflix with a daily and per-title stats panel. Unaffected by playback speed changes. // @author Silverarmor // @match https://www.netflix.com/* // @homepageURL https://github.com/Silverarmor/Userscripts // @updateURL https://raw.githubusercontent.com/Silverarmor/Userscripts/master/netflix/netflix-watch-time-tracker.user.js // @downloadURL https://raw.githubusercontent.com/Silverarmor/Userscripts/master/netflix/netflix-watch-time-tracker.user.js // @grant none // @run-at document-idle // ==/UserScript== (function () { "use strict"; // Time is accumulated as wall-clock seconds while the player is actually // playing, NOT as video.currentTime deltas, so changing playback speed // never inflates or deflates the totals. const STORAGE_KEY = "netflix-watch-time-tracker"; const TICK_MS = 1000; const SAVE_EVERY_MS = 10000; // Ticks stop firing while the machine sleeps; cap the credit for a single // tick so a wake-up after hours asleep doesn't count as watch time. const MAX_TICK_CREDIT_S = 5; const UNKNOWN_TITLE = "Unknown title"; const TITLE_SELECTOR = '[data-uia="video-title"]'; const CONTROLS_SELECTOR = '[data-uia="controls-standard"]'; const HISTORY_DAYS = 7; let data = loadData(); let sessionSeconds = 0; let lastTickAt = Date.now(); let lastSaveAt = Date.now(); let dirty = false; // The title element only exists in the DOM while the player controls are // visible, so seconds are buffered per watch-page path until it appears. let currentPath = ""; let currentTitle = ""; let pendingSeconds = 0; let pillEl = null; let panelEl = null; let panelOpen = false; function loadData() { try { const parsed = JSON.parse(localStorage.getItem(STORAGE_KEY)); if (parsed && typeof parsed === "object" && parsed.days && typeof parsed.days === "object") { return parsed; } } catch (error) { // Corrupt or missing data; start fresh below. } return { days: {} }; } function saveData() { try { localStorage.setItem(STORAGE_KEY, JSON.stringify(data)); dirty = false; lastSaveAt = Date.now(); } catch (error) { // Storage full or unavailable; totals stay in memory and retry later. } } function dateKey(date) { const y = date.getFullYear(); const m = String(date.getMonth() + 1).padStart(2, "0"); const d = String(date.getDate()).padStart(2, "0"); return `${y}-${m}-${d}`; } function dayBucket(key) { if (!data.days[key]) { data.days[key] = { seconds: 0, titles: {} }; } return data.days[key]; } function lastDayKeys(count) { const keys = []; const now = new Date(); for (let i = 0; i < count; i++) { keys.push(dateKey(new Date(now.getFullYear(), now.getMonth(), now.getDate() - i))); } return keys; } function formatDuration(totalSeconds) { const seconds = Math.round(totalSeconds); if (seconds < 60) { return seconds === 0 ? "0m" : "<1m"; } const hours = Math.floor(seconds / 3600); const minutes = Math.floor((seconds % 3600) / 60); return hours > 0 ? `${hours}h ${minutes}m` : `${minutes}m`; } function isWatchPage() { return location.pathname.startsWith("/watch"); } function findActiveVideo() { // Only count the actual player page; browse pages autoplay muted trailers // in