// ==UserScript==
// @name Peacock字幕保存器 (Peacock Subtitle Saver)
// @namespace https://129899.xyz
// @version 1.0
// @description Peacock字幕保存器是一款专为Peacock TV设计的用户脚本,可自动记录视频播放中的字幕并保存为.txt文件,方便后续使用。| Peacock Subtitle Saver is a user script for Peacock TV that automatically records subtitles during playback and saves them as .txt files for future use.
// @author aka1298
// @match https://www.peacocktv.com/*
// @grant none
// @license MIT
// @downloadURL none
// ==/UserScript==
(function() {
'use strict';
// Global instance to prevent multiple initializations
let subtitleSaverInstance = null;
class SubtitleSaver {
constructor() {
// Core state variables
this.savedSubtitles = [];
this.recordStatus = false;
this.lastSavedSubtitle = '';
this.autoScrollStatus = true;
this.startTime = null;
this.lastSubtitleTime = null;
// UI elements
this.buttonGroup = null;
this.startButton = null;
this.autoScrollButton = null;
this.previewPanel = null;
// Bind methods to maintain 'this' context
this.downloadSubtitles = this.downloadSubtitles.bind(this);
this.toggleRecording = this.toggleRecording.bind(this);
this.clearSubtitles = this.clearSubtitles.bind(this);
this.subtitleObserverCallback = this.subtitleObserverCallback.bind(this);
this.toggleAutoScroll = this.toggleAutoScroll.bind(this);
this.updatePreviewPanel = this.updatePreviewPanel.bind(this);
// Initialize UI and observers
this.createUI();
this.createPreviewPanel();
this.initializeObserver();
console.log('Peacock字幕保存器已初始化');
}
// Static styles for consistent UI
static get BUTTON_STYLE() {
return `
padding: 8px 16px;
font-size: 14px;
font-weight: bold;
color: white;
border: none;
border-radius: 5px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
cursor: pointer;
transition: all 0.2s ease-in-out;
margin-right: 5px;
`;
}
static get PREVIEW_PANEL_STYLE() {
return `
position: fixed;
top: 5%;
right: 20px;
width: 300px;
max-height: 400px;
background-color: rgba(0, 0, 0, 0.8);
color: white;
padding: 15px;
border-radius: 8px;
overflow-y: auto;
z-index: 10000;
font-size: 14px;
line-height: 1.5;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.3);
`;
}
createButton(text, baseColor, hoverColor, clickHandler) {
const button = document.createElement('button');
button.textContent = text;
button.style.cssText = SubtitleSaver.BUTTON_STYLE + `background-color: ${baseColor};`;
button.onmouseover = () => {
button.style.backgroundColor = hoverColor;
button.style.transform = 'scale(1.05)';
};
button.onmouseout = () => {
button.style.backgroundColor = baseColor;
button.style.transform = 'scale(1)';
};
button.onclick = clickHandler;
return button;
}
createPreviewPanel() {
this.previewPanel = document.createElement('div');
this.previewPanel.style.cssText = SubtitleSaver.PREVIEW_PANEL_STYLE;
this.previewPanel.style.display = 'none';
document.body.appendChild(this.previewPanel);
}
updatePreviewPanel() {
if (!this.autoScrollStatus) {
this.previewPanel.style.display = 'none';
return;
}
this.previewPanel.style.display = 'block';
const lastSubtitles = this.savedSubtitles.slice(-5);
this.previewPanel.innerHTML = `
已记录 ${this.savedSubtitles.length} 条字幕
${lastSubtitles.map(subtitle => `${subtitle}
`).join('')}
`;
// Only auto-scroll when enabled
if (this.autoScrollStatus) {
requestAnimationFrame(() => {
this.previewPanel.scrollTop = this.previewPanel.scrollHeight;
});
}
}
createUI() {
this.buttonGroup = document.createElement('div');
Object.assign(this.buttonGroup.style, {
position: 'fixed',
top: '5%',
left: '50%',
transform: 'translateX(-50%)',
zIndex: '10000',
display: 'flex',
gap: '10px',
backgroundColor: 'rgba(0, 0, 0, 0.6)',
padding: '8px',
borderRadius: '8px'
});
this.startButton = this.createButton(
'开始记录',
'#4CAF50',
'#367c39',
this.toggleRecording
);
const clearButton = this.createButton(
'停止并清除',
'#ff9800',
'#cc7a00',
this.clearSubtitles
);
const downloadButton = this.createButton(
'下载',
'#f44336',
'#c8352e',
this.downloadSubtitles
);
this.autoScrollButton = this.createButton(
'自动滚动: 开',
'#2196F3',
'#1976D2',
this.toggleAutoScroll
);
this.buttonGroup.append(this.startButton, clearButton, downloadButton, this.autoScrollButton);
document.body.appendChild(this.buttonGroup);
}
toggleAutoScroll() {
this.autoScrollStatus = !this.autoScrollStatus;
this.autoScrollButton.textContent = `自动滚动: ${this.autoScrollStatus ? '开' : '关'}`;
this.previewPanel.style.display = this.autoScrollStatus ? 'block' : 'none';
}
toggleRecording() {
this.recordStatus = !this.recordStatus;
if (this.recordStatus) {
this.startTime = new Date();
this.startButton.textContent = '暂停';
this.startButton.style.backgroundColor = '#ff4444';
} else {
this.startTime = null;
this.startButton.textContent = '开始记录';
this.startButton.style.backgroundColor = '#4CAF50';
}
this.updatePreviewPanel();
}
clearSubtitles() {
this.recordStatus = false;
this.startButton.textContent = '开始记录';
this.startButton.style.backgroundColor = '#4CAF50';
this.savedSubtitles = [];
this.lastSavedSubtitle = '';
this.startTime = null;
this.lastSubtitleTime = null;
this.updatePreviewPanel();
console.log('已清除保存的字幕并重置变量。');
}
downloadSubtitles() {
if (this.savedSubtitles.length === 0) {
alert('没有可下载的字幕!');
return;
}
const timestamp = new Date().toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
}).replace(/[/:]/g, '-');
// Get video title if available
let videoTitle = '';
try {
const titleElement = document.querySelector('.video-player__title');
if (titleElement) {
videoTitle = titleElement.textContent.trim();
}
} catch (e) {
console.log('无法获取视频标题:', e);
}
const filename = videoTitle
? `peacock_${videoTitle.replace(/[^\w\s]/gi, '')}_${timestamp}.txt`
: `peacock_subtitles_${timestamp}.txt`;
const metadata = [
'==================',
`记录时间: ${timestamp}`,
videoTitle ? `视频标题: ${videoTitle}` : '',
`字幕数量: ${this.savedSubtitles.length}`,
'==================\n'
].filter(Boolean); // Remove empty lines
const content = [...metadata, ...this.savedSubtitles];
const blob = new Blob([content.join('\n')], {
type: 'text/plain;charset=utf-8'
});
const link = document.createElement('a');
link.href = URL.createObjectURL(blob);
link.download = filename;
link.click();
URL.revokeObjectURL(link.href);
}
subtitleObserverCallback(mutationsList) {
if (!this.recordStatus) return;
try {
// Look for Peacock subtitle containers
const subtitleContainer = document.querySelector('[data-t-subtitles="true"]');
if (!subtitleContainer || subtitleContainer.style.display === 'none') return;
// Target ALL line elements from the Peacock subtitle structure
const subtitleLines = subtitleContainer.querySelectorAll('.video-player__subtitles__line');
if (!subtitleLines || subtitleLines.length === 0) return;
// Combine all subtitle lines into a single text
const subtitleText = Array.from(subtitleLines)
.map(line => line.innerText.trim())
.filter(Boolean)
.join(' ');
if (!subtitleText || subtitleText === this.lastSavedSubtitle) return;
// Add timestamp if available
const currentTime = document.querySelector('video')?.currentTime;
let formattedSubtitle = subtitleText;
if (false) {
const minutes = Math.floor(currentTime / 60);
const seconds = Math.floor(currentTime % 60);
const timeStamp = `[${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}]`;
formattedSubtitle = `${timeStamp} ${subtitleText}`;
}
this.lastSavedSubtitle = subtitleText;
this.savedSubtitles.push(formattedSubtitle);
this.updatePreviewPanel();
} catch (error) {
console.error('字幕处理错误:', error);
}
}
initializeObserver() {
const observerConfig = {
childList: true,
subtree: true,
attributes: true,
attributeFilter: ['style', 'class']
};
const subtitleObserver = new MutationObserver(this.subtitleObserverCallback);
// Function to initialize observer on player and subtitle elements
const initializePlayerObserver = () => {
// Find Peacock player element
const videoPlayer = document.querySelector('.video-player') ||
document.querySelector('[data-t-subtitles="true"]');
if (videoPlayer) {
subtitleObserver.observe(videoPlayer, observerConfig);
// Also observe body for dynamic changes
subtitleObserver.observe(document.body, observerConfig);
console.log('Peacock字幕观察器已启动');
return true;
}
return false;
};
// Try to initialize immediately
if (!initializePlayerObserver()) {
// If player not found, observe body until it appears
console.log('等待Peacock播放器加载...');
const bodyObserver = new MutationObserver((mutations, observer) => {
if (initializePlayerObserver()) {
observer.disconnect();
}
});
bodyObserver.observe(document.body, observerConfig);
// Fallback: retry after a delay
setTimeout(() => {
if (!document.querySelector('.video-player') &&
!document.querySelector('[data-t-subtitles="true"]')) {
console.log('尝试重新初始化字幕观察器...');
initializePlayerObserver();
}
}, 5000);
}
}
// Clean up method to remove UI elements
cleanup() {
if (this.buttonGroup) {
this.buttonGroup.remove();
}
if (this.previewPanel) {
this.previewPanel.remove();
}
}
}
// URL change detection for SPA
function checkForPlaybackPage() {
const isPlaybackPage = window.location.href.includes('/watch/playback');
// If we're on a playback page and no instance exists, create one
if (isPlaybackPage && !subtitleSaverInstance) {
console.log('检测到播放页面,初始化字幕保存器...');
subtitleSaverInstance = new SubtitleSaver();
}
// If we're not on a playback page but instance exists, clean up
else if (!isPlaybackPage && subtitleSaverInstance) {
console.log('离开播放页面,清理字幕保存器...');
subtitleSaverInstance.cleanup();
subtitleSaverInstance = null;
}
}
// Initial check
checkForPlaybackPage();
// Set up URL change detection for SPA navigation
let lastUrl = location.href;
// Create a new observer to watch for URL changes
const urlObserver = new MutationObserver(() => {
if (location.href !== lastUrl) {
lastUrl = location.href;
console.log('检测到URL变化:', lastUrl);
// Give the page a moment to load before checking
setTimeout(checkForPlaybackPage, 1000);
}
});
// Start observing
urlObserver.observe(document, { subtree: true, childList: true });
// Also check periodically as a fallback
setInterval(checkForPlaybackPage, 5000);
})();