Skip to content

Commit 0164e40

Browse files
committed
feat: add Better Fullscreen plugin
1 parent 141ae03 commit 0164e40

File tree

3 files changed

+794
-0
lines changed

3 files changed

+794
-0
lines changed
Lines changed: 390 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,390 @@
1+
import { createPlugin } from '@/utils';
2+
// @ts-ignore
3+
import style from './style.css?inline';
4+
import { getLyrics, LyricResult } from './lyrics';
5+
6+
export default createPlugin({
7+
name: 'Better Fullscreen',
8+
restartNeeded: true,
9+
config: {
10+
enabled: true,
11+
perfectSync: false,
12+
romanize: false
13+
},
14+
stylesheets: [style],
15+
16+
menu: async ({ getConfig, setConfig }) => {
17+
const config = await getConfig();
18+
return [
19+
{
20+
label: 'Enable Better Fullscreen',
21+
type: 'checkbox',
22+
checked: config.enabled,
23+
click: () => setConfig({ ...config, enabled: !config.enabled }),
24+
}
25+
];
26+
},
27+
28+
renderer: {
29+
async start(ctx: any) {
30+
let config = await ctx.getConfig();
31+
let isFullscreen = false;
32+
let lyrics: LyricResult | null = null;
33+
let lastSrc = '';
34+
let retryCount = 0;
35+
36+
const html = `
37+
<div id="bfs-container">
38+
<div class="bfs-bg-layer">
39+
<div class="bfs-blob bfs-blob-1"></div>
40+
<div class="bfs-blob bfs-blob-2"></div>
41+
<div class="bfs-blob bfs-blob-3"></div>
42+
<div class="bfs-blob bfs-blob-4"></div>
43+
<div class="bfs-blob bfs-blob-5"></div>
44+
</div>
45+
<div class="bfs-overlay"></div>
46+
47+
<div class="bfs-corner-zone bfs-zone-left"></div>
48+
<div class="bfs-corner-zone bfs-zone-right"></div>
49+
50+
<button id="bfs-settings-btn" title="Settings">
51+
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"></circle><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"></path></svg>
52+
</button>
53+
54+
<button id="bfs-close" title="Exit">
55+
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 6L6 18M6 6l12 12"/></svg>
56+
</button>
57+
58+
<div id="bfs-settings-modal">
59+
<div class="bfs-setting-item">
60+
<span>Perfect Sync</span>
61+
<label class="bfs-toggle">
62+
<input type="checkbox" id="bfs-opt-sync" ${config.perfectSync ? 'checked' : ''}>
63+
<span class="bfs-slider"></span>
64+
</label>
65+
</div>
66+
<div class="bfs-setting-item">
67+
<span>Romanize (Google)</span>
68+
<label class="bfs-toggle">
69+
<input type="checkbox" id="bfs-opt-roman" ${config.romanize ? 'checked' : ''}>
70+
<span class="bfs-slider"></span>
71+
</label>
72+
</div>
73+
</div>
74+
75+
<div class="bfs-content">
76+
<div class="bfs-lyrics-section">
77+
<div class="bfs-visualizer-icon" id="bfs-viz">
78+
<div class="bfs-viz-bar"></div><div class="bfs-viz-bar"></div><div class="bfs-viz-bar"></div>
79+
</div>
80+
<div class="bfs-lyrics-scroll" id="bfs-scroll">
81+
<div class="bfs-lyrics-wrapper" id="bfs-lines">
82+
<div class="bfs-empty">Loading...</div>
83+
</div>
84+
</div>
85+
</div>
86+
87+
<div class="bfs-meta-section">
88+
<div class="bfs-art"><img id="bfs-art" src="" crossorigin="anonymous" /></div>
89+
<div class="bfs-info">
90+
<div class="bfs-title" id="bfs-title">Title</div>
91+
<div class="bfs-artist" id="bfs-artist">Artist</div>
92+
</div>
93+
<div class="bfs-controls-container">
94+
<div class="bfs-progress-row">
95+
<span id="bfs-curr">0:00</span>
96+
<div class="bfs-bar-bg" id="bfs-seek"><div class="bfs-bar-fill" id="bfs-fill"></div></div>
97+
<span id="bfs-dur">0:00</span>
98+
</div>
99+
<div class="bfs-buttons">
100+
<button class="bfs-btn bfs-skip-btn" id="bfs-prev"><svg viewBox="0 0 24 24"><path d="M6 6h2v12H6zm3.5 6l8.5 6V6z"/></svg></button>
101+
<button class="bfs-btn bfs-play-btn" id="bfs-play">
102+
<svg id="bfs-icon-play" viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg>
103+
<svg id="bfs-icon-pause" viewBox="0 0 24 24" style="display:none"><path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"/></svg>
104+
</button>
105+
<button class="bfs-btn bfs-skip-btn" id="bfs-next"><svg viewBox="0 0 24 24"><path d="M6 18l8.5-6L6 6v12zM16 6v12h2V6h-2z"/></svg></button>
106+
</div>
107+
</div>
108+
</div>
109+
</div>
110+
<canvas id="bfs-canvas" width="50" height="50"></canvas>
111+
</div>
112+
`;
113+
114+
const div = document.createElement('div');
115+
div.innerHTML = html;
116+
document.body.appendChild(div);
117+
118+
const ui = {
119+
container: document.getElementById('bfs-container'),
120+
bgLayer: document.querySelector('.bfs-bg-layer') as HTMLElement,
121+
art: document.getElementById('bfs-art') as HTMLImageElement,
122+
title: document.getElementById('bfs-title'),
123+
artist: document.getElementById('bfs-artist'),
124+
curr: document.getElementById('bfs-curr'),
125+
dur: document.getElementById('bfs-dur'),
126+
fill: document.getElementById('bfs-fill'),
127+
seek: document.getElementById('bfs-seek'),
128+
lines: document.getElementById('bfs-lines'),
129+
scroll: document.getElementById('bfs-scroll'),
130+
canvas: document.getElementById('bfs-canvas') as HTMLCanvasElement,
131+
viz: document.getElementById('bfs-viz'),
132+
playBtn: document.getElementById('bfs-play'),
133+
prevBtn: document.getElementById('bfs-prev'),
134+
nextBtn: document.getElementById('bfs-next'),
135+
iconPlay: document.getElementById('bfs-icon-play'),
136+
iconPause: document.getElementById('bfs-icon-pause'),
137+
settingsBtn: document.getElementById('bfs-settings-btn'),
138+
settingsModal: document.getElementById('bfs-settings-modal'),
139+
optSync: document.getElementById('bfs-opt-sync') as HTMLInputElement,
140+
optRoman: document.getElementById('bfs-opt-roman') as HTMLInputElement
141+
};
142+
143+
const updateColors = () => {
144+
try {
145+
const ctx = ui.canvas.getContext('2d');
146+
if (!ctx) return;
147+
ctx.drawImage(ui.art, 0, 0, 50, 50);
148+
const data = ctx.getImageData(0, 0, 50, 50).data;
149+
150+
const getC = (x:number, y:number) => {
151+
const i = (y * 50 + x) * 4;
152+
return `rgb(${data[i]}, ${data[i+1]}, ${data[i+2]})`;
153+
};
154+
155+
document.documentElement.style.setProperty('--bfs-c1', getC(25, 25));
156+
document.documentElement.style.setProperty('--bfs-c2', getC(10, 10));
157+
document.documentElement.style.setProperty('--bfs-c3', getC(40, 40));
158+
document.documentElement.style.setProperty('--bfs-c4', getC(40, 10));
159+
document.documentElement.style.setProperty('--bfs-c5', getC(10, 40));
160+
} catch(e) {}
161+
};
162+
163+
const renderLyrics = () => {
164+
if (!ui.lines) return;
165+
ui.lines.innerHTML = '';
166+
167+
if (!lyrics) {
168+
ui.lines.innerHTML = `
169+
<div class="bfs-empty">
170+
<span>Lyrics not available</span>
171+
<button class="bfs-refresh-btn" id="bfs-force-fetch" style="margin-top:15px;">
172+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M23 4v6h-6"/><path d="M1 20v-6h6"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/></svg>
173+
Retry Search
174+
</button>
175+
</div>
176+
`;
177+
document.getElementById('bfs-force-fetch')?.addEventListener('click', () => {
178+
const title = ui.title?.innerText;
179+
const artist = ui.artist?.innerText;
180+
const video = document.querySelector('video');
181+
if(title && artist && video) {
182+
ui.lines!.innerHTML = '<div class="bfs-empty">Searching...</div>';
183+
performFetch(title, artist, video.duration);
184+
}
185+
});
186+
return;
187+
}
188+
189+
if (lyrics.lines) {
190+
lyrics.lines.forEach((line) => {
191+
const el = document.createElement('div');
192+
el.className = 'bfs-line';
193+
194+
const isInst = line.text.includes('...') || line.text.includes('♪') || line.text.toLowerCase().includes('instrumental');
195+
196+
if(isInst) {
197+
el.classList.add('instrumental');
198+
el.innerHTML = `
199+
<div class="bfs-viz-icon">
200+
<div class="bfs-viz-bar" style="height:12px;"></div>
201+
<div class="bfs-viz-bar" style="height:30px; animation-delay:0.2s;"></div>
202+
<div class="bfs-viz-bar" style="height:18px; animation-delay:0.4s;"></div>
203+
</div>`;
204+
} else {
205+
let html = `<span>${line.text}</span>`;
206+
if(line.romaji) {
207+
html += `<span class="bfs-romaji">${line.romaji}</span>`;
208+
}
209+
el.innerHTML = html;
210+
}
211+
212+
el.onclick = () => {
213+
const video = document.querySelector('video');
214+
if (video) video.currentTime = line.timeMs / 1000;
215+
};
216+
ui.lines?.appendChild(el);
217+
});
218+
} else if (lyrics.plain) {
219+
ui.lines.innerHTML = `<div class="bfs-line" style="cursor:default; opacity:1; filter:none; transform:none; white-space: pre-wrap;">${lyrics.plain}</div>`;
220+
}
221+
};
222+
223+
const syncLyrics = (time: number) => {
224+
if (!lyrics?.lines || !ui.lines) return;
225+
226+
const offset = config.perfectSync ? 0.5 : 0;
227+
const timeMs = (time + offset) * 1000;
228+
229+
let activeIdx = -1;
230+
for (let i = 0; i < lyrics.lines.length; i++) {
231+
if (timeMs >= lyrics.lines[i].timeMs) activeIdx = i;
232+
else break;
233+
}
234+
235+
const domLines = ui.lines.querySelectorAll('.bfs-line');
236+
let isInstrumental = false;
237+
238+
domLines.forEach((line: any, idx) => {
239+
if (idx === activeIdx) {
240+
if (!line.classList.contains('active')) {
241+
line.classList.add('active');
242+
line.scrollIntoView({ behavior: 'smooth', block: 'center' });
243+
}
244+
if(line.classList.contains('instrumental')) isInstrumental = true;
245+
} else {
246+
line.classList.remove('active');
247+
}
248+
});
249+
250+
if(isInstrumental) ui.viz?.classList.add('show');
251+
else ui.viz?.classList.remove('show');
252+
};
253+
254+
const performFetch = async (title: string, artist: string, duration: number) => {
255+
lyrics = await getLyrics(title, artist, duration, config.romanize);
256+
renderLyrics();
257+
};
258+
259+
const formatTime = (s: number) => {
260+
if (isNaN(s)) return "0:00";
261+
const m = Math.floor(s / 60);
262+
const sec = Math.floor(s % 60);
263+
return `${m}:${sec < 10 ? '0' : ''}${sec}`;
264+
};
265+
266+
setInterval(async () => {
267+
const video = document.querySelector('video');
268+
if (!video) return;
269+
270+
const title = (document.querySelector('ytmusic-player-bar .title') as HTMLElement)?.innerText;
271+
let artist = (document.querySelector('ytmusic-player-bar .byline') as HTMLElement)?.innerText;
272+
const artSrc = (document.querySelector('.image.ytmusic-player-bar') as HTMLImageElement)?.src;
273+
if(artist) artist = artist.split(/[·]/)[0].trim();
274+
275+
if (ui.title && title) ui.title.innerText = title;
276+
if (ui.artist && artist) ui.artist.innerText = artist;
277+
278+
if (ui.art && artSrc) {
279+
const highRes = artSrc.replace(/w\d+-h\d+/, 'w1200-h1200');
280+
if (ui.art.src !== highRes) {
281+
ui.art.src = highRes;
282+
ui.art.onload = updateColors;
283+
}
284+
}
285+
286+
const currentSrc = video.src;
287+
if (currentSrc && currentSrc !== lastSrc) {
288+
lastSrc = currentSrc;
289+
retryCount = 0;
290+
if (title && artist) {
291+
ui.lines!.innerHTML = '<div class="bfs-empty">Searching lyrics...</div>';
292+
performFetch(title, artist, video.duration);
293+
}
294+
}
295+
296+
if (isFullscreen) {
297+
ui.curr!.innerText = formatTime(video.currentTime);
298+
ui.dur!.innerText = formatTime(video.duration);
299+
const pct = (video.currentTime / video.duration) * 100;
300+
ui.fill!.style.width = `${pct}%`;
301+
syncLyrics(video.currentTime);
302+
303+
if (video.paused) {
304+
ui.iconPlay!.style.display = 'block';
305+
ui.iconPause!.style.display = 'none';
306+
} else {
307+
ui.iconPlay!.style.display = 'none';
308+
ui.iconPause!.style.display = 'block';
309+
}
310+
}
311+
}, 250);
312+
313+
const toggleFS = (active: boolean) => {
314+
isFullscreen = active;
315+
if (active) {
316+
document.body.classList.add('bfs-active');
317+
document.documentElement.requestFullscreen().catch(()=>{});
318+
} else {
319+
document.body.classList.remove('bfs-active');
320+
if (document.fullscreenElement) document.exitFullscreen().catch(()=>{});
321+
}
322+
};
323+
324+
document.getElementById('bfs-close')?.addEventListener('click', () => toggleFS(false));
325+
window.addEventListener('keydown', e => {
326+
if(e.key === 'F12') toggleFS(!isFullscreen);
327+
if(e.key === 'Escape') toggleFS(false);
328+
});
329+
330+
ui.settingsBtn?.addEventListener('click', (e) => {
331+
e.stopPropagation();
332+
ui.settingsModal?.classList.toggle('active');
333+
});
334+
ui.container?.addEventListener('click', (e) => {
335+
if(e.target !== ui.settingsBtn && !ui.settingsModal?.contains(e.target as Node)) {
336+
ui.settingsModal?.classList.remove('active');
337+
}
338+
});
339+
340+
ui.optSync?.addEventListener('change', (e) => {
341+
config.perfectSync = (e.target as HTMLInputElement).checked;
342+
ctx.setConfig(config);
343+
});
344+
345+
ui.optRoman?.addEventListener('change', async (e) => {
346+
config.romanize = (e.target as HTMLInputElement).checked;
347+
ctx.setConfig(config);
348+
const title = ui.title?.innerText;
349+
const artist = ui.artist?.innerText;
350+
const video = document.querySelector('video');
351+
if(title && artist && video) {
352+
ui.lines!.innerHTML = '<div class="bfs-empty">Processing...</div>';
353+
performFetch(title, artist, video.duration);
354+
}
355+
});
356+
357+
ui.playBtn?.addEventListener('click', () => { const v=document.querySelector('video'); if(v) v.paused?v.play():v.pause(); });
358+
ui.prevBtn?.addEventListener('click', () => (document.querySelector('.previous-button') as HTMLElement)?.click());
359+
ui.nextBtn?.addEventListener('click', () => (document.querySelector('.next-button') as HTMLElement)?.click());
360+
ui.seek?.addEventListener('click', (e) => {
361+
const v = document.querySelector('video'); if(!v)return;
362+
const rect = ui.seek!.getBoundingClientRect();
363+
v.currentTime = ((e.clientX - rect.left) / rect.width) * v.duration;
364+
});
365+
366+
// --- MOVED TO ALBUM ART ---
367+
setInterval(() => {
368+
const artContainer = document.querySelector('#song-image');
369+
370+
if (artContainer && !document.getElementById('bfs-trigger')) {
371+
const btn = document.createElement('div');
372+
btn.id = 'bfs-trigger';
373+
btn.title = 'Open Lyrics (Better Fullscreen)';
374+
btn.innerHTML = `<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M15 3h6v6M9 21H3v-6M21 3l-7 7M3 21l7-7"/></svg>`;
375+
376+
btn.onclick = (e) => {
377+
e.stopPropagation();
378+
toggleFS(true);
379+
};
380+
381+
if(getComputedStyle(artContainer).position === 'static') {
382+
(artContainer as HTMLElement).style.position = 'relative';
383+
}
384+
385+
artContainer.appendChild(btn);
386+
}
387+
}, 1000);
388+
}
389+
}
390+
});

0 commit comments

Comments
 (0)