fix moegirl ad blocker and optimize func

This commit is contained in:
2026-09-26 12:58:12 +08:00
parent e3e346b93a
commit 43e9e1b916
17 changed files with 550 additions and 302 deletions
@@ -22,9 +22,12 @@ object AndroidPlayerHolder {
.setContentType(C.AUDIO_CONTENT_TYPE_MUSIC)
.setUsage(C.USAGE_MEDIA)
.build(),
// 其他媒体永久抢焦点:暂停且不恢复。
// 电话/瞬时失去:暂停,焦点回来后恢复。
// 导航、通知等可闪避:音量降到约 0.2,结束后恢复原音量。
/* handleAudioFocus = */ true,
)
// 有线/蓝牙耳机拔出或断开时暂停。
// 有线/蓝牙耳机拔出或断开时暂停,不自动续播。媒体按键走 MediaSession。
.setHandleAudioBecomingNoisy(true)
.build()
player = created
@@ -46,7 +46,8 @@ class LibraryRepository(private val db: LlmpDatabase) {
val albumComparator = compareBy<Album>({ it.date.orEmpty() }, { it.albumId.orEmpty() })
// 各页正倒序由 UI 独立计算;此处只返回规范顺序。
val music = getAllMusic().filter { it.matches() }.sortedWith(musicComparator)
val allMusic = getAllMusic()
val music = allMusic.filter { it.matches() }.sortedWith(musicComparator)
// 可见歌曲已满足:导出筛选 +(HTTP 或本地 existFile)。
val musicIds = music.mapNotNull { it.musicId }.toSet()
val visibleAlbumIds = music.mapNotNull { it.albumId }.toSet()
@@ -66,7 +67,7 @@ class LibraryRepository(private val db: LlmpDatabase) {
val loved = getLovedMusic().filter { it.matches() }.asReversed()
// 最近播放与已筛选歌曲列表取交集。
// selectAllHistory 已按 timestamp DESC(最新在队首),规范顺序保持最新在前。
val recent = getHistoryMusic().filter { m ->
val recent = historyFrom(allMusic).filter { m ->
m.musicId in musicIds && (allGroup || m.group == group)
}
return LibraryLists(
@@ -192,10 +193,25 @@ class LibraryRepository(private val db: LlmpDatabase) {
fun getLovedMusic(): List<Music> =
q.selectLovedMusic().executeAsList().map { it.toDomain() }
fun getHistoryMusic(): List<Music> =
q.selectAllHistory().executeAsList().mapNotNull { h ->
getMusicById(h.musicId)?.copy(timestamp = h.timestamp)
fun getHistoryMusic(): List<Music> = historyFrom(getAllMusic())
/**
* 最近播放里、当前团组且(HTTP 或本地)可见的曲目。
* 只扫歌曲表和历史表,不加载专辑/艺人/歌单。
*/
fun recentVisible(group: String, enableHttp: Boolean): List<Music> {
val allGroup = group == "all"
return historyFrom(getAllMusic()).filter { music ->
(allGroup || music.group == group) && (enableHttp || music.existFile == true)
}
}
private fun historyFrom(allMusic: List<Music>): List<Music> {
val byId = allMusic.associateBy { it.musicId }
return q.selectAllHistory().executeAsList().mapNotNull { h ->
byId[h.musicId]?.copy(timestamp = h.timestamp)
}
}
fun upsertMusic(music: Music) {
val id = music.musicId ?: return
@@ -374,11 +390,14 @@ class LibraryRepository(private val db: LlmpDatabase) {
* 按 `ids` 顺序获取曲目;
* HTTP 关闭时跳过缺失或非本地文件。
*/
fun getMusicByIdsPreservingOrder(ids: List<String>, enableHttp: Boolean): List<Music> =
ids.mapNotNull { id ->
val music = getMusicById(id) ?: return@mapNotNull null
fun getMusicByIdsPreservingOrder(ids: List<String>, enableHttp: Boolean): List<Music> {
if (ids.isEmpty()) return emptyList()
val byId = getAllMusic().associateBy { it.musicId }
return ids.mapNotNull { id ->
val music = byId[id] ?: return@mapNotNull null
if (!enableHttp && music.existFile != true) null else music
}
}
fun savePlayList(items: List<Music>, playingId: String?) {
q.deletePlayList()
@@ -5,215 +5,87 @@ import kotlinx.serialization.Serializable
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
private val contentBlockerJson = Json { explicitNulls = false }
/**
* 应用内 WebView 广告过滤。
* Android:拦截请求 + 页面脚本;iOS:WKContentRuleList + UserScript。
* 对齐 Flutter 萌娘百科页:少量 URL 拦截 + 选择器隐藏,DOM 清理由原生每秒执行一次。
* 不在页面里安装具名函数或 setInterval,避免被站点当成广告拦截脚本。
*/
object WebViewAdBlock {
val blockedHostSuffixes: List<String> = listOf(
"doubleclick.net",
"googlesyndication.com",
"googleadservices.com",
"google-analytics.com",
"googletagmanager.com",
"googletagservices.com",
"adservice.google.com",
"pagead2.googlesyndication.com",
"ads.youtube.com",
"cpro.baidu.com",
"pos.baidu.com",
"hm.baidu.com",
"tanx.com",
"alimama.com",
"mmstat.com",
"cnzz.com",
"irs01.com",
"irs01.net",
"admaster.com.cn",
"gdt.qq.com",
"e.qq.com",
"ad.qq.com",
"adsrvr.org",
"adnxs.com",
"criteo.com",
"criteo.net",
"taboola.com",
"outbrain.com",
"media.net",
"a-mo.net",
"moatads.com",
"scorecardresearch.com",
"quantserve.com",
"adform.net",
"adform.com",
"rubiconproject.com",
"pubmatic.com",
"openx.net",
"casalemedia.com",
"contextweb.com",
"lijit.com",
"amazon-adsystem.com",
"fundingchoicesmessages.google.com",
"fondrea.com",
"fundeary.com",
"grfz.laogu.wang",
"laogu.wang",
"intellitxt.com",
"exoclick.com",
"juicyads.com",
"propellerads.com",
"popads.net",
"mgid.com",
"revcontent.com",
"zergnet.com",
"yieldmo.com",
"sharethrough.com",
"spotxchange.com",
"teads.tv",
"smartadserver.com",
"serving-sys.com",
"2mdn.net",
"inmobi.com",
"chartbeat.com",
"hotjar.com",
"clarity.ms",
)
private val adUrlFilters: List<Regex> = listOf(
".*.doubleclick.net/.*",
".*.ads.pubmatic.com/.*",
".*.googlesyndication.com/.*",
".*.google-analytics.com/.*",
".*.adservice.google.*/.*",
".*.adbrite.com/.*",
".*.exponential.com/.*",
".*.quantserve.com/.*",
".*.scorecardresearch.com/.*",
".*.zedo.com/.*",
".*.adsafeprotected.com/.*",
".*.teads.tv/.*",
".*.outbrain.com/.*",
).map { Regex(it, RegexOption.IGNORE_CASE) }
private val cssHideSelectors: List<String> = listOf(
"ins.adsbygoogle",
".adsbygoogle",
".google-auto-placed",
"[id*='google_ads']",
"[class*='google-ad']",
"[data-ad-slot]",
"[data-ad-client]",
"iframe[src*='doubleclick']",
"iframe[src*='googlesyndication']",
"iframe[src*='adservice']",
"iframe[src*='fondrea']",
"iframe[src*='fundeary']",
".fc-ab-root",
)
const val cosmeticSelector: String = ".banner, .banners, .ads, .ad, .advert"
fun isMoegirlUrl(url: String): Boolean =
url.contains("moegirl.org", ignoreCase = true)
fun isBlockedHost(host: String): Boolean {
val normalized = host.trim().lowercase().removePrefix("www.")
if (normalized.isEmpty()) return false
return blockedHostSuffixes.any { suffix ->
normalized == suffix || normalized.endsWith(".$suffix")
}
fun isBlockedUrl(url: String): Boolean {
if (url.isBlank()) return false
return adUrlFilters.any { it.containsMatchIn(url) }
}
fun injectionScript(url: String): String =
if (isMoegirlUrl(url)) moegirlCleanupScript() else cssHideScript()
/**
* 萌百广告清理:装一次后每秒自跑。
* 不要在 Kotlin raw string 里写 `\\.`,会变成 JS 里的字面反斜杠。
*/
fun moegirlCleanupScript(): String = """
/** 只跑一遍,由原生定时器重复调用。 */
fun domCleanupScript(): String = """
(function() {
if ((location.host || '').indexOf('moegirl.org') === -1) return;
if (window.__llmpAdblock) {
window.__llmpAdblock();
return;
}
var KEYWORDS = ['广告','推广','推廣','廣告','谷歌广告','百度推广','百度推廣','加载中','ads','promotions'];
function compact(text) {
return (text || '').replace(/\s+/g, '').replace(/[×x✕✖X]/g, '').toLowerCase();
}
function isBadge(el) {
var text = (el.innerText || el.textContent || '').trim();
return text.length <= 16 && KEYWORDS.indexOf(compact(text)) >= 0;
}
function isStop(node) {
if (!node || node === document.body || node === document.documentElement) return true;
var id = node.id || '';
return id === 'moe-article' || id === 'moe-main-container' || id === 'app' || id === 'content';
}
function pickRoot(badge) {
var last = null;
for (var node = badge; node && !isStop(node); node = node.parentElement) {
if (node.classList && node.classList.contains('mw-parser-output')) break;
if (node.classList && (
node.classList.contains('moe-card') ||
node.classList.contains('fc-ab-root') ||
node.classList.contains('adsbygoogle')
)) return node;
var style = getComputedStyle(node);
var box = node.getBoundingClientRect();
if ((style.position === 'fixed' || style.position === 'sticky') &&
box.height >= 36 && box.height < innerHeight * 0.6 &&
box.width >= innerWidth * 0.35) return node;
if (node.querySelector && node.querySelector('img,iframe,video,canvas') &&
box.height >= 64 && box.height <= innerHeight * 0.55 && box.width >= 180) {
last = node;
var divElements = document.querySelectorAll('*');
var keywords = ['广告', '推广', '谷歌广告', '加载中'];
for (var parent of divElements) {
if (parent.className === 'adsbygoogle') {
parent.remove();
}
var childNodes = parent.childNodes;
if (childNodes.length <= 3) {
for (var node of childNodes) {
var elementText = node.textContent;
if (keywords.includes(elementText)) {
parent.remove();
break;
}
}
}
return last;
}
function run() {
try {
document.querySelectorAll('.adsbygoogle,ins.adsbygoogle,.fc-ab-root,#mw-data-after-content')
.forEach(function(node) { node.remove(); });
document.querySelectorAll('a,span,div,small,label,em,i,button').forEach(function(el) {
if (!isBadge(el)) return;
var root = pickRoot(el);
if (root && !root.closest('#footer-moegirl,footer.mw-footer')) root.remove();
});
} catch (e) {}
}
window.__llmpAdblock = run;
run();
setInterval(run, 1000);
})();
""".trimIndent()
fun cssHideScript(): String {
val selectors = cssHideSelectors.joinToString(",") { it.replace("'", "\\'") }
return """
(function() {
if (window.__llmpCssAdblock) return;
window.__llmpCssAdblock = true;
var selectors = '$selectors';
function hide() {
try {
selectors.split(',').forEach(function(sel) {
document.querySelectorAll(sel).forEach(function(node) {
node.style.setProperty('display', 'none', 'important');
});
});
} catch (e) {}
}
hide();
setInterval(hide, 1000);
})();
""".trimIndent()
}
/** Android 没有内容规则引擎,用一次性样式等价 CSS display none。 */
fun cosmeticStyleScript(): String = """
(function() {
var css = '$cosmeticSelector { display: none !important; }';
var nodes = document.getElementsByTagName('style');
for (var i = 0; i < nodes.length; i++) {
if (nodes[i].textContent === css) return;
}
var style = document.createElement('style');
style.appendChild(document.createTextNode(css));
(document.head || document.documentElement).appendChild(style);
})();
""".trimIndent()
fun buildContentBlockerJson(): String {
val rules = blockedHostSuffixes.map { host ->
val rules = adUrlFilters.map { pattern ->
ContentBlockerRule(
trigger = ContentBlockerTrigger(urlFilter = ".*$host"),
trigger = ContentBlockerTrigger(urlFilter = pattern.pattern),
action = ContentBlockerAction(type = "block"),
)
} + ContentBlockerRule(
trigger = ContentBlockerTrigger(urlFilter = ".*"),
action = ContentBlockerAction(
type = "css-display-none",
selector = cssHideSelectors.joinToString(", "),
selector = cosmeticSelector,
),
)
return Json.encodeToString(rules)
return contentBlockerJson.encodeToString(rules)
}
}
@@ -10,7 +10,7 @@ import kotlinx.coroutines.launch
class SleepTimer(
private val scope: CoroutineScope,
private val onFire: () -> Unit,
private val onFire: suspend () -> Unit,
) {
private val _remainingMs = MutableStateFlow(0L)
val remainingMs: StateFlow<Long> = _remainingMs.asStateFlow()
@@ -98,11 +98,9 @@ class PathResolver(
*/
fun coverDisplayUri(music: Music): String? {
if (music.musicId == null) return null
// 不在 UI 线程 stat。文件缺失时由图片加载失败落到 Logo。
if (music.existFile == true) {
val path = absoluteCoverPath(music)
if (path != null && fs.exists(path)) {
return "file://$path"
}
absoluteCoverPath(music)?.let { return "file://$it" }
}
if (settings.enableHttp) {
val relative = joinMediaRelativePath(music.baseUrl, music.coverPath) ?: return null
@@ -115,10 +113,7 @@ class PathResolver(
fun coverDisplayUri(album: Album): String? {
val rel = album.coverPath ?: return null
if (album.existFile == true) {
val path = absoluteAlbumCoverPath(album)
if (path != null && fs.exists(path)) {
return "file://$path"
}
absoluteAlbumCoverPath(album)?.let { return "file://$it" }
}
if (settings.enableHttp) {
return joinHttpUrl(rel)
@@ -13,6 +13,12 @@ import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import platform.AVFAudio.AVAudioSession
import platform.AVFAudio.AVAudioSessionCategoryPlayback
import platform.AVFAudio.AVAudioSessionInterruptionNotification
import platform.AVFAudio.AVAudioSessionInterruptionOptionKey
import platform.AVFAudio.AVAudioSessionInterruptionOptionShouldResume
import platform.AVFAudio.AVAudioSessionInterruptionTypeBegan
import platform.AVFAudio.AVAudioSessionInterruptionTypeEnded
import platform.AVFAudio.AVAudioSessionInterruptionTypeKey
import platform.AVFAudio.AVAudioSessionRouteChangeNotification
import platform.AVFAudio.AVAudioSessionRouteChangeReasonKey
import platform.AVFAudio.AVAudioSessionRouteChangeReasonOldDeviceUnavailable
@@ -82,6 +88,13 @@ actual class PlayerController {
private var endObserver: Any? = null
private var failObserver: Any? = null
private var routeObserver: Any? = null
private var interruptionObserver: Any? = null
/**
* 电话、闹钟、其他音频会话打断前正在播放。
* 用户在打断期间手动暂停则清掉,结束后不再自动恢复。
*/
private var resumeAfterInterruption = false
private val _state = MutableStateFlow(PlayerState())
actual val state: StateFlow<PlayerState> = _state.asStateFlow()
@@ -120,6 +133,7 @@ actual class PlayerController {
observePlaybackEnd()
observePlaybackFailed()
observeAudioRouteChanges()
observeAudioInterruptions()
}
private fun ensureAudioReady() {
@@ -284,7 +298,45 @@ actual class PlayerController {
}
}
/** 耳机/蓝牙音频路由不可用时暂停(拔出/断开)。 */
/**
* 电话 / 闹钟 / 其他 App 占用音频会话时暂停。
* 结束且系统给出 ShouldResume 才恢复;导航等短提示由系统把本会话音量闪避,不必自己改 volume。
*/
private fun observeAudioInterruptions() {
interruptionObserver = NSNotificationCenter.defaultCenter.addObserverForName(
name = AVAudioSessionInterruptionNotification,
`object` = AVAudioSession.sharedInstance(),
queue = null,
) { notification: NSNotification? ->
val info = notification?.userInfo
val type = (info?.get(AVAudioSessionInterruptionTypeKey) as? NSNumber)?.unsignedLongValue
val options =
(info?.get(AVAudioSessionInterruptionOptionKey) as? NSNumber)?.unsignedLongValue ?: 0uL
dispatch_async(dispatch_get_main_queue()) {
when (type) {
AVAudioSessionInterruptionTypeBegan -> {
val wasPlaying = _state.value.isPlaying
if (wasPlaying) {
pause()
resumeAfterInterruption = true
} else {
resumeAfterInterruption = false
}
}
AVAudioSessionInterruptionTypeEnded -> {
val shouldResume =
(options and AVAudioSessionInterruptionOptionShouldResume) != 0uL
val resume = shouldResume && resumeAfterInterruption
resumeAfterInterruption = false
if (resume) play()
}
}
}
}
}
/** 耳机/蓝牙音频路由不可用时暂停(拔出/断开),不自动续播。 */
private fun observeAudioRouteChanges() {
routeObserver = NSNotificationCenter.defaultCenter.addObserverForName(
name = AVAudioSessionRouteChangeNotification,
@@ -540,6 +592,7 @@ actual class PlayerController {
}
actual fun pause() {
resumeAfterInterruption = false
endScrub()
player.pause()
positionJob?.cancel()