@@ -18,9 +18,23 @@ extension SingletonPlayerWebView {
1818 // Volume enforcement: track target volume set by Swift
1919 // Don't set a default - only enforce when explicitly set by Swift
2020 // window.__kasetTargetVolume is set by volume init script at document start
21- let volumeEnforcementTimeout = null; // Debounce volume enforcement
2221 let isEnforcingVolume = false; // Prevent feedback loops
2322
23+ // Reusable 3-way volume enforcement (video element + YouTube APIs)
24+ function enforceVolumeNow() {
25+ const targetVol = window.__kasetTargetVolume;
26+ const v = document.querySelector('video');
27+ if (!v || typeof targetVol !== 'number' || Math.abs(v.volume - targetVol) <= 0.01) return;
28+ isEnforcingVolume = true;
29+ v.volume = targetVol;
30+ const ytVol = Math.round(targetVol * 100);
31+ const p = document.querySelector('ytmusic-player');
32+ if (p && p.playerApi) p.playerApi.setVolume(ytVol);
33+ const mp = document.getElementById('movie_player');
34+ if (mp && mp.setVolume) mp.setVolume(ytVol);
35+ setTimeout(() => { isEnforcingVolume = false; }, 50);
36+ }
37+
2438 function waitForPlayerBar() {
2539 const playerBar = document.querySelector('ytmusic-player-bar');
2640 if (playerBar) {
@@ -39,6 +53,8 @@ extension SingletonPlayerWebView {
3953 setTimeout(attachVideoListeners, 500);
4054 return;
4155 }
56+ if (video.__kasetListenersAttached) return;
57+ video.__kasetListenersAttached = true;
4258
4359 video.addEventListener('play', startPolling);
4460 video.addEventListener('playing', startPolling);
@@ -81,68 +97,33 @@ extension SingletonPlayerWebView {
8197 });
8298 }
8399
84- // Volume enforcement: listen for external volume changes with debounce
85- // This catches YouTube's attempts to change volume and reverts to our target
100+ // Volume enforcement: immediately revert external volume changes
101+ // No debounce — the isEnforcingVolume flag prevents feedback loops.
102+ // A debounce allowed YouTube's rapid-fire init events to keep pushing
103+ // enforcement later, leaving wrong volume audible for 1-2 seconds.
86104 video.addEventListener('volumechange', () => {
87- // Skip if we're currently enforcing (prevents feedback loop)
88105 if (isEnforcingVolume) return;
89-
90- // Debounce to prevent rapid-fire enforcement
91- if (volumeEnforcementTimeout) {
92- clearTimeout(volumeEnforcementTimeout);
93- }
94- volumeEnforcementTimeout = setTimeout(() => {
95- // Also skip if Swift is actively setting volume
96- if (window.__kasetIsSettingVolume) {
97- volumeEnforcementTimeout = null;
98- return;
99- }
100- const targetVol = window.__kasetTargetVolume;
101- const currentVideo = document.querySelector('video');
102- // Only enforce if target was explicitly set and differs significantly
103- if (currentVideo && typeof targetVol === 'number' && Math.abs(currentVideo.volume - targetVol) > 0.01) {
104- isEnforcingVolume = true;
105- currentVideo.volume = targetVol;
106-
107- // Also enforce via YouTube's internal APIs
108- const ytVolume = Math.round(targetVol * 100);
109- const player = document.querySelector('ytmusic-player');
110- if (player && player.playerApi) {
111- player.playerApi.setVolume(ytVolume);
112- }
113- const moviePlayer = document.getElementById('movie_player');
114- if (moviePlayer && moviePlayer.setVolume) {
115- moviePlayer.setVolume(ytVolume);
116- }
117-
118- // Clear flag after a tick to allow next external change to be caught
119- setTimeout(() => { isEnforcingVolume = false; }, 10);
120- }
121- volumeEnforcementTimeout = null;
122- }, 100);
106+ if (window.__kasetIsSettingVolume) return;
107+ enforceVolumeNow();
123108 });
124109
125- // CRITICAL: Apply target volume immediately when video element is first detected
126- // This handles the case where didFinish already set __kasetTargetVolume but
127- // the video element didn't exist yet. Without this, YouTube creates video at
128- // 100% and volumechange may never fire (no change from initial state).
129- const targetVol = window.__kasetTargetVolume;
130- if (typeof targetVol === 'number') {
131- isEnforcingVolume = true;
132- video.volume = targetVol;
133-
134- // Also set YouTube's internal player API volume
135- const player = document.querySelector('ytmusic-player');
136- if (player && player.playerApi) {
137- player.playerApi.setVolume(Math.round(targetVol * 100));
138- }
139- const moviePlayer = document.getElementById('movie_player');
140- if (moviePlayer && moviePlayer.setVolume) {
141- moviePlayer.setVolume(Math.round(targetVol * 100));
142- }
110+ // Enforce volume at media lifecycle events where YouTube resets volume.
111+ // YouTube's player often restores its stored volume at these points.
112+ video.addEventListener('loadedmetadata', () => enforceVolumeNow());
113+ video.addEventListener('loadeddata', () => enforceVolumeNow());
114+ video.addEventListener('canplay', () => enforceVolumeNow());
143115
144- setTimeout(() => { isEnforcingVolume = false; }, 10);
145- }
116+ // Apply target volume immediately when video element is first detected
117+ enforceVolumeNow();
118+
119+ // Startup enforcement burst: YouTube may reset volume up to ~2s after
120+ // playback starts (via internal player init, quality switching, etc.).
121+ // Enforce every 200ms for the first 3 seconds to catch delayed resets.
122+ let burstCount = 0;
123+ const burstInterval = setInterval(() => {
124+ enforceVolumeNow();
125+ if (++burstCount >= 15) clearInterval(burstInterval);
126+ }, 200);
146127
147128 // Start polling if already playing
148129 if (!video.paused) {
@@ -155,9 +136,7 @@ extension SingletonPlayerWebView {
155136 const videoObserver = new MutationObserver(() => {
156137 const video = document.querySelector('video');
157138 if (video && !video.__kasetListenersAttached) {
158- video.__kasetListenersAttached = true;
159139 attachVideoListeners();
160- // Note: attachVideoListeners now applies target volume immediately
161140 }
162141 });
163142 videoObserver.observe(document.body, { childList: true, subtree: true });
0 commit comments