diff --git a/lib/core/youtube_ads.dart b/lib/core/youtube_ads.dart new file mode 100644 index 0000000..91b5532 --- /dev/null +++ b/lib/core/youtube_ads.dart @@ -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}); +'''; diff --git a/lib/ui/browser_page.dart b/lib/ui/browser_page.dart index cdeadf7..7321f9c 100644 --- a/lib/ui/browser_page.dart +++ b/lib/ui/browser_page.dart @@ -13,6 +13,7 @@ import '../app/download_manager.dart'; import '../core/bookmarks.dart'; import '../core/download_detect.dart' show CookieRecord, toNetscapeCookies; import '../core/web_address.dart'; +import '../core/youtube_ads.dart'; import '../platform/windows/cef_runtime.dart'; import '../platform/windows/com_guard.dart'; import '../services/downloader.dart' show DownloadState; @@ -35,6 +36,9 @@ abstract class WebNav { /// 페이지의 동영상 · 소리 멈추기 (다른 화면으로 갈 때) Future pauseMedia(); + + /// 페이지에서 스크립트 실행 (YouTube 광고 건너뛰기 등) + Future runScript(String js); } /// 페이지의 모든 동영상 · 소리를 멈추는 스크립트 @@ -135,6 +139,7 @@ class _BrowserPageState extends State with RouteAware { BrowserPage._lastUrl = u; setState(() => _url = u); if (!_addressFocus.hasFocus) _address.text = u; + _applyAdSettings(); }, onTitle: (t) => mounted ? setState(() => _title = t) : null, onProgress: (p) => mounted ? setState(() => _progress = p) : null, @@ -170,6 +175,17 @@ class _BrowserPageState extends State with RouteAware { _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 화면 등으로 가며 브라우저 화면이 닫힌다: 살려 둔 웹뷰에서 소리만 나지 않게 멈춘다 @override void didPop() { @@ -582,6 +598,13 @@ class _InAppNav implements WebNav { } catch (_) {} } + @override + Future runScript(String js) async { + try { + await c.evaluateJavascript(source: js); + } catch (_) {} + } + static const _cookieSites = [ 'https://www.youtube.com/', 'https://m.youtube.com/', @@ -739,6 +762,13 @@ class _CefNav implements WebNav { } catch (_) {} } + @override + Future runScript(String js) async { + try { + await c.evaluateJavascript(js); + } catch (_) {} + } + /// YouTube · Google 쿠키를 yt-dlp 용 cookies.txt 로 (Edge 와 같은 파일 - 지금 쓰는 엔진의 로그인이 쓰인다). /// Chrome 엔진은 쿠키의 이름 · 값만 알려 주므로 만료 · 보안 표시는 기본값으로 적는다. @override diff --git a/lib/ui/settings_page.dart b/lib/ui/settings_page.dart index 10d0d41..4dd937e 100644 --- a/lib/ui/settings_page.dart +++ b/lib/ui/settings_page.dart @@ -342,6 +342,19 @@ class _SettingsPageState extends State { ), _textTile('홈 주소', s.homeUrl, '웹 브라우저의 시작 · 🏠 주소', (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) ...[ ListTile( title: const Text('브라우저 엔진 (앱 안)'), diff --git a/test/browser_test.dart b/test/browser_test.dart index 694ac60..fb10a67 100644 --- a/test/browser_test.dart +++ b/test/browser_test.dart @@ -44,6 +44,9 @@ class _Nav implements WebNav { int paused = 0; @override Future pauseMedia() async => paused++; + final scripts = []; + @override + Future runScript(String js) async => scripts.add(js); final loads = []; @override Future load(String url) async => loads.add(url); diff --git a/test/youtube_ads_test.dart b/test/youtube_ads_test.dart new file mode 100644 index 0000000..fc53258 --- /dev/null +++ b/test/youtube_ads_test.dart @@ -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> 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.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 없음'); +}