Add TwitchAdSolutions-style ad blocking for native and WebView players.
Some checks failed
CI / Analyze, test, and build Android (push) Has been cancelled
CI / Build iOS (push) Has been cancelled

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:
copilot 2026-09-30 10:55:50 +02:00
parent bb150ce42a
commit 1aae7438bb
16 changed files with 1697 additions and 12 deletions

41
DESIGN.md Normal file
View file

@ -0,0 +1,41 @@
# Frosty fork — Twitch ad blocking (DESIGN)
Personal Forgejo fork of [tommyxchow/frosty](https://github.com/tommyxchow/frosty) that adds **TwitchAdSolutions (vaft)**-style live ad blocking.
Canonical living plan: session `active-plan.md` for conversation `37b527bf-e540-4753-be1f-39906fe2ba28`. ADR: **ADR-026**.
## Problem
Upstream Frosty plays Twitch HLS (native) or `player.twitch.tv` (WebView). Twitch may stitch ad pods into the media playlist (`stitched` / `EXT-X-DATERANGE`). Upstream only **detects** native ad breaks (to avoid bogus latency recovery); it does **not** block ads. Turbo/sub `hide_ads` via linked web session is separate and not a client blocker.
## Approach (vaft-equivalent)
Browser vaft hooks `Worker`/`fetch` and rewrites m3u8. Frosty’s native player cannot do that from Dart (`loadUrl` + headers only).
| Layer | Behavior |
| --- | --- |
| Primary token | GQL `PlaybackAccessToken` with `playerType=popout` (vaft `ForceAccessTokenPlayerType`) when Settings `blockTwitchAds` is on |
| Ad break | Native `adBreakStream` → probe backup types `embed` → `popout` → `autoplay`; accept first Usher multivariant whose sampled media playlist lacks `stitched`; `loadUrl` that URL |
| Ad end | Restore primary popout Usher URL |
| WebView | Best-effort inject of vendored `assets/js/vaft.user.js` (not true document-start on Android) |
| UI | Settings toggle (default on; enabling forces native player); overlay “Blocking ads” |
## Key code
- `lib/apis/twitch_ad_solutions.dart` — constants + `TwitchAdFreePlayback`
- `lib/apis/twitch_gql_api.dart` — `playerType` / `platform`, `fetchPlaylistText`
- `lib/screens/channel/video/native_video_store.dart` — load + swap
- `lib/screens/channel/video/video_store.dart` — WebView inject
- `lib/screens/settings/stores/settings_store.dart` — `blockTwitchAds`
- `assets/js/vaft.user.js` — upstream TwitchAdSolutions script
## Non-goals (v1)
- Upstream PR
- Full Worker prototype cleaning
- Third-party proxies
- HLS segment stripping inside `better_native_video_player` (unless smoke tests demand it)
## Build
Flutter **3.47.0** (pinned in `pubspec.yaml`). Local SDK: `/home/yuna/.local/flutter-3.47.0`.

1119
assets/js/vaft.user.js Normal file

File diff suppressed because it is too large Load diff

35
docs/DESIGN-adblock.md Normal file
View file

@ -0,0 +1,35 @@
# Design: Twitch ad blocking (vaft) in Frosty fork
## Intent
Personal Forgejo fork of Frosty blocks Twitch live ads using [TwitchAdSolutions vaft](https://github.com/pixeltris/TwitchAdSolutions) ideas, adapted to Flutter.
## Upstream baseline
Frosty **did not** ship an adblocker. The native player only **detects** stitched ad breaks (`adBreakStream`) to avoid bad latency/stall recovery. Turbo/sub may get `hide_ads` via linked web GQL session — server-side, not client blocking.
## Strategy
### Native player (primary — this is the reliable path)
1. When `SettingsStore.blockTwitchAds` is true, request `PlaybackAccessToken` with `playerType=popout` (vaft `ForceAccessTokenPlayerType`) instead of `site`.
2. When the native plugin reports an ad break, resolve a backup Usher URL trying `embed` → `popout` → `autoplay`, preferring playlists whose media m3u8 does not contain `stitched`, then `loadUrl` that stream.
3. When the ad break ends, restore the primary popout URL.
4. Overlay shows “Blocking ads” while `_blockingAds` is true.
Implementation: `lib/apis/twitch_ad_solutions.dart`, `lib/apis/twitch_gql_api.dart`, `lib/screens/channel/video/native_video_store.dart`.
### WebView player (legacy, best-effort)
Inject vendored `assets/js/vaft.user.js` on page start/finish. Not true `@run-at document-start` on Android via `webview_flutter`, so native remains preferred. Enabling adblock in Settings forces native on.
## Settings
- `blockTwitchAds` (default `true`)
- Turning adblock on sets `useNativePlayer = true`
## Non-goals
- Upstream PR
- Full browser Worker prototype rewriting on native
- Third-party CDN proxies unless player-type swap proves insufficient

View 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';

View file

@ -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,

View file

@ -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;

View file

@ -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,

View file

@ -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: [

View file

@ -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();

View file

@ -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

View file

@ -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;

View file

@ -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},

View file

@ -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',

View file

@ -151,6 +151,7 @@ flutter:
- assets/icons/logo.svg
- assets/fonts/
- assets/release-notes.md
- assets/js/vaft.user.js
# An image asset can refer to one or more resolution-specific "variants", see
# https://flutter.dev/to/resolution-aware-images

View file

@ -0,0 +1,84 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:frosty/apis/twitch_ad_solutions.dart';
void main() {
group('TwitchAdSolutions', () {
test('playlistContainsAds detects stitched signifier', () {
expect(
TwitchAdSolutions.playlistContainsAds(
'#EXTM3U\n#EXT-X-DATERANGE:ID="stitched-ad-1"\n',
),
isTrue,
);
expect(
TwitchAdSolutions.playlistContainsAds('#EXTM3U\n#EXTINF:2.0,\nlive.ts\n'),
isFalse,
);
});
test('playlistIsMidroll detects midroll markers', () {
expect(
TwitchAdSolutions.playlistIsMidroll('X-TV-TWITCH-AD-POD-TYPE="MIDROLL"'),
isTrue,
);
expect(TwitchAdSolutions.playlistIsMidroll('live only'), isFalse);
});
test('platformForPlayerType matches vaft', () {
expect(TwitchAdSolutions.platformForPlayerType('autoplay'), 'android');
expect(TwitchAdSolutions.platformForPlayerType('popout'), 'web');
expect(TwitchAdSolutions.platformForPlayerType('embed'), 'web');
});
test('force / backup player types match vaft defaults', () {
expect(TwitchAdSolutions.forceAccessTokenPlayerType, 'popout');
expect(TwitchAdSolutions.backupPlayerTypes, [
'embed',
'popout',
'autoplay',
]);
expect(TwitchAdSolutions.fallbackPlayerType, 'embed');
});
});
group('TwitchAdFreePlayback.firstMediaPlaylistUrl', () {
test('resolves relative media playlist against base', () {
const master = '''
#EXTM3U
#EXT-X-STREAM-INF:BANDWIDTH=1000,RESOLUTION=640x360
360p.m3u8
''';
final url = TwitchAdFreePlayback.firstMediaPlaylistUrl(
master,
baseUrl: 'https://usher.ttvnw.net/api/v2/channel/hls/foo.m3u8?x=1',
);
expect(
url,
'https://usher.ttvnw.net/api/v2/channel/hls/360p.m3u8',
);
});
test('keeps absolute media playlist URLs', () {
const master = '''
#EXTM3U
#EXT-X-STREAM-INF:BANDWIDTH=1000
https://video-weaver.example/v1/playlist/abc.m3u8
''';
final url = TwitchAdFreePlayback.firstMediaPlaylistUrl(
master,
baseUrl: 'https://usher.ttvnw.net/foo.m3u8',
);
expect(url, 'https://video-weaver.example/v1/playlist/abc.m3u8');
});
test('returns null when no media URI present', () {
expect(
TwitchAdFreePlayback.firstMediaPlaylistUrl(
'#EXTM3U\n',
baseUrl: 'https://example.com/x.m3u8',
),
isNull,
);
});
});
}

View file

@ -15,6 +15,7 @@ void main() {
expect(store.defaultToHighestQuality, isFalse);
expect(store.useTextureRendering, isTrue);
expect(store.useNativePlayer, isTrue);
expect(store.blockTwitchAds, isTrue);
expect(store.showOverlay, isTrue);
expect(store.toggleableOverlay, isFalse);
expect(store.showLatency, isFalse);