篡改猴脚本:给web版小宇宙FM增加播放速率选项

2个月没写博客,上来水一篇

xyz

记得以前小宇宙FM网页版可以调整倍速,后来这个选项消失了(也可能记错了)?是想让大家都下载App吧。

通过TamperMonkey写脚本,给小宇宙播放页面增加了倍速选项,方便在电脑上收听。
(搭配这个播客榜单,探索自己感兴趣的播客节目,真不错)

此脚本已发布至GreasyFork

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
// ==UserScript==
// @name 小宇宙FM-增加倍速选项
// @namespace http://tampermonkey.net/
// @version 0.91
// @description 给小宇宙播放页面增加倍速选项,方便在电脑上收听
// @author icheer.me
// @match https://www.xiaoyuzhoufm.com/episode/*
// @match https://www.xiaoyuzhoufm.com/podcast/*
// @icon https://www.google.com/s2/favicons?sz=64&domain=xiaoyuzhoufm.com
// @grant none
// @license MIT
// ==/UserScript==

const sleep = time => new Promise(resolve => setTimeout(resolve, time));
const $ = sel => document.querySelector(sel);
const $$ = sel => Array.from(document.querySelectorAll(sel));
const CE = tag => document.createElement(tag);

async function init(delay = 0) {
await sleep(delay);
const panel = $('.controls');
if (!panel) return console.error('panel not found');
const audio = $('audio');
if (!audio) return console.error('audio not found');
if (panel.querySelector('select')) return;
// v0.1 倍速选项
makePlaybackRateSelect(panel, audio);
// v0.2 下载按钮
makeDownloadButton(panel, audio);
// v0.3 循环播放
makeLoopCheckbox(panel, audio);
// v0.5 左右按键控制播放进度
makeKeyboardControl();
// v0.6 调出单集列表页面隐藏着的播放面板
fixPodcastPage(audio, delay);
// v0.4 二维码淡化
// v0.7 允许选中和复制网页内的文字
overrideStyles();
// v0.8 记录最近30个单集的播放进度,打开页面时自动恢复进度并播放
loadAudioProgress();
// v0.9 Show Notes 中存在时间轴信息时,自动高亮当前主题
loopExecute(1000, saveAudioProgress, highlightCurrentTopic)
}
init(500);

// 倍速下拉框
function makePlaybackRateSelect(panel, audio) {
const select = CE('select');
// 0.5倍速听谈话类节目太魔性了,是一个无用的选项,需要的话可以自己在下方添加<option value="0.5x">0.5x</option>
select.innerHTML = `
<option value="1x">1x</option>
<option value="1.25x">1.25x</option>
<option value="1.5x">1.5x</option>
<option value="1.75x">1.75x</option>
<option value="2x">2x</option>
<option value="3x">3x</option>
`;
select.style = 'width: 72px; height: 28px; margin: 0 10px; padding: 0 4px; border-radius: 4px; border: 1px solid #ccc; font-size: 14px; color: #333; outline: none';
panel.insertBefore(select, panel.children[0]);
// 最后一次选择的倍速偏好,自动带入
select.value = '1x';
if (localStorage.getItem('xyzRate')) {
const rate = parseFloat(localStorage.getItem('xyzRate')) || 1;
audio.playbackRate = rate;
select.value = rate + 'x';
}
// 选择倍速时,让倍速生效,并在localStorage中记录偏好,以便下次自动生效
select.addEventListener('change', e => {
const rate = parseFloat(e.target.value) || 1;
audio.playbackRate = rate;
localStorage.setItem('xyzRate', rate);
});
}

// 下载按钮
function makeDownloadButton(panel, audio) {
const download = CE('a');
download.innerText = '下载音频';
download.style = 'display: inline-block; width: 72px; margin: 0 75px 0 10px; text-align: left; color: var(--theme-color); font-size: 14px; text-decoration: none';
download.href = audio.src;
download.target = '_blank';
const title = $('h1') && $('h1').innerText.trim();
const fileName = audio.src.split('/').pop();
const extName = fileName.split('.').pop();
download.download = title ? `${title}.${extName}` : fileName;
panel.appendChild(download);
}

// 循环播放复选框
function makeLoopCheckbox(panel, audio) {
const loopLabel = CE('label');
const loopBox = CE('input');
const loopSpan = CE('span');
loopLabel.style = 'margin: 0 10px; color: var(--theme-color); font-size: 14px';
loopBox.type = 'checkbox';
loopBox.style = 'display: inline-block; vertical-align: middle; margin-right: 4px; background: #fff; opacity: 0.15';
loopSpan.style = 'display: inline-block; vertical-align: middle'
loopSpan.innerText = '循环';
loopLabel.appendChild(loopBox);
loopLabel.appendChild(loopSpan);
panel.insertBefore(loopLabel, panel.children[0]);
// 切换循环播放时,使audio.loop生效
loopBox.addEventListener('change', e => {
audio.loop = e.target.checked;
loopBox.style.opacity = e.target.checked ? 1 : 0.15;
});
}

// 样式修改
function overrideStyles() {
const style = CE('style');
style.innerHTML = `
.title .highlight-word-clipper {
width: 65px !important;
}
main aside {
opacity: 0.08;
}
main aside:hover {
opacity: 1;
}
div, img, span, p {
-webkit-user-select: auto;
-moz-user-select: auto;
-ms-user-select: auto;
user-select: auto;
-webkit-touch-callout: initial;
}
.highlight {
display: block;
position: relative;
}
.highlight + br {
display: none;
}
.highlight::before {
content: '▶';
position: absolute;
left: -1.25em;
}
`;
$('head').appendChild(style);
}

// 左右按键控制播放进度
function makeKeyboardControl() {
const btnLeft = $('.controls button[aria-label^="后退"]');
const btnRight = $('.controls button[aria-label^="前进"]');
document.addEventListener('keyup', e => {
if (e.key === 'ArrowLeft') {
btnLeft && btnLeft.click();
} else if (e.key === 'ArrowRight') {
btnRight && btnRight.click();
}
});
}

// 调出单集列表页面隐藏着的播放面板
function fixPodcastPage(audio, delay) {
if (/^\/podcast\//.test(location.pathname)) {
$('section.wrap').style.transform = 'none';
audio.onplay = () => {
const rate = parseFloat(localStorage.getItem('xyzRate')) || 1;
if (audio.playbackRate !== rate) audio.playbackRate = rate;
};
}
// 解决在单集和列表之间切换时,功能失效的问题
if ($('.podcast-title')) {
$('.podcast-title').onclick = () => init(450);
}
$$('main.tabs a').forEach(a => {
a.onclick = () => init(450);
});
if (!history.pushStateOld) {
history.pushStateOld = history.pushState;
history.pushState = (...args) => {
init(450);
history.pushStateOld.apply(history, args);
};
window.onpopstate = () => init(450);
}
}

// 记录最近30个单集的播放进度,打开页面时自动恢复进度
const progressKey = 'xyzAudioProgress';

function getEpisodeId(audio) {
let id = /episode\/([0-9a-f]+)/.exec(location.href)?.[1];
if (!id) id = /track_id=(\d+)/.exec(audio.src)?.[1];
return id;
}

function saveAudioProgress() {
const audio = $('audio');
if (!audio || !audio.src || audio.paused) return;
const id = getEpisodeId(audio);
if (!id) return;
let list = [];
try {
list = JSON.parse(localStorage.getItem(progressKey) || '[]');
} catch (e) {
console.error(e);
}
let item = list.find(i => i.id === id)
if (!item) {
item = { id };
list.unshift(item);
}
item.time = audio.currentTime;
list = list.slice(0, 30);
localStorage.setItem(progressKey, JSON.stringify(list));
}

function loadAudioProgress() {
const audio = $('audio');
if (!audio || !audio.src) return;
const id = getEpisodeId(audio);
if (!id) return;
let list = [];
try {
list = JSON.parse(localStorage.getItem(progressKey) || '[]');
} catch (e) {
console.error(e);
}
const item = list.find(i => i.id === id);
if (item) {
audio.currentTime = item.time;
}
audio.play();
audio.onended = removeAudioProgress;
}

function removeAudioProgress() {
const audio = $('audio');
if (!audio || !audio.src) return;
const id = getEpisodeId(audio);
if (!id) return;
let list = [];
try {
list = JSON.parse(localStorage.getItem(progressKey) || '[]');
} catch (e) {
console.error(e);
}
list = list.filter(i => i.id !== id);
localStorage.setItem(progressKey, JSON.stringify(list));
}

// ShowNotes中存在时间轴信息时,自动高亮当前主题
function highlightCurrentTopic() {
const audio = $('audio');
if (!audio || !audio.src) return;
const timestamps = $$('.sn-content a.timestamp[data-timestamp]');
if (!timestamps.length) return;
const times = timestamps.map(a => parseFloat(a.dataset.timestamp) || 0);
let paragraphs = timestamps.map(a => a.closest('p'));
if (paragraphs[0] === paragraphs[1]) {
paragraphs = timestamps.map(a => a.closest('span'));
}
const currentTime = audio.currentTime;
const currentIdx = times.findLastIndex(t => t <= currentTime);
paragraphs.forEach((p, i) => {
p.classList.remove('highlight');
if (i === currentIdx) p.classList.add('highlight');
});
}

// 循环执行
function loopExecute(interval = 1000, ...funcs) {
if (window.audioLoopTimer) return;
window.audioLoopTimer = setInterval(() => {
funcs.forEach(func => func());
}, interval);
}

Changelog
2023-12-05 v0.1: 倍速下拉框功能,并记录最近一次的倍速偏好
2023-12-05 v0.2: 增加下载音频的按钮,右键 –> 链接另存为 即可下载
2023-12-06 v0.3: 增加循环按钮,默认不开启,也不记录偏好
2023-12-06 v0.4: 二维码淡化处理
2023-12-08 v0.5: 左右键控制播放进度
2023-12-20 v0.6: 适配播客单集列表页面 /podcast/*
2024-01-12 v0.7: 允许选中和复制网页内的文字
2024-02-06 v0.8: 记录最近30个单集的播放进度,打开页面时自动恢复进度并播放
2024-03-25 v0.9: Show Notes 中存在时间轴信息时,自动高亮当前主题

Buy me a coffee ☕