fix moegirl ad blocker and optimize func
This commit is contained in:
+4
-1
@@ -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
|
||||
|
||||
+27
-8
@@ -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()
|
||||
|
||||
+56
-184
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -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()
|
||||
|
||||
+3
-8
@@ -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)
|
||||
|
||||
+54
-1
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user