Skip to content

Commit fc31073

Browse files
committed
feat: enhance background media playback stability
Improve background playback reliability by forcing WebView focus states and visibility, ensuring media sessions remain active even when the app is backgrounded. Added fallback metadata and notification updates to improve the media controller experience.
1 parent 4a84002 commit fc31073

4 files changed

Lines changed: 169 additions & 81 deletions

File tree

app/src/main/java/com/example/media/MediaPlaybackService.kt

Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -65,14 +65,6 @@ class MediaPlaybackService : Service() {
6565
artwork = cachedArtworkBitmap
6666
)
6767
promoteToForeground(initialNotification)
68-
69-
if (metadata == null && !isPlaying) {
70-
mediaSession?.setActive(false)
71-
stopForeground(STOP_FOREGROUND_REMOVE)
72-
val manager = getSystemService(Context.NOTIFICATION_SERVICE) as? NotificationManager
73-
manager?.cancel(NOTIFICATION_ID)
74-
stopSelf()
75-
}
7668
}
7769

7870
private fun setupMediaSession() {
@@ -188,6 +180,10 @@ class MediaPlaybackService : Service() {
188180

189181
val notification = buildNotification(title, artist, album, isPlaying, cachedArtworkBitmap)
190182
promoteToForeground(notification)
183+
try {
184+
val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
185+
manager.notify(NOTIFICATION_ID, notification)
186+
} catch (e: Exception) { }
191187

192188
// Asynchronously fetch artwork if new URL provided
193189
if (artworkUrl.isNotBlank() && artworkUrl != cachedArtworkUrl) {

app/src/main/java/com/example/media/MediaSessionManager.kt

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,15 @@ object MediaSessionManager {
9696
_activeMediaTabId.value = tabId
9797

9898
if (playing) {
99+
if (_currentMetadata.value == null) {
100+
_currentMetadata.value = BrowserMediaMetadata(
101+
title = "YouTube Video",
102+
artist = "YouTube",
103+
album = "Feather Browser",
104+
artworkUrl = "",
105+
tabId = tabId
106+
)
107+
}
99108
startOrUpdateService(context)
100109
} else if (isServiceActive) {
101110
startOrUpdateService(context)

app/src/main/java/com/example/privacy/FingerprintScriptGenerator.kt

Lines changed: 127 additions & 73 deletions
Original file line numberDiff line numberDiff line change
@@ -79,32 +79,26 @@ object FingerprintScriptGenerator {
7979
window.__feather_bg_play_active = true;
8080
8181
// 1. Spoof Page Visibility API so websites (like YouTube) report visible and focused
82-
try {
83-
const defineProp = function(obj, prop, value) {
84-
try {
85-
Object.defineProperty(obj, prop, {
86-
get: function() { return value; },
87-
set: function() {},
88-
configurable: true,
89-
enumerable: true
90-
});
91-
} catch(e) {}
92-
};
82+
['hidden', 'webkitHidden'].forEach(function(prop) {
83+
try {
84+
Object.defineProperty(document, prop, { get: function() { return false; }, configurable: true, enumerable: true });
85+
Object.defineProperty(Document.prototype, prop, { get: function() { return false; }, configurable: true, enumerable: true });
86+
} catch(e) {}
87+
});
9388
94-
defineProp(document, 'hidden', false);
95-
defineProp(document, 'visibilityState', 'visible');
96-
defineProp(document, 'webkitHidden', false);
97-
defineProp(document, 'webkitVisibilityState', 'visible');
98-
defineProp(Document.prototype, 'hidden', false);
99-
defineProp(Document.prototype, 'visibilityState', 'visible');
100-
defineProp(Document.prototype, 'webkitHidden', false);
101-
defineProp(Document.prototype, 'webkitVisibilityState', 'visible');
102-
89+
['visibilityState', 'webkitVisibilityState'].forEach(function(prop) {
90+
try {
91+
Object.defineProperty(document, prop, { get: function() { return 'visible'; }, configurable: true, enumerable: true });
92+
Object.defineProperty(Document.prototype, prop, { get: function() { return 'visible'; }, configurable: true, enumerable: true });
93+
} catch(e) {}
94+
});
95+
96+
try {
10397
document.hasFocus = function() { return true; };
10498
Document.prototype.hasFocus = function() { return true; };
10599
} catch(e) {}
106100
107-
// 2. Prevent YouTube from auto-pausing on tab change: intercept visibilitychange event listeners
101+
// 2. Prevent YouTube from auto-pausing on tab change: intercept visibilitychange and pagehide
108102
try {
109103
const origAddEventListener = EventTarget.prototype.addEventListener;
110104
EventTarget.prototype.addEventListener = function(type, listener, options) {
@@ -114,19 +108,50 @@ object FingerprintScriptGenerator {
114108
return origAddEventListener.apply(this, arguments);
115109
};
116110
117-
['visibilitychange', 'webkitvisibilitychange'].forEach(function(evt) {
118-
origAddEventListener.call(window, evt, function(e) {
111+
['visibilitychange', 'webkitvisibilitychange', 'pagehide'].forEach(function(evt) {
112+
window.addEventListener(evt, function(e) {
119113
if (e && e.stopImmediatePropagation) e.stopImmediatePropagation();
120114
if (e && e.stopPropagation) e.stopPropagation();
121115
}, true);
122-
origAddEventListener.call(document, evt, function(e) {
116+
document.addEventListener(evt, function(e) {
123117
if (e && e.stopImmediatePropagation) e.stopImmediatePropagation();
124118
if (e && e.stopPropagation) e.stopPropagation();
125119
}, true);
126120
});
127121
} catch(e) {}
128122
129-
// 3. Media Session Hooking for Android notification metadata & controls
123+
// 3. Spoof IntersectionObserver for video / player elements so YouTube mobile doesn't pause when offscreen
124+
try {
125+
if ('IntersectionObserver' in window) {
126+
const OrigObserver = window.IntersectionObserver;
127+
window.IntersectionObserver = function(callback, options) {
128+
const wrappedCallback = function(entries, obs) {
129+
try {
130+
const modified = entries.map(function(entry) {
131+
const target = entry.target;
132+
if (target && (target.tagName === 'VIDEO' || target.id === 'player' || (target.className && typeof target.className === 'string' && (target.className.indexOf('player') !== -1 || target.className.indexOf('video') !== -1)))) {
133+
return new Proxy(entry, {
134+
get: function(t, p) {
135+
if (p === 'isIntersecting') return true;
136+
if (p === 'intersectionRatio') return 1.0;
137+
return t[p];
138+
}
139+
});
140+
}
141+
return entry;
142+
});
143+
return callback(modified, obs);
144+
} catch(e) {
145+
return callback(entries, obs);
146+
}
147+
};
148+
return new OrigObserver(wrappedCallback, options);
149+
};
150+
window.IntersectionObserver.prototype = OrigObserver.prototype;
151+
}
152+
} catch(e) {}
153+
154+
// 4. Media Session Hooking for Android notification metadata & controls
130155
window.__feather_actions = window.__feather_actions || {};
131156
132157
function getMediaThumbnail() {
@@ -147,22 +172,22 @@ object FingerprintScriptGenerator {
147172
if (navigator.mediaSession && navigator.mediaSession.metadata && navigator.mediaSession.metadata.title) {
148173
return navigator.mediaSession.metadata.title;
149174
}
150-
const ytTitle = document.querySelector('h1.title, .slim-video-metadata-title, ytm-slim-video-metadata-section-renderer .slim-video-information-title, ytd-watch-metadata #title h1, .ytp-title-link');
175+
const ytTitle = document.querySelector('h1.title, .slim-video-metadata-title, ytm-slim-video-metadata-section-renderer .slim-video-information-title, ytd-watch-metadata #title h1, .ytp-title-link, [class*="video-title"]');
151176
if (ytTitle && ytTitle.innerText && ytTitle.innerText.trim()) {
152177
return ytTitle.innerText.trim();
153178
}
154179
const docTitle = document.title.replace(/ - YouTube$/i, '').replace(/^\(\d+\)\s*/, '').trim();
155180
if (docTitle && docTitle !== 'YouTube') return docTitle;
156181
} catch(e) {}
157-
return 'YouTube';
182+
return 'YouTube Video';
158183
}
159184
160185
function getMediaArtist() {
161186
try {
162187
if (navigator.mediaSession && navigator.mediaSession.metadata && navigator.mediaSession.metadata.artist) {
163188
return navigator.mediaSession.metadata.artist;
164189
}
165-
const ytAuthor = document.querySelector('.ytm-channel-thumbnail-with-profile-name .profile-name, #owner-name a, #channel-name a, ytd-channel-name a, .ytm-media-item-metadata .channel-name');
190+
const ytAuthor = document.querySelector('.ytm-channel-thumbnail-with-profile-name .profile-name, #owner-name a, #channel-name a, ytd-channel-name a, .ytm-media-item-metadata .channel-name, [class*="channel-name"]');
166191
if (ytAuthor && ytAuthor.innerText && ytAuthor.innerText.trim()) {
167192
return ytAuthor.innerText.trim();
168193
}
@@ -175,59 +200,79 @@ object FingerprintScriptGenerator {
175200
function notifyMediaBridge(isPlaying) {
176201
try {
177202
if (!window.FeatherMediaBridge) return;
178-
if (!isPlaying && !wasActivePlayback) return;
179-
180203
if (isPlaying) {
181204
wasActivePlayback = true;
182205
const title = getMediaTitle();
183206
const artist = getMediaArtist();
184207
const art = getMediaThumbnail();
185-
window.FeatherMediaBridge.updateMetadata(title, artist, 'Feather Browser', art);
208+
window.FeatherMediaBridge.updateMetadata(title, artist, 'YouTube', art);
209+
window.FeatherMediaBridge.updatePlaybackState(true);
210+
} else {
211+
if (wasActivePlayback) {
212+
window.FeatherMediaBridge.updatePlaybackState(false);
213+
}
186214
}
187-
window.FeatherMediaBridge.updatePlaybackState(isPlaying);
188215
} catch(e) {}
189216
}
190217
191218
try {
192219
if ('mediaSession' in navigator) {
193-
const origMS = navigator.mediaSession;
220+
const ms = navigator.mediaSession;
221+
let _currentMetadata = ms.metadata || null;
222+
194223
try {
195-
const origSetMetadata = Object.getOwnPropertyDescriptor(MediaSession.prototype, 'metadata')?.set;
196-
if (origSetMetadata) {
197-
Object.defineProperty(origMS, 'metadata', {
198-
set: function(val) {
199-
try {
200-
if (window.FeatherMediaBridge && val) {
201-
let artUrl = '';
202-
if (val.artwork && val.artwork.length > 0) {
203-
artUrl = val.artwork[val.artwork.length - 1].src || '';
204-
}
205-
window.FeatherMediaBridge.updateMetadata(
206-
val.title || getMediaTitle(),
207-
val.artist || getMediaArtist(),
208-
val.album || 'Feather Browser',
209-
artUrl || getMediaThumbnail()
210-
);
224+
const proto = Object.getPrototypeOf(ms) || ms;
225+
const desc = Object.getOwnPropertyDescriptor(proto, 'metadata') || Object.getOwnPropertyDescriptor(ms, 'metadata');
226+
Object.defineProperty(ms, 'metadata', {
227+
get: function() {
228+
if (desc && desc.get) {
229+
try { return desc.get.call(this); } catch(e) {}
230+
}
231+
return _currentMetadata;
232+
},
233+
set: function(val) {
234+
_currentMetadata = val;
235+
if (desc && desc.set) {
236+
try { desc.set.call(this, val); } catch(e) {}
237+
}
238+
try {
239+
if (window.FeatherMediaBridge && val) {
240+
let artUrl = '';
241+
if (val.artwork && val.artwork.length > 0) {
242+
artUrl = val.artwork[val.artwork.length - 1].src || '';
211243
}
212-
} catch(e) {}
213-
return origSetMetadata.call(this, val);
214-
},
215-
configurable: true
216-
});
217-
}
244+
const t = val.title || getMediaTitle();
245+
const a = val.artist || getMediaArtist();
246+
window.FeatherMediaBridge.updateMetadata(
247+
t,
248+
a,
249+
val.album || 'YouTube',
250+
artUrl || getMediaThumbnail()
251+
);
252+
window.FeatherMediaBridge.updatePlaybackState(true);
253+
}
254+
} catch(e) {}
255+
},
256+
configurable: true,
257+
enumerable: true
258+
});
218259
} catch(e) {}
219260
220261
try {
221-
const origSetHandler = origMS.setActionHandler.bind(origMS);
222-
origMS.setActionHandler = function(action, handler) {
223-
window.__feather_actions[action] = handler;
224-
return origSetHandler(action, handler);
225-
};
262+
const origSetHandler = ms.setActionHandler;
263+
if (typeof origSetHandler === 'function') {
264+
ms.setActionHandler = function(action, handler) {
265+
window.__feather_actions[action] = handler;
266+
try {
267+
return origSetHandler.call(ms, action, handler);
268+
} catch(e) {}
269+
};
270+
}
226271
} catch(e) {}
227272
}
228273
} catch(e) {}
229274
230-
// 4. Media control methods callable from notification bar
275+
// 5. Media control methods callable from notification bar
231276
window.__feather_media_play = function() {
232277
try {
233278
if (window.__feather_actions && typeof window.__feather_actions['play'] === 'function') {
@@ -331,28 +376,37 @@ object FingerprintScriptGenerator {
331376
}
332377
};
333378
334-
// 5. Periodic monitor & watchdog for HTML media elements
379+
// 6. Periodic monitor & watchdog for HTML media elements
335380
let lastReportedState = null;
336381
let lastReportedTitle = '';
337382
338383
function hookMediaElement(el) {
339384
if (!el || el.__feather_monitored) return;
340385
el.__feather_monitored = true;
341386
342-
['play', 'playing'].forEach(function(evt) {
343-
el.addEventListener(evt, function() {
387+
function handlePlay() {
388+
wasActivePlayback = true;
389+
notifyMediaBridge(true);
390+
}
391+
392+
el.addEventListener('play', handlePlay);
393+
el.addEventListener('playing', handlePlay);
394+
el.addEventListener('timeupdate', function() {
395+
if (!el.paused && !el.ended && !wasActivePlayback) {
344396
wasActivePlayback = true;
345397
notifyMediaBridge(true);
346-
});
398+
}
347399
});
348400
349401
el.addEventListener('pause', function() {
350-
const anyStillPlaying = Array.from(document.querySelectorAll('video, audio')).some(function(m) {
351-
return !m.paused && !m.ended;
352-
});
353-
if (!anyStillPlaying) {
354-
notifyMediaBridge(false);
355-
}
402+
setTimeout(function() {
403+
const anyPlaying = Array.from(document.querySelectorAll('video, audio')).some(function(m) {
404+
return !m.paused && !m.ended;
405+
});
406+
if (!anyPlaying) {
407+
notifyMediaBridge(false);
408+
}
409+
}, 250);
356410
});
357411
358412
el.addEventListener('ended', function() {
@@ -369,7 +423,7 @@ object FingerprintScriptGenerator {
369423
let anyPlaying = false;
370424
els.forEach(function(el) {
371425
hookMediaElement(el);
372-
if (!el.paused && !el.ended) {
426+
if (!el.paused && !el.ended && el.currentTime > 0) {
373427
anyPlaying = true;
374428
}
375429
});
@@ -388,7 +442,7 @@ object FingerprintScriptGenerator {
388442
}
389443
} catch(e) {}
390444
};
391-
setInterval(monitorMedia, 1000);
445+
setInterval(monitorMedia, 800);
392446
monitorMedia();
393447
} catch(e) {}
394448
})();

0 commit comments

Comments
 (0)