@@ -15,7 +15,11 @@ const ICONS = {
1515 update : `<svg width="98" height="96" viewBox="0 0 98 96" fill="none" xmlns="http://www.w3.org/2000/svg" style="width:18px;height:18px;"><g clip-path="url(#clip0_730_27136)"><path d="M41.4395 69.3848C28.8066 67.8535 19.9062 58.7617 19.9062 46.9902C19.9062 42.2051 21.6289 37.0371 24.5 33.5918C23.2559 30.4336 23.4473 23.7344 24.8828 20.959C28.7109 20.4805 33.8789 22.4902 36.9414 25.2656C40.5781 24.1172 44.4062 23.543 49.0957 23.543C53.7852 23.543 57.6133 24.1172 61.0586 25.1699C64.0254 22.4902 69.2891 20.4805 73.1172 20.959C74.457 23.543 74.6484 30.2422 73.4043 33.4961C76.4668 37.1328 78.0937 42.0137 78.0937 46.9902C78.0937 58.7617 69.1934 67.6621 56.3691 69.2891C59.623 71.3945 61.8242 75.9883 61.8242 81.252L61.8242 91.2051C61.8242 94.0762 64.2168 95.7031 67.0879 94.5547C84.4102 87.9512 98 70.6289 98 49.1914C98 22.1074 75.9883 6.69539e-07 48.9043 4.309e-07C21.8203 1.92261e-07 -1.9479e-07 22.1074 -4.3343e-07 49.1914C-6.20631e-07 70.4375 13.4941 88.0469 31.6777 94.6504C34.2617 95.6074 36.75 93.8848 36.75 91.3008L36.75 83.6445C35.4102 84.2188 33.6875 84.6016 32.1562 84.6016C25.8398 84.6016 22.1074 81.1563 19.4277 74.7441C18.375 72.1602 17.2266 70.6289 15.0254 70.3418C13.877 70.2461 13.4941 69.7676 13.4941 69.1934C13.4941 68.0449 15.4082 67.1836 17.3223 67.1836C20.0977 67.1836 22.4902 68.9063 24.9785 72.4473C26.8926 75.2227 28.9023 76.4668 31.2949 76.4668C33.6875 76.4668 35.2187 75.6055 37.4199 73.4043C39.0469 71.7773 40.291 70.3418 41.4395 69.3848Z" fill="white"/></g><defs><clipPath id="clip0_730_27136"><rect width="98" height="96" fill="white"/></clipPath></defs></svg>` ,
1616 bigPlay : `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="#ffffff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="opacity:1;width:40px;height:40px;"><path fill="none" d="M5 5a2 2 0 0 1 3.008-1.728l11.997 6.998a2 2 0 0 1 .003 3.458l-12 7A2 2 0 0 1 5 19z"/></svg>` ,
1717 backward : `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="#ffffff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="opacity:1;"><path d="M12 6a2 2 0 0 0-3.414-1.414l-6 6a2 2 0 0 0 0 2.828l6 6A2 2 0 0 0 12 18z"/><path d="M22 6a2 2 0 0 0-3.414-1.414l-6 6a2 2 0 0 0 0 2.828l6 6A2 2 0 0 0 22 18z"/></svg>` ,
18- forward : `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="#ffffff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="opacity:1;"><path d="M12 6a2 2 0 0 1 3.414-1.414l6 6a2 2 0 0 1 0 2.828l-6 6A2 2 0 0 1 12 18z"/><path d="M2 6a2 2 0 0 1 3.414-1.414l6 6a2 2 0 0 1 0 2.828l-6 6A2 2 0 0 1 2 18z"/></svg>`
18+ forward : `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="#ffffff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="opacity:1;"><path d="M12 6a2 2 0 0 1 3.414-1.414l6 6a2 2 0 0 1 0 2.828l-6 6A2 2 0 0 1 12 18z"/><path d="M2 6a2 2 0 0 1 3.414-1.414l6 6a2 2 0 0 1 0 2.828l-6 6A2 2 0 0 1 2 18z"/></svg>` ,
19+ volumeHigh : `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="#ffffff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="opacity:1;"><path fill="none" d="M11 4.702a.705.705 0 0 0-1.203-.498L6.413 7.587A1.4 1.4 0 0 1 5.416 8H3a1 1 0 0 0-1 1v6a1 1 0 0 0 1 1h2.416a1.4 1.4 0 0 1 .997.413l3.383 3.384A.705.705 0 0 0 11 19.298zM16 9a5 5 0 0 1 0 6m3.364 3.364a9 9 0 0 0 0-12.728"/></svg>` ,
20+ volumeMedium : `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="#ffffff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="opacity:1;"><path fill="none" d="M11 4.702a.705.705 0 0 0-1.203-.498L6.413 7.587A1.4 1.4 0 0 1 5.416 8H3a1 1 0 0 0-1 1v6a1 1 0 0 0 1 1h2.416a1.4 1.4 0 0 1 .997.413l3.383 3.384A.705.705 0 0 0 11 19.298zM16 9a5 5 0 0 1 0 6"/></svg>` ,
21+ volumeLow : `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="#ffffff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="opacity:1;"><path fill="none" d="M11 4.702a.705.705 0 0 0-1.203-.498L6.413 7.587A1.4 1.4 0 0 1 5.416 8H3a1 1 0 0 0-1 1v6a1 1 0 0 0 1 1h2.416a1.4 1.4 0 0 1 .997.413l3.383 3.384A.705.705 0 0 0 11 19.298z"/></svg>` ,
22+ volumeMute : `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="#ffffff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="opacity:1;"><path fill="none" d="M11 4.702a.705.705 0 0 0-1.203-.498L6.413 7.587A1.4 1.4 0 0 1 5.416 8H3a1 1 0 0 0-1 1v6a1 1 0 0 0 1 1h2.416a1.4 1.4 0 0 1 .997.413l3.383 3.384A.705.705 0 0 0 11 19.298zM22 9l-6 6m0-6l6 6"/></svg>`
1923} ;
2024
2125function getResumeKey ( channelSlug , videoSlug ) {
@@ -308,11 +312,34 @@ function formatTime(seconds) {
308312 }
309313}
310314
315+ function getVolumeIcon ( volume ) {
316+ if ( volume <= 0 ) return ICONS . volumeMute ;
317+ if ( volume < 0.2 ) return ICONS . volumeLow ;
318+ if ( volume < 0.5 ) return ICONS . volumeMedium ;
319+ return ICONS . volumeHigh ;
320+ }
321+
311322async function unlockVideo ( triggerElement ) {
312323 if ( isUnlocking ) return ;
313324 const container = triggerElement . closest ( '.relative.flex.flex-col' ) ;
314325 if ( ! container || container . dataset . kickUnlockerProcessing ) return ;
315326
327+ const containerRect = container . getBoundingClientRect ( ) ;
328+ const fallbackMinHeight = Math . max (
329+ Math . round ( containerRect . height || 0 ) ,
330+ Math . round ( ( containerRect . width || 0 ) * 9 / 16 ) ,
331+ 360
332+ ) ;
333+
334+ container . style . width = '100%' ;
335+ container . style . minHeight = `${ fallbackMinHeight } px` ;
336+
337+ if ( containerRect . width > 0 && containerRect . height > 0 ) {
338+ container . style . aspectRatio = `${ containerRect . width } / ${ containerRect . height } ` ;
339+ } else {
340+ container . style . aspectRatio = '16 / 9' ;
341+ }
342+
316343 if ( activeHls ) { activeHls . destroy ( ) ; activeHls = null ; }
317344
318345 try {
@@ -321,7 +348,7 @@ async function unlockVideo(triggerElement) {
321348 // --- STEP 1: FULL WIPE & SPLASH ---
322349 // Immediately hide everything (including the underlying placeholder) and show our splash screen
323350 container . innerHTML = `
324- <div style="width:100%;height:100%;background:#000;display:flex;flex-direction:column;justify-content:center;align-items:center;font-family:Inter,sans-serif;">
351+ <div style="width:100%;height:100%;min-height: ${ fallbackMinHeight } px; background:#000;display:flex;flex-direction:column;justify-content:center;align-items:center;font-family:Inter,sans-serif;">
325352 <div style="font-size:24px;font-weight:bold;color:#53fc18;margin-bottom:8px;">🔓 Kick Unlocker</div>
326353 <div style="font-size:14px;color:rgba(255,255,255,0.7);">Searching stream...</div>
327354 </div>
@@ -375,16 +402,25 @@ async function unlockVideo(triggerElement) {
375402 const playerHTML = `
376403 <div id="k-player" style="width:100%;height:100%;position:relative;background:black;overflow:hidden;font-family:Inter,sans-serif;">
377404 <video id="k-video" playsinline style="width:100%;height:100%;object-fit:contain;"></video>
405+ <div id="k-loading" class="visible" aria-hidden="true">
406+ <div class="k-loading-spinner"></div>
407+ </div>
378408 <div id="k-controls" style="position:absolute;bottom:0;left:0;width:100%;padding:20px 15px 10px 15px;background:linear-gradient(to top, rgba(0,0,0,0.9), transparent);display:flex;flex-direction:column;opacity:0;transition:opacity 0.2s;">
379409 <div id="k-track" style="width:100%;height:5px;padding:8px 0;background:rgba(255,255,255,0.3);background-clip:content-box;box-sizing:content-box;cursor:pointer;position:relative;margin-bottom:4px;border-radius:2px;">
380- <div id="k-track-tooltip"></div>
410+ <div id="k-track-tooltip">
411+ <div id="k-track-tooltip-time">0:00</div>
412+ </div>
381413 <div id="k-progress" style="width:0%;height:100%;background:#53fc18;position:relative;border-radius:2px;"></div>
382414 </div>
383415 <div style="display:flex;align-items:center;justify-content:space-between;">
384416 <div style="display:flex;align-items:center;gap:15px;">
385417 <button id="k-play" style="background:none;border:none;cursor:pointer;opacity:0.9;">${ ICONS . play } </button>
386418 <span id="k-time" style="font-size:13px;color:#ddd;font-variant-numeric:tabular-nums;">0:00 / 0:00</span>
387- <input id="k-volume" type="range" min="0" max="1" step="0.01" value="1">
419+ <div id="k-volume-wrap">
420+ <button id="k-volume-btn" type="button" aria-label="Mute volume">${ ICONS . volumeHigh } </button>
421+ <input id="k-volume" type="range" min="0" max="1" step="0.01" value="1">
422+ <span id="k-volume-value">100%</span>
423+ </div>
388424 </div>
389425 <div style="display:flex;align-items:center;gap:15px;">
390426 <button id="k-update-btn" type="button" title="Open latest update" style="display:none;">${ ICONS . update } </button>
@@ -414,15 +450,66 @@ async function unlockVideo(triggerElement) {
414450 const progressBar = videoParent . querySelector ( '#k-progress' ) ;
415451 const track = videoParent . querySelector ( '#k-track' ) ;
416452 const trackTooltip = videoParent . querySelector ( '#k-track-tooltip' ) ;
453+ const trackTooltipTime = videoParent . querySelector ( '#k-track-tooltip-time' ) ;
417454 const timeDisplay = videoParent . querySelector ( '#k-time' ) ;
418455 const qualWrap = videoParent . querySelector ( '#k-quality-wrap' ) ;
419456 const qualBtn = videoParent . querySelector ( '#k-quality-btn' ) ;
420457 const qualMenu = videoParent . querySelector ( '#k-quality-menu' ) ;
421458 const seekIndicator = videoParent . querySelector ( '#k-seek-indicator' ) ;
459+ const loadingOverlay = videoParent . querySelector ( '#k-loading' ) ;
422460
461+ const volumeWrap = videoParent . querySelector ( '#k-volume-wrap' ) ;
462+ const volumeButton = videoParent . querySelector ( '#k-volume-btn' ) ;
423463 const volumeSlider = videoParent . querySelector ( '#k-volume' ) ;
464+ const volumeValue = videoParent . querySelector ( '#k-volume-value' ) ;
424465 const initialVolume = Number . isFinite ( savedPlayerSettings . volume ) ? savedPlayerSettings . volume : 1 ;
425466 let seekIndicatorTimeout = null ;
467+ let hasStartedPlayback = false ;
468+ let loadingStateTimeout = null ;
469+ let previousVolumeBeforeMute = initialVolume > 0 ? initialVolume : 1 ;
470+
471+ const updateVolumeSliderVisual = ( volume ) => {
472+ const percent = Math . max ( 0 , Math . min ( 100 , Math . round ( volume * 100 ) ) ) ;
473+ volumeSlider . style . setProperty ( '--k-volume-percent' , `${ percent } %` ) ;
474+ volumeValue . textContent = `${ percent } %` ;
475+ volumeButton . innerHTML = getVolumeIcon ( volume ) ;
476+ volumeButton . setAttribute ( 'aria-label' , percent === 0 ? 'Unmute volume' : 'Mute volume' ) ;
477+ volumeWrap . dataset . muted = percent === 0 ? 'true' : 'false' ;
478+ } ;
479+
480+ const applyVolume = ( volume , { persist = true } = { } ) => {
481+ const normalizedVolume = Math . max ( 0 , Math . min ( 1 , Number ( volume ) || 0 ) ) ;
482+ if ( normalizedVolume > 0 ) previousVolumeBeforeMute = normalizedVolume ;
483+
484+ vid . muted = normalizedVolume === 0 ;
485+ vid . volume = normalizedVolume ;
486+ volumeSlider . value = String ( normalizedVolume ) ;
487+ updateVolumeSliderVisual ( normalizedVolume ) ;
488+
489+ if ( persist ) {
490+ savePlayerSettings ( playerSettingsKey , { volume : normalizedVolume } ) ;
491+ }
492+ } ;
493+
494+ const setLoadingState = ( isLoading , { immediate = false } = { } ) => {
495+ clearTimeout ( loadingStateTimeout ) ;
496+
497+ if ( ! isLoading ) {
498+ loadingOverlay . classList . remove ( 'visible' ) ;
499+ return ;
500+ }
501+
502+ if ( immediate ) {
503+ loadingOverlay . classList . add ( 'visible' ) ;
504+ return ;
505+ }
506+
507+ loadingStateTimeout = setTimeout ( ( ) => {
508+ if ( ! vid . paused && ! vid . ended ) {
509+ loadingOverlay . classList . add ( 'visible' ) ;
510+ }
511+ } , 250 ) ;
512+ } ;
426513
427514 const showSeekIndicator = ( direction ) => {
428515 seekIndicator . innerHTML = direction === 'forward' ? ICONS . forward : ICONS . backward ;
@@ -443,8 +530,7 @@ async function unlockVideo(triggerElement) {
443530 } ;
444531 bindGlobalPlayerListeners ( ) ;
445532
446- volumeSlider . value = String ( initialVolume ) ;
447- vid . volume = initialVolume ;
533+ applyVolume ( initialVolume , { persist : false } ) ;
448534
449535 getLatestReleaseInfo ( ) . then ( ( release ) => {
450536 if ( ! release || ! btnUpdate ?. isConnected ) return ;
@@ -462,9 +548,17 @@ async function unlockVideo(triggerElement) {
462548 } ) ;
463549
464550 volumeSlider . addEventListener ( 'input' , ( ) => {
465- const volume = parseFloat ( volumeSlider . value ) ;
466- vid . volume = volume ;
467- savePlayerSettings ( playerSettingsKey , { volume } ) ;
551+ applyVolume ( parseFloat ( volumeSlider . value ) ) ;
552+ } ) ;
553+
554+ volumeButton . addEventListener ( 'click' , ( ) => {
555+ if ( vid . volume <= 0 || vid . muted ) {
556+ applyVolume ( previousVolumeBeforeMute > 0 ? previousVolumeBeforeMute : 1 ) ;
557+ return ;
558+ }
559+
560+ previousVolumeBeforeMute = vid . volume > 0 ? vid . volume : previousVolumeBeforeMute ;
561+ applyVolume ( 0 ) ;
468562 } ) ;
469563
470564 chatController . connectVideo ( vid ) ;
@@ -481,6 +575,29 @@ async function unlockVideo(triggerElement) {
481575 btnPlay . innerHTML = ICONS . play ;
482576 btnBig . classList . add ( 'visible' ) ;
483577 } ) ;
578+ vid . addEventListener ( 'playing' , ( ) => {
579+ hasStartedPlayback = true ;
580+ setLoadingState ( false ) ;
581+ } ) ;
582+ vid . addEventListener ( 'waiting' , ( ) => {
583+ if ( ! vid . paused ) setLoadingState ( true ) ;
584+ } ) ;
585+ vid . addEventListener ( 'seeking' , ( ) => {
586+ if ( hasStartedPlayback ) setLoadingState ( true ) ;
587+ } ) ;
588+ vid . addEventListener ( 'seeked' , ( ) => {
589+ if ( ! vid . paused && vid . readyState >= 3 ) setLoadingState ( false ) ;
590+ } ) ;
591+ vid . addEventListener ( 'canplay' , ( ) => {
592+ if ( ! vid . paused && hasStartedPlayback ) setLoadingState ( false ) ;
593+ } ) ;
594+ vid . addEventListener ( 'stalled' , ( ) => {
595+ if ( ! vid . paused ) setLoadingState ( true ) ;
596+ } ) ;
597+ vid . addEventListener ( 'loadeddata' , ( ) => {
598+ if ( vid . paused && ! hasStartedPlayback ) setLoadingState ( false ) ;
599+ } ) ;
600+ vid . addEventListener ( 'ended' , ( ) => setLoadingState ( false ) ) ;
484601 let lastSave = 0 ;
485602
486603 vid . addEventListener ( 'timeupdate' , ( ) => {
@@ -508,7 +625,7 @@ async function unlockVideo(triggerElement) {
508625 const ratio = Math . max ( 0 , Math . min ( 1 , ( e . clientX - rect . left ) / rect . width ) ) ;
509626 const hoverTime = ratio * vid . duration ;
510627
511- trackTooltip . textContent = formatTime ( hoverTime ) ;
628+ trackTooltipTime . textContent = formatTime ( hoverTime ) ;
512629 trackTooltip . style . left = `${ ratio * rect . width } px` ;
513630 trackTooltip . classList . add ( 'visible' ) ;
514631 } ) ;
@@ -532,7 +649,9 @@ async function unlockVideo(triggerElement) {
532649 activeHls = hls ;
533650 hls . loadSource ( finalUrl ) ;
534651 hls . attachMedia ( vid ) ;
652+ hls . on ( Hls . Events . MANIFEST_LOADING , ( ) => setLoadingState ( true , { immediate : true } ) ) ;
535653 hls . on ( Hls . Events . MANIFEST_PARSED , ( ) => {
654+ setLoadingState ( false ) ;
536655 const setQuality = ( level , label , optionRef = null , qualitySettings = null ) => {
537656 hls . currentLevel = level ;
538657 qualBtn . textContent = `${ label } ▴` ;
@@ -603,6 +722,7 @@ async function unlockVideo(triggerElement) {
603722 }
604723
605724 hls . on ( Hls . Events . ERROR , ( e , data ) => {
725+ if ( data . fatal ) setLoadingState ( false ) ;
606726 if ( data . fatal ) {
607727 if ( data . type === Hls . ErrorTypes . NETWORK_ERROR ) hls . startLoad ( ) ;
608728 else if ( data . type === Hls . ErrorTypes . MEDIA_ERROR ) hls . recoverMediaError ( ) ;
0 commit comments