Add TwitchAdSolutions-style ad blocking for native and WebView players.
Request playback with non-site player types and swap to backup Usher streams on stitched ad breaks; inject vendored vaft into the legacy WebView and expose a Settings toggle. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
bb150ce42a
commit
1aae7438bb
16 changed files with 1697 additions and 12 deletions
163
lib/apis/twitch_ad_solutions.dart
Normal file
163
lib/apis/twitch_ad_solutions.dart
Normal file
|
|
@ -0,0 +1,163 @@
|
|||
import 'package:flutter/foundation.dart';
|
||||
import 'package:frosty/apis/twitch_gql_api.dart';
|
||||
import 'package:frosty/models/playback_access_token.dart';
|
||||
|
||||
/// Dart-side port of the [TwitchAdSolutions vaft](https://github.com/pixeltris/TwitchAdSolutions)
|
||||
/// strategy for Frosty's native HLS player.
|
||||
///
|
||||
/// Browser vaft hooks `Worker`/`fetch` to rewrite playlists. Here we instead:
|
||||
/// 1. Request playback tokens with a non-`site` [forceAccessTokenPlayerType]
|
||||
/// (vaft's `ForceAccessTokenPlayerType`).
|
||||
/// 2. When stitched ads still appear, try [backupPlayerTypes] until a playlist
|
||||
/// without the ad signifier is found, then hand that Usher URL to the player.
|
||||
class TwitchAdSolutions {
|
||||
TwitchAdSolutions._();
|
||||
|
||||
/// Matches vaft `AdSignifier` — Twitch marks ad pods with this in `#EXT-X-DATERANGE`.
|
||||
static const adSignifier = 'stitched';
|
||||
|
||||
/// vaft `ForceAccessTokenPlayerType` — used for the primary stream when
|
||||
/// ad blocking is enabled.
|
||||
static const forceAccessTokenPlayerType = 'popout';
|
||||
|
||||
/// vaft `BackupPlayerTypes` order when the primary playlist still has ads.
|
||||
static const backupPlayerTypes = <String>[
|
||||
'embed',
|
||||
'popout',
|
||||
'autoplay',
|
||||
];
|
||||
|
||||
/// vaft `FallbackPlayerType` when every backup still carries ads.
|
||||
static const fallbackPlayerType = 'embed';
|
||||
|
||||
/// GQL `platform` for a given player type (vaft uses `android` for autoplay).
|
||||
static String platformForPlayerType(String playerType) =>
|
||||
playerType == 'autoplay' ? 'android' : 'web';
|
||||
|
||||
static bool playlistContainsAds(String playlistText) =>
|
||||
playlistText.contains(adSignifier);
|
||||
|
||||
/// True when [text] looks like a midroll (vaft midroll banner path).
|
||||
static bool playlistIsMidroll(String playlistText) =>
|
||||
playlistText.contains('"MIDROLL"') ||
|
||||
playlistText.contains('"midroll"') ||
|
||||
playlistText.contains("MIDROLL");
|
||||
}
|
||||
|
||||
/// Resolves Usher HLS URLs using TwitchAdSolutions player-type strategy.
|
||||
class TwitchAdFreePlayback {
|
||||
TwitchAdFreePlayback(this._gql);
|
||||
|
||||
final TwitchGqlApi _gql;
|
||||
|
||||
/// Primary URL when adblock is on: force [TwitchAdSolutions.forceAccessTokenPlayerType].
|
||||
Future<({String url, PlaybackAccessToken token, String playerType})>
|
||||
resolvePrimary({
|
||||
required String login,
|
||||
String? authToken,
|
||||
}) {
|
||||
return _resolveForPlayerType(
|
||||
login: login,
|
||||
authToken: authToken,
|
||||
playerType: TwitchAdSolutions.forceAccessTokenPlayerType,
|
||||
requireAdFree: false,
|
||||
);
|
||||
}
|
||||
|
||||
/// Walks [TwitchAdSolutions.backupPlayerTypes] for a playlist without ads.
|
||||
/// Falls back to [TwitchAdSolutions.fallbackPlayerType] even if ads remain.
|
||||
Future<({String url, PlaybackAccessToken token, String playerType})>
|
||||
resolveBackup({
|
||||
required String login,
|
||||
String? authToken,
|
||||
}) async {
|
||||
for (final playerType in TwitchAdSolutions.backupPlayerTypes) {
|
||||
try {
|
||||
final resolved = await _resolveForPlayerType(
|
||||
login: login,
|
||||
authToken: authToken,
|
||||
playerType: playerType,
|
||||
requireAdFree: true,
|
||||
);
|
||||
debugPrint(
|
||||
'TwitchAdFreePlayback: backup OK playerType=$playerType',
|
||||
);
|
||||
return resolved;
|
||||
} catch (e) {
|
||||
debugPrint(
|
||||
'TwitchAdFreePlayback: backup playerType=$playerType failed: $e',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
debugPrint(
|
||||
'TwitchAdFreePlayback: all backups had ads; using fallback '
|
||||
'${TwitchAdSolutions.fallbackPlayerType}',
|
||||
);
|
||||
return _resolveForPlayerType(
|
||||
login: login,
|
||||
authToken: authToken,
|
||||
playerType: TwitchAdSolutions.fallbackPlayerType,
|
||||
requireAdFree: false,
|
||||
);
|
||||
}
|
||||
|
||||
Future<({String url, PlaybackAccessToken token, String playerType})>
|
||||
_resolveForPlayerType({
|
||||
required String login,
|
||||
String? authToken,
|
||||
required String playerType,
|
||||
required bool requireAdFree,
|
||||
}) async {
|
||||
final token = await _gql.getPlaybackAccessToken(
|
||||
login: login,
|
||||
authToken: authToken,
|
||||
playerType: playerType,
|
||||
platform: TwitchAdSolutions.platformForPlayerType(playerType),
|
||||
);
|
||||
final url = _gql.buildHlsUrl(login: login, token: token);
|
||||
|
||||
if (requireAdFree) {
|
||||
final hasAds = await _multivariantHasAds(url);
|
||||
if (hasAds) {
|
||||
throw StateError('Playlist for playerType=$playerType still has ads');
|
||||
}
|
||||
}
|
||||
|
||||
return (url: url, token: token, playerType: playerType);
|
||||
}
|
||||
|
||||
/// Fetches the multivariant playlist and samples the first media variant for
|
||||
/// the ad signifier (same idea as vaft probing backup encodings).
|
||||
Future<bool> _multivariantHasAds(String hlsUrl) async {
|
||||
final master = await _gql.fetchPlaylistText(hlsUrl);
|
||||
final mediaUrl = firstMediaPlaylistUrl(master, baseUrl: hlsUrl);
|
||||
if (mediaUrl == null) {
|
||||
// Can't verify — treat as ad-free so we don't reject a usable stream.
|
||||
return false;
|
||||
}
|
||||
final media = await _gql.fetchPlaylistText(mediaUrl);
|
||||
return TwitchAdSolutions.playlistContainsAds(media);
|
||||
}
|
||||
|
||||
/// First `.m3u8` URI in a multivariant playlist, resolved against [baseUrl].
|
||||
@visibleForTesting
|
||||
static String? firstMediaPlaylistUrl(
|
||||
String masterPlaylist, {
|
||||
required String baseUrl,
|
||||
}) {
|
||||
final lines = masterPlaylist.replaceAll('\r', '').split('\n');
|
||||
for (final raw in lines) {
|
||||
final line = raw.trim();
|
||||
if (line.isEmpty || line.startsWith('#')) continue;
|
||||
if (!line.contains('.m3u8')) continue;
|
||||
return Uri.parse(baseUrl).resolve(line).toString();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Tiny blank MP4 (same payload vaft serves for stripped ad segments). Exposed
|
||||
/// for tests / future local playlist rewriting; not required for URL-swap mode.
|
||||
const twitchAdSolutionsBlankSegmentDataUri =
|
||||
'data:video/mp4;base64,AAAAKGZ0eXBtcDQyAAAAAWlzb21tcDQyZGFzaGF2YzFpc282aGxzZgAABEltb292AAAAbG12aGQAAAAAAAAAAAAAAAAAAYagAAAAAAABAAABAAAAAAAAAAAAAAAAAQAAAAAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADAAABqHRyYWsAAABcdGtoZAAAAAMAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAEAAAAAAQAAAAAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAURtZGlhAAAAIG1kaGQAAAAAAAAAAAAAAAAAALuAAAAAAFXEAAAAAAAtaGRscgAAAAAAAAAAc291bgAAAAAAAAAAAAAAAFNvdW5kSGFuZGxlcgAAAADvbWluZgAAABBzbWhkAAAAAAAAAAAAAAAkZGluZgAAABxkcmVmAAAAAAAAAAEAAAAMdXJsIAAAAAEAAACzc3RibAAAAGdzdHNkAAAAAAAAAAEAAABXbXA0YQAAAAAAAAABAAAAAAAAAAAAAgAQAAAAALuAAAAAAAAzZXNkcwAAAAADgICAIgABAASAgIAUQBUAAAAAAAAAAAAAAAWAgIACEZAGgICAAQIAAAAQc3R0cwAAAAAAAAAAAAAAEHN0c2MAAAAAAAAAAAAAABRzdHN6AAAAAAAAAAAAAAAAAAAAEHN0Y28AAAAAAAAAAAAAAeV0cmFrAAAAXHRraGQAAAADAAAAAAAAAAAAAAACAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAABAAAAAAoAAAAFoAAAAAAGBbWRpYQAAACBtZGhkAAAAAAAAAAAAAAAAAA9CQAAAAABVxAAAAAAALWhkbHIAAAAAAAAAAHZpZGUAAAAAAAAAAAAAAABWaWRlb0hhbmRsZXIAAAABLG1pbmYAAAAUdm1oZAAAAAEAAAAAAAAAAAAAACRkaW5mAAAAHGRyZWYAAAAAAAAAAQAAAAx1cmwgAAAAAQAAAOxzdGJsAAAAoHN0c2QAAAAAAAAAAQAAAJBhdmMxAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAAAAoABaABIAAAASAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAGP//AAAAOmF2Y0MBTUAe/+EAI2dNQB6WUoFAX/LgLUBAQFAAAD6AAA6mDgAAHoQAA9CW7y4KAQAEaOuPIAAAABBzdHRzAAAAAAAAAAAAAAAQc3RzYwAAAAAAAAAAAAAAFHN0c3oAAAAAAAAAAAAAAAAAAAAQc3RjbwAAAAAAAAAAAAAASG12ZXgAAAAgdHJleAAAAAAAAAABAAAAAQAAAC4AAAAAAoAAAAAAACB0cmV4AAAAAAAAAAIAAAABAACCNQAAAAACQAAA';
|
||||
|
|
@ -34,12 +34,18 @@ class TwitchGqlApi extends BaseApiClient {
|
|||
/// When [authToken] is provided, the request is authenticated which may
|
||||
/// grant ad-free playback for subscribers and Turbo users.
|
||||
///
|
||||
/// [playerType] / [platform] mirror the web player's GQL variables. Ad
|
||||
/// blocking (TwitchAdSolutions / vaft) uses non-`site` player types such as
|
||||
/// `popout` / `embed` / `autoplay`.
|
||||
///
|
||||
/// Uses the web client's persisted GQL query (smaller payload, blends in
|
||||
/// with regular web traffic); falls back to the inline query template if
|
||||
/// Twitch ever rotates the persisted-query hash.
|
||||
Future<PlaybackAccessToken> getPlaybackAccessToken({
|
||||
required String login,
|
||||
String? authToken,
|
||||
String playerType = 'site',
|
||||
String platform = 'web',
|
||||
}) async {
|
||||
final headers = <String, String>{
|
||||
'Client-ID': twitchGqlClientId,
|
||||
|
|
@ -52,8 +58,8 @@ class TwitchGqlApi extends BaseApiClient {
|
|||
'login': login,
|
||||
'isVod': false,
|
||||
'vodID': '',
|
||||
'playerType': 'site',
|
||||
'platform': 'web',
|
||||
'playerType': playerType,
|
||||
'platform': platform,
|
||||
};
|
||||
|
||||
Future<JsonMap> query(Map<String, Object> body) =>
|
||||
|
|
@ -92,6 +98,11 @@ class TwitchGqlApi extends BaseApiClient {
|
|||
return PlaybackAccessToken.fromGqlResponse(response);
|
||||
}
|
||||
|
||||
/// Fetches raw playlist text (multivariant or media) with playback headers.
|
||||
Future<String> fetchPlaylistText(String url) async {
|
||||
return get<String>(url, headers: playbackHttpHeaders);
|
||||
}
|
||||
|
||||
/// Builds the HLS stream URL for the given channel [login] and [token].
|
||||
String buildHlsUrl({
|
||||
required String login,
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import 'package:firebase_crashlytics/firebase_crashlytics.dart';
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:frosty/apis/base_api_client.dart';
|
||||
import 'package:frosty/apis/twitch_ad_solutions.dart';
|
||||
import 'package:frosty/apis/twitch_api.dart';
|
||||
import 'package:frosty/apis/twitch_gql_api.dart';
|
||||
import 'package:frosty/constants.dart';
|
||||
|
|
@ -113,9 +114,23 @@ abstract class NativeVideoStoreBase
|
|||
/// suspended rather than burning refresh attempts on ad-induced noise.
|
||||
var _isAdBreakActive = false;
|
||||
|
||||
/// True after we swapped to a TwitchAdSolutions backup player-type URL.
|
||||
var _usingAdBackup = false;
|
||||
|
||||
/// Serializes ad-break URL swaps so overlapping adBreakStream events don't
|
||||
/// race loadUrl calls.
|
||||
var _adSwapInFlight = false;
|
||||
|
||||
late final TwitchAdFreePlayback _adFreePlayback =
|
||||
TwitchAdFreePlayback(twitchGqlApi);
|
||||
|
||||
@readonly
|
||||
var _loading = true;
|
||||
|
||||
/// Whether ad blocking is actively avoiding/replacing an ad pod.
|
||||
@readonly
|
||||
var _blockingAds = false;
|
||||
|
||||
@readonly
|
||||
var _paused = true;
|
||||
|
||||
|
|
@ -276,6 +291,12 @@ abstract class NativeVideoStoreBase
|
|||
debugPrint(
|
||||
'NativeVideoStore: ad break ${isActive ? 'started' : 'ended'}',
|
||||
);
|
||||
if (!settingsStore.blockTwitchAds) {
|
||||
runInAction(() => _blockingAds = false);
|
||||
return;
|
||||
}
|
||||
// Fire-and-forget: swap stream URL per TwitchAdSolutions backup path.
|
||||
unawaited(_onAdBreakChanged(isActive));
|
||||
});
|
||||
|
||||
_pipSub = _controller!.isPipEnabledStream.listen((isPip) {
|
||||
|
|
@ -378,12 +399,27 @@ abstract class NativeVideoStoreBase
|
|||
Future<void> _loadStream(int generation) async {
|
||||
// Use web cookie token (works with web Client-ID) for ad-free playback.
|
||||
final authToken = authStore.gqlToken;
|
||||
late final String hlsUrl;
|
||||
late final PlaybackAccessToken token;
|
||||
try {
|
||||
token = await twitchGqlApi.getPlaybackAccessToken(
|
||||
login: userLogin,
|
||||
authToken: authToken,
|
||||
);
|
||||
if (settingsStore.blockTwitchAds) {
|
||||
final resolved = await _adFreePlayback.resolvePrimary(
|
||||
login: userLogin,
|
||||
authToken: authToken,
|
||||
);
|
||||
hlsUrl = resolved.url;
|
||||
token = resolved.token;
|
||||
debugPrint(
|
||||
'NativeVideoStore: adblock primary '
|
||||
'playerType=${resolved.playerType}',
|
||||
);
|
||||
} else {
|
||||
token = await twitchGqlApi.getPlaybackAccessToken(
|
||||
login: userLogin,
|
||||
authToken: authToken,
|
||||
);
|
||||
hlsUrl = twitchGqlApi.buildHlsUrl(login: userLogin, token: token);
|
||||
}
|
||||
} catch (e) {
|
||||
if (authToken != null) {
|
||||
debugPrint('NativeVideoStore: auth token failed, retrying without: $e');
|
||||
|
|
@ -392,7 +428,16 @@ abstract class NativeVideoStoreBase
|
|||
// reflects the true state and future loads skip this request.
|
||||
authStore.invalidateGqlToken();
|
||||
}
|
||||
token = await twitchGqlApi.getPlaybackAccessToken(login: userLogin);
|
||||
if (settingsStore.blockTwitchAds) {
|
||||
final resolved = await _adFreePlayback.resolvePrimary(
|
||||
login: userLogin,
|
||||
);
|
||||
hlsUrl = resolved.url;
|
||||
token = resolved.token;
|
||||
} else {
|
||||
token = await twitchGqlApi.getPlaybackAccessToken(login: userLogin);
|
||||
hlsUrl = twitchGqlApi.buildHlsUrl(login: userLogin, token: token);
|
||||
}
|
||||
} else {
|
||||
rethrow;
|
||||
}
|
||||
|
|
@ -401,10 +446,11 @@ abstract class NativeVideoStoreBase
|
|||
if (_disposed || generation != _initGeneration || !settingsStore.showVideo) {
|
||||
return;
|
||||
}
|
||||
runInAction(
|
||||
() => _restrictedStreamQualities = token.restrictedQualities,
|
||||
);
|
||||
final hlsUrl = twitchGqlApi.buildHlsUrl(login: userLogin, token: token);
|
||||
runInAction(() {
|
||||
_restrictedStreamQualities = token.restrictedQualities;
|
||||
_usingAdBackup = false;
|
||||
_blockingAds = false;
|
||||
});
|
||||
|
||||
await _controller!.loadUrl(
|
||||
url: hlsUrl,
|
||||
|
|
@ -417,6 +463,66 @@ abstract class NativeVideoStoreBase
|
|||
_startLatencyPolling();
|
||||
}
|
||||
|
||||
/// TwitchAdSolutions backup swap when the native player reports a stitched
|
||||
/// ad break; restores the primary (popout) URL when the break ends.
|
||||
Future<void> _onAdBreakChanged(bool isActive) async {
|
||||
if (_disposed || !settingsStore.blockTwitchAds || _controller == null) {
|
||||
return;
|
||||
}
|
||||
if (_adSwapInFlight) return;
|
||||
_adSwapInFlight = true;
|
||||
final generation = _initGeneration;
|
||||
try {
|
||||
if (isActive) {
|
||||
runInAction(() => _blockingAds = true);
|
||||
final resolved = await _adFreePlayback.resolveBackup(
|
||||
login: userLogin,
|
||||
authToken: authStore.gqlToken,
|
||||
);
|
||||
if (_disposed || generation != _initGeneration) return;
|
||||
debugPrint(
|
||||
'NativeVideoStore: blocking ads via '
|
||||
'playerType=${resolved.playerType}',
|
||||
);
|
||||
await _controller!.loadUrl(
|
||||
url: resolved.url,
|
||||
headers: TwitchGqlApi.playbackHttpHeaders,
|
||||
);
|
||||
if (_disposed || generation != _initGeneration) return;
|
||||
await _controller!.configureForLivePlayback();
|
||||
runInAction(() {
|
||||
_usingAdBackup = true;
|
||||
_restrictedStreamQualities = resolved.token.restrictedQualities;
|
||||
});
|
||||
} else if (_usingAdBackup) {
|
||||
debugPrint('NativeVideoStore: ad break ended, restoring primary');
|
||||
final resolved = await _adFreePlayback.resolvePrimary(
|
||||
login: userLogin,
|
||||
authToken: authStore.gqlToken,
|
||||
);
|
||||
if (_disposed || generation != _initGeneration) return;
|
||||
await _controller!.loadUrl(
|
||||
url: resolved.url,
|
||||
headers: TwitchGqlApi.playbackHttpHeaders,
|
||||
);
|
||||
if (_disposed || generation != _initGeneration) return;
|
||||
await _controller!.configureForLivePlayback();
|
||||
runInAction(() {
|
||||
_usingAdBackup = false;
|
||||
_blockingAds = false;
|
||||
_restrictedStreamQualities = resolved.token.restrictedQualities;
|
||||
});
|
||||
} else {
|
||||
runInAction(() => _blockingAds = false);
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('NativeVideoStore: adblock swap failed: $e');
|
||||
runInAction(() => _blockingAds = _isAdBreakActive || _usingAdBackup);
|
||||
} finally {
|
||||
_adSwapInFlight = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Shared error handler for both `_initPlayer` and `_reloadCurrentController`.
|
||||
/// Checks stream liveness, auto-retries if CDN is still serving stale
|
||||
/// manifests, or surfaces the error with the overlay visible.
|
||||
|
|
@ -1278,6 +1384,9 @@ abstract class NativeVideoStoreBase
|
|||
_paused = true;
|
||||
_isStalled = false;
|
||||
_isAdBreakActive = false;
|
||||
_usingAdBackup = false;
|
||||
_blockingAds = false;
|
||||
_adSwapInFlight = false;
|
||||
_restrictedStreamQualities = [];
|
||||
_isQualitySwitching = false;
|
||||
_isAudioOnlyMode = false;
|
||||
|
|
|
|||
|
|
@ -57,6 +57,29 @@ mixin _$NativeVideoStore on NativeVideoStoreBase, Store {
|
|||
});
|
||||
}
|
||||
|
||||
late final _$_blockingAdsAtom = Atom(
|
||||
name: 'NativeVideoStoreBase._blockingAds',
|
||||
context: context,
|
||||
);
|
||||
|
||||
bool get blockingAds {
|
||||
_$_blockingAdsAtom.reportRead();
|
||||
return super._blockingAds;
|
||||
}
|
||||
|
||||
@override
|
||||
bool get isBlockingAds => blockingAds;
|
||||
|
||||
@override
|
||||
bool get _blockingAds => blockingAds;
|
||||
|
||||
@override
|
||||
set _blockingAds(bool value) {
|
||||
_$_blockingAdsAtom.reportWrite(value, super._blockingAds, () {
|
||||
super._blockingAds = value;
|
||||
});
|
||||
}
|
||||
|
||||
late final _$_pausedAtom = Atom(
|
||||
name: 'NativeVideoStoreBase._paused',
|
||||
context: context,
|
||||
|
|
|
|||
|
|
@ -322,6 +322,25 @@ class VideoOverlay extends StatelessWidget {
|
|||
height: 80, // Covers bottom area around controls
|
||||
child: Container(decoration: bottomGradient),
|
||||
),
|
||||
if (videoStore.isBlockingAds)
|
||||
Positioned(
|
||||
top: 8,
|
||||
left: 8,
|
||||
child: IgnorePointer(
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black.withValues(alpha: 0.8),
|
||||
),
|
||||
child: const Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 8, vertical: 5),
|
||||
child: Text(
|
||||
'Blocking ads',
|
||||
style: TextStyle(color: Colors.white, fontSize: 12),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
// Content
|
||||
Stack(
|
||||
children: [
|
||||
|
|
|
|||
|
|
@ -29,6 +29,9 @@ abstract class VideoPlayerInterface {
|
|||
String get streamQuality;
|
||||
String? get latency;
|
||||
|
||||
/// True while TwitchAdSolutions is actively avoiding an ad break.
|
||||
bool get isBlockingAds;
|
||||
|
||||
void handleVideoTap();
|
||||
void handlePausePlay();
|
||||
void handleToggleOverlay();
|
||||
|
|
|
|||
|
|
@ -183,9 +183,20 @@ abstract class VideoStoreBase with Store implements VideoPlayerInterface {
|
|||
// Every real navigation invalidates the injected JS context, so
|
||||
// every one must re-arm init. Unconditional is safe: the
|
||||
// onPageFinished handler below still filters on videoUrl.
|
||||
onPageStarted: (url) => _needsInit = true,
|
||||
onPageStarted: (url) {
|
||||
_needsInit = true;
|
||||
// Best-effort: inject vaft as early as the WebView allows.
|
||||
// True document-start injection isn't exposed by webview_flutter
|
||||
// on Android; native player remains the reliable adblock path.
|
||||
if (settingsStore.blockTwitchAds) {
|
||||
unawaited(_injectVaftUserScript());
|
||||
}
|
||||
},
|
||||
onPageFinished: (url) async {
|
||||
if (url != videoUrl) return;
|
||||
if (settingsStore.blockTwitchAds) {
|
||||
await _injectVaftUserScript();
|
||||
}
|
||||
if (!_needsInit) return;
|
||||
_needsInit = false;
|
||||
await initVideo();
|
||||
|
|
@ -263,6 +274,11 @@ abstract class VideoStoreBase with Store implements VideoPlayerInterface {
|
|||
@readonly
|
||||
var _isInPipMode = false;
|
||||
|
||||
/// WebView path relies on the injected vaft userscript's own overlay; the
|
||||
/// Dart overlay banner is driven by the native player only.
|
||||
@override
|
||||
bool get isBlockingAds => false;
|
||||
|
||||
/// The video URL to use for the webview.
|
||||
String get videoUrl =>
|
||||
'https://player.twitch.tv/?channel=$userLogin&muted=false&parent=frosty';
|
||||
|
|
@ -636,6 +652,22 @@ abstract class VideoStoreBase with Store implements VideoPlayerInterface {
|
|||
}
|
||||
}
|
||||
|
||||
/// Injects [assets/js/vaft.user.js] (TwitchAdSolutions) into the player page.
|
||||
Future<void> _injectVaftUserScript() async {
|
||||
try {
|
||||
final raw = await rootBundle.loadString('assets/js/vaft.user.js');
|
||||
// Strip UserScript metadata; keep the IIFE body.
|
||||
final script = raw.replaceFirst(
|
||||
RegExp(r'^[\s\S]*?==/UserScript==\s*', multiLine: true),
|
||||
'',
|
||||
);
|
||||
await videoWebViewController.runJavaScript(script);
|
||||
debugPrint('VideoStore: injected TwitchAdSolutions vaft');
|
||||
} catch (e) {
|
||||
debugPrint('VideoStore: vaft inject failed: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Called whenever the video/overlay is tapped.
|
||||
@override
|
||||
@action
|
||||
|
|
|
|||
|
|
@ -69,6 +69,7 @@ abstract class _SettingsStoreBase with Store {
|
|||
static const defaultDefaultToHighestQuality = false;
|
||||
static const defaultUseTextureRendering = true;
|
||||
static const defaultUseNativePlayer = true;
|
||||
static const defaultBlockTwitchAds = true;
|
||||
|
||||
static const defaultShowOverlay = true;
|
||||
static const defaultToggleableOverlay = false;
|
||||
|
|
@ -91,6 +92,12 @@ abstract class _SettingsStoreBase with Store {
|
|||
@observable
|
||||
var useNativePlayer = defaultUseNativePlayer;
|
||||
|
||||
/// TwitchAdSolutions (vaft)-style ad blocking for the native player, and
|
||||
/// userscript injection for the legacy WebView player.
|
||||
@JsonKey(defaultValue: defaultBlockTwitchAds)
|
||||
@observable
|
||||
var blockTwitchAds = defaultBlockTwitchAds;
|
||||
|
||||
/// 5.2.0 made native the default. 5.1.0 already wrote
|
||||
/// `useNativePlayer: false` into saved settings, so a Dart default
|
||||
/// change alone would leave existing installs on WebView.
|
||||
|
|
@ -123,6 +130,7 @@ abstract class _SettingsStoreBase with Store {
|
|||
defaultToHighestQuality = defaultDefaultToHighestQuality;
|
||||
useTextureRendering = defaultUseTextureRendering;
|
||||
useNativePlayer = defaultUseNativePlayer;
|
||||
blockTwitchAds = defaultBlockTwitchAds;
|
||||
|
||||
showOverlay = defaultShowOverlay;
|
||||
toggleableOverlay = defaultToggleableOverlay;
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ SettingsStore _$SettingsStoreFromJson(
|
|||
..defaultToHighestQuality = json['defaultToHighestQuality'] as bool? ?? false
|
||||
..useTextureRendering = json['useTextureRendering'] as bool? ?? true
|
||||
..useNativePlayer = json['useNativePlayer'] as bool? ?? true
|
||||
..blockTwitchAds = json['blockTwitchAds'] as bool? ?? true
|
||||
..nativePlayerV52Migrated = json['nativePlayerV52Migrated'] as bool? ?? false
|
||||
..showOverlay = json['showOverlay'] as bool? ?? true
|
||||
..toggleableOverlay = json['toggleableOverlay'] as bool? ?? false
|
||||
|
|
@ -107,6 +108,7 @@ Map<String, dynamic> _$SettingsStoreToJson(
|
|||
'defaultToHighestQuality': instance.defaultToHighestQuality,
|
||||
'useTextureRendering': instance.useTextureRendering,
|
||||
'useNativePlayer': instance.useNativePlayer,
|
||||
'blockTwitchAds': instance.blockTwitchAds,
|
||||
'nativePlayerV52Migrated': instance.nativePlayerV52Migrated,
|
||||
'showOverlay': instance.showOverlay,
|
||||
'toggleableOverlay': instance.toggleableOverlay,
|
||||
|
|
@ -352,6 +354,24 @@ mixin _$SettingsStore on _SettingsStoreBase, Store {
|
|||
});
|
||||
}
|
||||
|
||||
late final _$blockTwitchAdsAtom = Atom(
|
||||
name: '_SettingsStoreBase.blockTwitchAds',
|
||||
context: context,
|
||||
);
|
||||
|
||||
@override
|
||||
bool get blockTwitchAds {
|
||||
_$blockTwitchAdsAtom.reportRead();
|
||||
return super.blockTwitchAds;
|
||||
}
|
||||
|
||||
@override
|
||||
set blockTwitchAds(bool value) {
|
||||
_$blockTwitchAdsAtom.reportWrite(value, super.blockTwitchAds, () {
|
||||
super.blockTwitchAds = value;
|
||||
});
|
||||
}
|
||||
|
||||
late final _$showOverlayAtom = Atom(
|
||||
name: '_SettingsStoreBase.showOverlay',
|
||||
context: context,
|
||||
|
|
@ -1245,6 +1265,7 @@ showVideo: ${showVideo},
|
|||
defaultToHighestQuality: ${defaultToHighestQuality},
|
||||
useTextureRendering: ${useTextureRendering},
|
||||
useNativePlayer: ${useNativePlayer},
|
||||
blockTwitchAds: ${blockTwitchAds},
|
||||
showOverlay: ${showOverlay},
|
||||
toggleableOverlay: ${toggleableOverlay},
|
||||
showLatency: ${showLatency},
|
||||
|
|
|
|||
|
|
@ -34,6 +34,21 @@ class VideoSettings extends StatelessWidget {
|
|||
onChanged: (newValue) =>
|
||||
settingsStore.useNativePlayer = newValue,
|
||||
),
|
||||
if (settingsStore.showVideo)
|
||||
SettingsListSwitch(
|
||||
title: 'Block Twitch ads',
|
||||
subtitle: Text(
|
||||
settingsStore.useNativePlayer
|
||||
? 'Uses TwitchAdSolutions (vaft): popout/embed/autoplay player types and backup streams during ad breaks.'
|
||||
: 'Injects the vaft userscript into the WebView. Native player is more reliable — enable it above if ads still play.',
|
||||
),
|
||||
value: settingsStore.blockTwitchAds,
|
||||
onChanged: (newValue) {
|
||||
settingsStore.blockTwitchAds = newValue;
|
||||
// Ad blocking is most effective on the native path.
|
||||
if (newValue) settingsStore.useNativePlayer = true;
|
||||
},
|
||||
),
|
||||
if (!Platform.isIOS || isIPad())
|
||||
SettingsListSwitch(
|
||||
title: 'Default to highest quality',
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue