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