웹 브라우저: YouTube 광고 자동 건너뛰기 · 광고 배너 숨기기 (환경 설정에서 켜기 · 끄기)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>main
parent
fb92349176
commit
9a0d361565
|
|
@ -0,0 +1,71 @@
|
||||||
|
/// YouTube 광고 건너뛰기 · 광고 영역 숨기기 스크립트 (앱 안 웹 브라우저가 YouTube 페이지에 넣는다).
|
||||||
|
///
|
||||||
|
/// - 건너뛰기: [건너뛰기] 버튼이 나오면 누르고, 건너뛸 수 없는 광고는 소리를 끄고 끝으로 빨리 감는다.
|
||||||
|
/// 광고가 나오는 동안 화면 위쪽에 "광고 건너뛰는 중…" 을 잠깐 보여 준다.
|
||||||
|
/// - 숨기기: 목록 · 영상 옆 · 영상 위의 광고 배너를 감춘다.
|
||||||
|
///
|
||||||
|
/// 여러 번 넣어도 한 번만 동작하고, 다시 넣으면 켜기 · 끄기 설정만 바뀐다 (페이지를 다시 읽지 않아도 됨).
|
||||||
|
String youtubeAdScript({required bool skip, required bool hide}) => '''
|
||||||
|
(function(cfg){
|
||||||
|
var h = location.hostname;
|
||||||
|
if (h !== 'youtube.com' && h.slice(-12) !== '.youtube.com') return;
|
||||||
|
window.__jjAdCfg = cfg;
|
||||||
|
if (window.__jjAd) return;
|
||||||
|
window.__jjAd = 1;
|
||||||
|
var css = null, badge = null;
|
||||||
|
var HIDE = 'ytd-ad-slot-renderer,ytd-in-feed-ad-layout-renderer,ytd-banner-promo-renderer,'
|
||||||
|
+ 'ytd-promoted-sparkles-web-renderer,ytd-promoted-video-renderer,ytd-display-ad-renderer,'
|
||||||
|
+ 'ytd-companion-slot-renderer,ytd-action-companion-ad-renderer,ytd-statement-banner-renderer,'
|
||||||
|
+ '#player-ads,#masthead-ad,.ytp-ad-overlay-container,.ytp-ad-overlay-slot,.ytp-ad-image-overlay,'
|
||||||
|
+ 'ytm-promoted-sparkles-web-renderer,ytm-companion-ad-renderer,ytm-promoted-video-renderer,'
|
||||||
|
+ 'ad-slot-renderer,ytm-ad-slot-renderer,.ad-container,.masthead-ad'
|
||||||
|
+ '{display:none !important}';
|
||||||
|
function showBadge(on) {
|
||||||
|
if (on && !badge) {
|
||||||
|
badge = document.createElement('div');
|
||||||
|
badge.textContent = '광고 건너뛰는 중…';
|
||||||
|
badge.style.cssText = 'position:fixed;top:12px;left:50%;transform:translateX(-50%);z-index:2147483647;'
|
||||||
|
+ 'background:rgba(0,0,0,.75);color:#fff;font:600 13px sans-serif;padding:6px 14px;border-radius:14px;pointer-events:none';
|
||||||
|
(document.body || document.documentElement).appendChild(badge);
|
||||||
|
} else if (!on && badge) {
|
||||||
|
badge.remove();
|
||||||
|
badge = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function tick() {
|
||||||
|
var c = window.__jjAdCfg || {};
|
||||||
|
if (c.hide && !css) {
|
||||||
|
css = document.createElement('style');
|
||||||
|
css.textContent = HIDE;
|
||||||
|
(document.head || document.documentElement).appendChild(css);
|
||||||
|
} else if (!c.hide && css) {
|
||||||
|
css.remove();
|
||||||
|
css = null;
|
||||||
|
}
|
||||||
|
var v = document.querySelector('video');
|
||||||
|
var p = document.querySelector('.html5-video-player');
|
||||||
|
var ad = !!(p && (p.classList.contains('ad-showing') || p.classList.contains('ad-interrupting')));
|
||||||
|
if (c.skip && ad) {
|
||||||
|
var b = document.querySelector('.ytp-skip-ad-button,.ytp-ad-skip-button,.ytp-ad-skip-button-modern,'
|
||||||
|
+ '.ytp-ad-skip-button-container button,.ytp-ad-skip-button-slot button');
|
||||||
|
if (b) { try { b.click(); } catch (e) {} }
|
||||||
|
if (v) {
|
||||||
|
if (!v.__jjAd) { v.__jjAd = 1; v.__jjMuted = v.muted; v.__jjRate = v.playbackRate; }
|
||||||
|
v.muted = true;
|
||||||
|
try { if (isFinite(v.duration) && v.duration > 0.5) v.currentTime = v.duration - 0.1; } catch (e) {}
|
||||||
|
try { v.playbackRate = 16; } catch (e) {}
|
||||||
|
}
|
||||||
|
showBadge(true);
|
||||||
|
} else {
|
||||||
|
if (v && v.__jjAd) {
|
||||||
|
v.__jjAd = 0;
|
||||||
|
v.muted = v.__jjMuted;
|
||||||
|
try { v.playbackRate = v.__jjRate || 1; } catch (e) {}
|
||||||
|
}
|
||||||
|
showBadge(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setInterval(tick, 300);
|
||||||
|
tick();
|
||||||
|
})({skip: $skip, hide: $hide});
|
||||||
|
''';
|
||||||
|
|
@ -13,6 +13,7 @@ import '../app/download_manager.dart';
|
||||||
import '../core/bookmarks.dart';
|
import '../core/bookmarks.dart';
|
||||||
import '../core/download_detect.dart' show CookieRecord, toNetscapeCookies;
|
import '../core/download_detect.dart' show CookieRecord, toNetscapeCookies;
|
||||||
import '../core/web_address.dart';
|
import '../core/web_address.dart';
|
||||||
|
import '../core/youtube_ads.dart';
|
||||||
import '../platform/windows/cef_runtime.dart';
|
import '../platform/windows/cef_runtime.dart';
|
||||||
import '../platform/windows/com_guard.dart';
|
import '../platform/windows/com_guard.dart';
|
||||||
import '../services/downloader.dart' show DownloadState;
|
import '../services/downloader.dart' show DownloadState;
|
||||||
|
|
@ -35,6 +36,9 @@ abstract class WebNav {
|
||||||
|
|
||||||
/// 페이지의 동영상 · 소리 멈추기 (다른 화면으로 갈 때)
|
/// 페이지의 동영상 · 소리 멈추기 (다른 화면으로 갈 때)
|
||||||
Future<void> pauseMedia();
|
Future<void> pauseMedia();
|
||||||
|
|
||||||
|
/// 페이지에서 스크립트 실행 (YouTube 광고 건너뛰기 등)
|
||||||
|
Future<void> runScript(String js);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 페이지의 모든 동영상 · 소리를 멈추는 스크립트
|
/// 페이지의 모든 동영상 · 소리를 멈추는 스크립트
|
||||||
|
|
@ -135,6 +139,7 @@ class _BrowserPageState extends State<BrowserPage> with RouteAware {
|
||||||
BrowserPage._lastUrl = u;
|
BrowserPage._lastUrl = u;
|
||||||
setState(() => _url = u);
|
setState(() => _url = u);
|
||||||
if (!_addressFocus.hasFocus) _address.text = u;
|
if (!_addressFocus.hasFocus) _address.text = u;
|
||||||
|
_applyAdSettings();
|
||||||
},
|
},
|
||||||
onTitle: (t) => mounted ? setState(() => _title = t) : null,
|
onTitle: (t) => mounted ? setState(() => _title = t) : null,
|
||||||
onProgress: (p) => mounted ? setState(() => _progress = p) : null,
|
onProgress: (p) => mounted ? setState(() => _progress = p) : null,
|
||||||
|
|
@ -170,6 +175,17 @@ class _BrowserPageState extends State<BrowserPage> with RouteAware {
|
||||||
_nav?.pauseMedia();
|
_nav?.pauseMedia();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 설정 화면 등에서 돌아왔다: 바뀐 광고 설정을 지금 페이지에 적용
|
||||||
|
@override
|
||||||
|
void didPopNext() => _applyAdSettings();
|
||||||
|
|
||||||
|
/// YouTube 페이지면 광고 건너뛰기 · 숨기기 스크립트를 넣는다 (환경 설정 > 시작 · 웹 브라우저)
|
||||||
|
void _applyAdSettings() {
|
||||||
|
if (!(Uri.tryParse(_url)?.host ?? '').endsWith('youtube.com')) return;
|
||||||
|
final s = widget.c.settings;
|
||||||
|
_nav?.runScript(youtubeAdScript(skip: s.youtubeAdSkip, hide: s.youtubeAdHide));
|
||||||
|
}
|
||||||
|
|
||||||
/// MKV 화면 등으로 가며 브라우저 화면이 닫힌다: 살려 둔 웹뷰에서 소리만 나지 않게 멈춘다
|
/// MKV 화면 등으로 가며 브라우저 화면이 닫힌다: 살려 둔 웹뷰에서 소리만 나지 않게 멈춘다
|
||||||
@override
|
@override
|
||||||
void didPop() {
|
void didPop() {
|
||||||
|
|
@ -582,6 +598,13 @@ class _InAppNav implements WebNav {
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> runScript(String js) async {
|
||||||
|
try {
|
||||||
|
await c.evaluateJavascript(source: js);
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
static const _cookieSites = [
|
static const _cookieSites = [
|
||||||
'https://www.youtube.com/',
|
'https://www.youtube.com/',
|
||||||
'https://m.youtube.com/',
|
'https://m.youtube.com/',
|
||||||
|
|
@ -739,6 +762,13 @@ class _CefNav implements WebNav {
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> runScript(String js) async {
|
||||||
|
try {
|
||||||
|
await c.evaluateJavascript(js);
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
/// YouTube · Google 쿠키를 yt-dlp 용 cookies.txt 로 (Edge 와 같은 파일 - 지금 쓰는 엔진의 로그인이 쓰인다).
|
/// YouTube · Google 쿠키를 yt-dlp 용 cookies.txt 로 (Edge 와 같은 파일 - 지금 쓰는 엔진의 로그인이 쓰인다).
|
||||||
/// Chrome 엔진은 쿠키의 이름 · 값만 알려 주므로 만료 · 보안 표시는 기본값으로 적는다.
|
/// Chrome 엔진은 쿠키의 이름 · 값만 알려 주므로 만료 · 보안 표시는 기본값으로 적는다.
|
||||||
@override
|
@override
|
||||||
|
|
|
||||||
|
|
@ -342,6 +342,19 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||||
),
|
),
|
||||||
_textTile('홈 주소', s.homeUrl, '웹 브라우저의 시작 · 🏠 주소',
|
_textTile('홈 주소', s.homeUrl, '웹 브라우저의 시작 · 🏠 주소',
|
||||||
(v) => c.updateSettings((x) => x.homeUrl = v.trim().isEmpty ? 'https://www.youtube.com/' : v.trim())),
|
(v) => c.updateSettings((x) => x.homeUrl = v.trim().isEmpty ? 'https://www.youtube.com/' : v.trim())),
|
||||||
|
SwitchListTile(
|
||||||
|
value: s.youtubeAdSkip,
|
||||||
|
onChanged: (v) => c.updateSettings((x) => x.youtubeAdSkip = v),
|
||||||
|
title: const Text('YouTube 광고 자동 건너뛰기'),
|
||||||
|
subtitle: const Text('앱 안 웹 브라우저: [건너뛰기] 를 대신 누르고, 건너뛸 수 없는 광고는 소리를 끄고 빨리 넘깁니다 '
|
||||||
|
'(넘기는 동안 화면 위쪽에 "광고 건너뛰는 중…")'),
|
||||||
|
),
|
||||||
|
SwitchListTile(
|
||||||
|
value: s.youtubeAdHide,
|
||||||
|
onChanged: (v) => c.updateSettings((x) => x.youtubeAdHide = v),
|
||||||
|
title: const Text('YouTube 광고 배너 숨기기'),
|
||||||
|
subtitle: const Text('목록 · 영상 옆 · 영상 위에 나오는 광고 영역을 감춥니다'),
|
||||||
|
),
|
||||||
if (desk) ...[
|
if (desk) ...[
|
||||||
ListTile(
|
ListTile(
|
||||||
title: const Text('브라우저 엔진 (앱 안)'),
|
title: const Text('브라우저 엔진 (앱 안)'),
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,9 @@ class _Nav implements WebNav {
|
||||||
int paused = 0;
|
int paused = 0;
|
||||||
@override
|
@override
|
||||||
Future<void> pauseMedia() async => paused++;
|
Future<void> pauseMedia() async => paused++;
|
||||||
|
final scripts = <String>[];
|
||||||
|
@override
|
||||||
|
Future<void> runScript(String js) async => scripts.add(js);
|
||||||
final loads = <String>[];
|
final loads = <String>[];
|
||||||
@override
|
@override
|
||||||
Future<void> load(String url) async => loads.add(url);
|
Future<void> load(String url) async => loads.add(url);
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,91 @@
|
||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:jj_mkvmaker/app/settings.dart';
|
||||||
|
import 'package:jj_mkvmaker/core/youtube_ads.dart';
|
||||||
|
import 'package:path/path.dart' as p;
|
||||||
|
|
||||||
|
/// 가짜 YouTube 페이지 (DOM 흉내) 에서 스크립트를 실제로 돌려 본다 (node 가 있을 때)
|
||||||
|
const _harness = r'''
|
||||||
|
var timers = [];
|
||||||
|
global.setInterval = function (f) { timers.push(f); return timers.length; };
|
||||||
|
function el(cls) {
|
||||||
|
return { classList: { _c: cls.slice(), contains: function (x) { return this._c.indexOf(x) >= 0; },
|
||||||
|
remove: function (x) { this._c = this._c.filter(function (y) { return y !== x; }); } },
|
||||||
|
clicked: 0, click: function () { this.clicked++; }, style: {}, remove: function () { this.removed = 1; } };
|
||||||
|
}
|
||||||
|
var player = el(['html5-video-player', 'ad-showing']);
|
||||||
|
var skip = el(['ytp-skip-ad-button']);
|
||||||
|
var video = { muted: false, playbackRate: 1.5, duration: 30, currentTime: 2 };
|
||||||
|
var appended = [];
|
||||||
|
var body = { appendChild: function (n) { appended.push(n); } };
|
||||||
|
global.location = { hostname: process.argv[3] };
|
||||||
|
global.document = {
|
||||||
|
body: body, head: body, documentElement: body,
|
||||||
|
createElement: function () { return el([]); },
|
||||||
|
querySelector: function (q) {
|
||||||
|
if (q === 'video') return video;
|
||||||
|
if (q === '.html5-video-player') return player;
|
||||||
|
if (q.indexOf('skip') >= 0) return player.classList.contains('ad-showing') ? skip : null;
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
global.window = global;
|
||||||
|
require(process.argv[2]);
|
||||||
|
var r = { installed: timers.length };
|
||||||
|
r.skipClicked = skip.clicked; r.muted = video.muted; r.rate = video.playbackRate; r.time = video.currentTime;
|
||||||
|
r.badge = appended.some(function (n) { return n.textContent === '광고 건너뛰는 중…'; });
|
||||||
|
r.style = appended.some(function (n) { return (n.textContent || '').indexOf('display:none') >= 0; });
|
||||||
|
player.classList.remove('ad-showing');
|
||||||
|
timers.forEach(function (f) { f(); });
|
||||||
|
r.afterMuted = video.muted; r.afterRate = video.playbackRate;
|
||||||
|
console.log(JSON.stringify(r));
|
||||||
|
''';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
test('설정: 광고 건너뛰기 · 숨기기 기본 켜짐, 저장', () {
|
||||||
|
final s = AppSettings();
|
||||||
|
expect([s.youtubeAdSkip, s.youtubeAdHide], [true, true]);
|
||||||
|
s
|
||||||
|
..youtubeAdSkip = false
|
||||||
|
..youtubeAdHide = false;
|
||||||
|
final back = AppSettings.fromJson(s.toJson());
|
||||||
|
expect([back.youtubeAdSkip, back.youtubeAdHide], [false, false]);
|
||||||
|
expect(youtubeAdScript(skip: false, hide: true), contains('{skip: false, hide: true}'));
|
||||||
|
});
|
||||||
|
|
||||||
|
final node = Platform.isWindows ? r'C:\Program Files\nodejs\node.exe' : '/usr/bin/node';
|
||||||
|
test('가짜 YouTube 페이지: 건너뛰기 버튼 누르기 · 소리 끄고 빨리 감기 · 끝나면 되돌리기', () async {
|
||||||
|
final dir = Directory.systemTemp.createTempSync('jj_ads_');
|
||||||
|
addTearDown(() => dir.deleteSync(recursive: true));
|
||||||
|
Future<Map<String, Object?>> run(String host, {bool skip = true, bool hide = true}) async {
|
||||||
|
final script = File(p.join(dir.path, 'ad_${host}_$skip$hide.js'))
|
||||||
|
..writeAsStringSync(youtubeAdScript(skip: skip, hide: hide));
|
||||||
|
final harness = File(p.join(dir.path, 'harness.js'))..writeAsStringSync(_harness);
|
||||||
|
final r = await Process.run(node, [harness.path, script.path, host]);
|
||||||
|
expect(r.exitCode, 0, reason: '${r.stderr}');
|
||||||
|
return Map<String, Object?>.from(jsonDecode((r.stdout as String).trim()) as Map);
|
||||||
|
}
|
||||||
|
|
||||||
|
final yt = await run('www.youtube.com');
|
||||||
|
expect(yt['installed'], 1);
|
||||||
|
expect(yt['skipClicked'], 1);
|
||||||
|
expect(yt['muted'], true);
|
||||||
|
expect(yt['rate'], 16);
|
||||||
|
expect(yt['time'], closeTo(29.9, 0.01));
|
||||||
|
expect(yt['badge'], true);
|
||||||
|
expect(yt['style'], true);
|
||||||
|
// 광고가 끝나면 원래 소리 · 속도로
|
||||||
|
expect(yt['afterMuted'], false);
|
||||||
|
expect(yt['afterRate'], 1.5);
|
||||||
|
|
||||||
|
// 끄면 아무것도 하지 않는다
|
||||||
|
final off = await run('m.youtube.com', skip: false, hide: false);
|
||||||
|
expect([off['skipClicked'], off['muted'], off['style']], [0, false, false]);
|
||||||
|
|
||||||
|
// YouTube 가 아닌 사이트에는 넣지 않는다
|
||||||
|
final other = await run('example.com');
|
||||||
|
expect(other['installed'], 0);
|
||||||
|
}, skip: File(node).existsSync() ? false : 'node 없음');
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue