feat(video): adopt streamlink playback hardening and ad-break awareness
- use Twitch's persisted PlaybackAccessToken query with web client headers (UA/Referer/Origin pinned per streamlink), falling back to the inline template query on GQL errors - switch playlist requests to the usher v2 endpoint and forward the same playback headers to the native player - parse hide_ads and chansub.restricted_bitrates from the token payload; surface subscriber-only qualities as disabled tiles in the quality picker - subscribe to the native player's adBreakChanged events: pause latency polling, defer stall recovery, and skip bounce counting during ad pods - bump better_native_video_player to 8556673 (stitched-ad detection) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
2258f9832b
commit
b305deec42
9 changed files with 322 additions and 59 deletions
|
|
@ -1,6 +1,7 @@
|
|||
import 'dart:math';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:frosty/apis/base_api_client.dart';
|
||||
import 'package:frosty/constants.dart';
|
||||
import 'package:frosty/models/playback_access_token.dart';
|
||||
|
|
@ -12,38 +13,89 @@ import 'package:frosty/models/playback_access_token.dart';
|
|||
class TwitchGqlApi extends BaseApiClient {
|
||||
TwitchGqlApi(Dio dio) : super(dio, 'https://gql.twitch.tv');
|
||||
|
||||
/// Browser-consistent User-Agent for playback-related requests. Twitch has
|
||||
/// rejected access-token requests with non-browser UAs before
|
||||
/// (streamlink#6574); sending one consistent with the web client ID and
|
||||
/// web session token avoids the obvious fingerprint mismatch.
|
||||
static const playbackUserAgent =
|
||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 '
|
||||
'(KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36';
|
||||
|
||||
/// Headers for the HLS playlist/segment requests issued by the native
|
||||
/// player, mirroring what the web player sends to Usher/weaver.
|
||||
static const playbackHttpHeaders = <String, String>{
|
||||
'User-Agent': playbackUserAgent,
|
||||
'Referer': 'https://player.twitch.tv',
|
||||
'Origin': 'https://player.twitch.tv',
|
||||
};
|
||||
|
||||
/// Fetches a playback access token for the given channel [login].
|
||||
///
|
||||
/// When [authToken] is provided, the request is authenticated which may
|
||||
/// grant ad-free playback for subscribers and Turbo users.
|
||||
///
|
||||
/// 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,
|
||||
}) async {
|
||||
final headers = <String, String>{
|
||||
'Client-ID': twitchGqlClientId,
|
||||
'User-Agent': playbackUserAgent,
|
||||
if (authToken != null) 'Authorization': 'OAuth $authToken',
|
||||
};
|
||||
|
||||
final body = {
|
||||
'operationName': 'PlaybackAccessToken_Template',
|
||||
'query':
|
||||
'query PlaybackAccessToken_Template(\$login: String!, \$isLive: Boolean!, \$vodID: ID!, \$isVod: Boolean!, \$playerType: String!) { streamPlaybackAccessToken(channelName: \$login, params: {platform: "web", playerBackend: "mediaplayer", playerType: \$playerType}) @include(if: \$isLive) { value signature __typename } videoPlaybackAccessToken(id: \$vodID, params: {platform: "web", playerBackend: "mediaplayer", playerType: \$playerType}) @include(if: \$isVod) { value signature __typename } }',
|
||||
'variables': {
|
||||
'isLive': true,
|
||||
'login': login,
|
||||
'isVod': false,
|
||||
'vodID': '',
|
||||
'playerType': 'site',
|
||||
final variables = {
|
||||
'isLive': true,
|
||||
'login': login,
|
||||
'isVod': false,
|
||||
'vodID': '',
|
||||
'playerType': 'site',
|
||||
'platform': 'web',
|
||||
};
|
||||
|
||||
final persistedBody = {
|
||||
'operationName': 'PlaybackAccessToken',
|
||||
'extensions': {
|
||||
'persistedQuery': {
|
||||
'version': 1,
|
||||
// Stable for years; used by the web player and streamlink.
|
||||
'sha256Hash':
|
||||
'ed230aa1e33e07eebb8928504583da78a5173989fadfb1ac94be06a04f3cdbe9',
|
||||
},
|
||||
},
|
||||
'variables': variables,
|
||||
};
|
||||
|
||||
final response = await post<JsonMap>(
|
||||
'/gql',
|
||||
data: body,
|
||||
data: persistedBody,
|
||||
headers: headers,
|
||||
);
|
||||
|
||||
// PersistedQueryNotFound (or similar) comes back as a 200 with an
|
||||
// errors array — retry once with the inline template before failing.
|
||||
if (response['errors'] != null && response['data'] == null) {
|
||||
debugPrint(
|
||||
'TwitchGqlApi: persisted query failed (${response['errors']}), '
|
||||
'falling back to inline template',
|
||||
);
|
||||
final templateBody = {
|
||||
'operationName': 'PlaybackAccessToken_Template',
|
||||
'query':
|
||||
'query PlaybackAccessToken_Template(\$login: String!, \$isLive: Boolean!, \$vodID: ID!, \$isVod: Boolean!, \$playerType: String!) { streamPlaybackAccessToken(channelName: \$login, params: {platform: "web", playerBackend: "mediaplayer", playerType: \$playerType}) @include(if: \$isLive) { value signature __typename } videoPlaybackAccessToken(id: \$vodID, params: {platform: "web", playerBackend: "mediaplayer", playerType: \$playerType}) @include(if: \$isVod) { value signature __typename } }',
|
||||
'variables': variables,
|
||||
};
|
||||
final fallback = await post<JsonMap>(
|
||||
'/gql',
|
||||
data: templateBody,
|
||||
headers: headers,
|
||||
);
|
||||
return PlaybackAccessToken.fromGqlResponse(fallback);
|
||||
}
|
||||
|
||||
return PlaybackAccessToken.fromGqlResponse(response);
|
||||
}
|
||||
|
||||
|
|
@ -55,7 +107,10 @@ class TwitchGqlApi extends BaseApiClient {
|
|||
final random = Random().nextInt(999999);
|
||||
final encodedToken = Uri.encodeComponent(token.value);
|
||||
|
||||
return 'https://usher.ttvnw.net/api/channel/hls/$login.m3u8'
|
||||
// Usher v2 — what the web player and streamlink use today. Same query
|
||||
// params and token; the multivariant playlist is standards-compliant
|
||||
// (EXT-X-SESSION-DATA + STREAM-INF only, no EXT-X-MEDIA/TWITCH-INFO).
|
||||
return 'https://usher.ttvnw.net/api/v2/channel/hls/$login.m3u8'
|
||||
'?sig=${token.signature}'
|
||||
'&token=$encodedToken'
|
||||
'&allow_source=true'
|
||||
|
|
|
|||
|
|
@ -1,24 +1,65 @@
|
|||
class PlaybackAccessToken {
|
||||
final String value;
|
||||
final String signature;
|
||||
|
||||
const PlaybackAccessToken({
|
||||
required this.value,
|
||||
required this.signature,
|
||||
});
|
||||
|
||||
factory PlaybackAccessToken.fromGqlResponse(Map<String, dynamic> json) {
|
||||
final data =
|
||||
(json['data'] as Map<String, dynamic>?)?['streamPlaybackAccessToken']
|
||||
as Map<String, dynamic>?;
|
||||
if (data == null) {
|
||||
throw const FormatException(
|
||||
'streamPlaybackAccessToken missing from GQL response',
|
||||
);
|
||||
}
|
||||
return PlaybackAccessToken(
|
||||
value: data['value'] as String,
|
||||
signature: data['signature'] as String,
|
||||
);
|
||||
}
|
||||
}
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
class PlaybackAccessToken {
|
||||
final String value;
|
||||
final String signature;
|
||||
|
||||
/// Whether Twitch will suppress ads for this session (Turbo, sub, etc.).
|
||||
/// Defaults to false when the token payload can't be parsed.
|
||||
final bool hideAds;
|
||||
|
||||
/// Quality group names (e.g. "1080p60") that exist for this stream but are
|
||||
/// restricted to subscribers, so they won't appear in the HLS playlist.
|
||||
final List<String> restrictedQualities;
|
||||
|
||||
const PlaybackAccessToken({
|
||||
required this.value,
|
||||
required this.signature,
|
||||
this.hideAds = false,
|
||||
this.restrictedQualities = const [],
|
||||
});
|
||||
|
||||
/// Names in restricted_bitrates that aren't real video qualities
|
||||
/// (matches streamlink's filter).
|
||||
static final _nonQualityNameRe = RegExp(r'^((.+_)?archives|live|chunked)');
|
||||
|
||||
factory PlaybackAccessToken.fromGqlResponse(Map<String, dynamic> json) {
|
||||
final data =
|
||||
(json['data'] as Map<String, dynamic>?)?['streamPlaybackAccessToken']
|
||||
as Map<String, dynamic>?;
|
||||
if (data == null) {
|
||||
throw const FormatException(
|
||||
'streamPlaybackAccessToken missing from GQL response',
|
||||
);
|
||||
}
|
||||
|
||||
final value = data['value'] as String;
|
||||
var hideAds = false;
|
||||
var restricted = const <String>[];
|
||||
try {
|
||||
final payload = jsonDecode(value) as Map<String, dynamic>;
|
||||
hideAds = payload['hide_ads'] as bool? ?? false;
|
||||
final bitrates =
|
||||
(payload['chansub'] as Map<String, dynamic>?)?['restricted_bitrates']
|
||||
as List<dynamic>?;
|
||||
if (bitrates != null) {
|
||||
restricted = bitrates
|
||||
.whereType<String>()
|
||||
.where((name) => !_nonQualityNameRe.hasMatch(name))
|
||||
.toList();
|
||||
}
|
||||
} catch (e) {
|
||||
// Token payload format is Twitch-internal — never fail playback over it.
|
||||
debugPrint('PlaybackAccessToken: failed to parse token payload: $e');
|
||||
}
|
||||
|
||||
return PlaybackAccessToken(
|
||||
value: value,
|
||||
signature: data['signature'] as String,
|
||||
hideAds: hideAds,
|
||||
restrictedQualities: restricted,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -103,6 +103,13 @@ abstract class NativeVideoStoreBase
|
|||
|
||||
StreamSubscription<bool>? _pipSub;
|
||||
StreamSubscription<List<NativeVideoPlayerQuality>>? _qualitiesSub;
|
||||
StreamSubscription<bool>? _adBreakSub;
|
||||
|
||||
/// True while a server-stitched ad break is playing (native player detects
|
||||
/// twitch-stitched-ad dateranges). Latency readings and play/buffer
|
||||
/// transitions are unreliable during ads, so recovery and chat sync are
|
||||
/// suspended rather than burning refresh attempts on ad-induced noise.
|
||||
var _isAdBreakActive = false;
|
||||
|
||||
@readonly
|
||||
var _loading = true;
|
||||
|
|
@ -125,6 +132,11 @@ abstract class NativeVideoStoreBase
|
|||
@readonly
|
||||
var _availableStreamQualities = <String>[];
|
||||
|
||||
/// Qualities that exist for this stream but require a subscription
|
||||
/// (parsed from the playback token's restricted_bitrates).
|
||||
@readonly
|
||||
var _restrictedStreamQualities = <String>[];
|
||||
|
||||
@readonly
|
||||
var _streamQualityIndex = 0;
|
||||
|
||||
|
|
@ -257,6 +269,13 @@ abstract class NativeVideoStoreBase
|
|||
return;
|
||||
}
|
||||
|
||||
_adBreakSub = _controller!.adBreakStream.listen((isActive) {
|
||||
_isAdBreakActive = isActive;
|
||||
debugPrint(
|
||||
'NativeVideoStore: ad break ${isActive ? 'started' : 'ended'}',
|
||||
);
|
||||
});
|
||||
|
||||
_pipSub = _controller!.isPipEnabledStream.listen((isPip) {
|
||||
runInAction(() {
|
||||
// iOS-only bookkeeping: track whether PiP was triggered by gesture
|
||||
|
|
@ -380,9 +399,15 @@ abstract class NativeVideoStoreBase
|
|||
if (_disposed || generation != _initGeneration || !settingsStore.showVideo) {
|
||||
return;
|
||||
}
|
||||
runInAction(
|
||||
() => _restrictedStreamQualities = token.restrictedQualities,
|
||||
);
|
||||
final hlsUrl = twitchGqlApi.buildHlsUrl(login: userLogin, token: token);
|
||||
|
||||
await _controller!.loadUrl(url: hlsUrl);
|
||||
await _controller!.loadUrl(
|
||||
url: hlsUrl,
|
||||
headers: TwitchGqlApi.playbackHttpHeaders,
|
||||
);
|
||||
if (_disposed || generation != _initGeneration || !settingsStore.showVideo) {
|
||||
return;
|
||||
}
|
||||
|
|
@ -503,7 +528,9 @@ abstract class NativeVideoStoreBase
|
|||
// to engage recovery on those stalls.
|
||||
if (_hasPlayedOnce && !_isQualitySwitching) {
|
||||
_isStalled = true;
|
||||
_recordShortPlayBounceIfNeeded();
|
||||
// Ad-pod entry/exit produces short play/buffer bounces that would
|
||||
// otherwise count toward the CDN-trouble error heuristic.
|
||||
if (!_isAdBreakActive) _recordShortPlayBounceIfNeeded();
|
||||
}
|
||||
// Clear _initializing on the first buffering event so the stall
|
||||
// recovery timer isn't permanently blocked during init stalls.
|
||||
|
|
@ -669,6 +696,15 @@ abstract class NativeVideoStoreBase
|
|||
return;
|
||||
}
|
||||
|
||||
// Ad-pod transitions cause brief discontinuities that look like stalls.
|
||||
// Defer (not skip) recovery so a genuine stall surviving the ad break
|
||||
// still gets handled once the break ends.
|
||||
if (_isAdBreakActive) {
|
||||
debugPrint('NativeVideoStore: stall during ad break, deferring recovery');
|
||||
_startStallRecoveryTimer();
|
||||
return;
|
||||
}
|
||||
|
||||
// When a stream ends the HLS server stops serving segments, causing
|
||||
// the player to stall indefinitely. Skip recovery if the stream is
|
||||
// offline — the overlay handles that state.
|
||||
|
|
@ -802,6 +838,11 @@ abstract class NativeVideoStoreBase
|
|||
}
|
||||
|
||||
Future<void> _updateLatency() async {
|
||||
// Program-date-time readings jump around during stitched ad breaks —
|
||||
// skip the poll entirely (display, recovery, and chat sync all resume
|
||||
// on the next tick after the break ends).
|
||||
if (_isAdBreakActive) return;
|
||||
|
||||
final seconds = await _controller?.getLatencyToLive();
|
||||
if (_disposed || seconds == null) return;
|
||||
final rounded = seconds.round();
|
||||
|
|
@ -1202,6 +1243,8 @@ abstract class NativeVideoStoreBase
|
|||
_pipSub = null;
|
||||
_qualitiesSub?.cancel();
|
||||
_qualitiesSub = null;
|
||||
_adBreakSub?.cancel();
|
||||
_adBreakSub = null;
|
||||
_controller?.removeActivityListener(_onPlayerActivity);
|
||||
_controller?.dispose();
|
||||
_controller = null;
|
||||
|
|
@ -1222,6 +1265,8 @@ abstract class NativeVideoStoreBase
|
|||
_pendingRefreshOnResume = false;
|
||||
_paused = true;
|
||||
_isStalled = false;
|
||||
_isAdBreakActive = false;
|
||||
_restrictedStreamQualities = [];
|
||||
_isQualitySwitching = false;
|
||||
_isAudioOnlyMode = false;
|
||||
_stallRecoveryAttempt = 0;
|
||||
|
|
|
|||
|
|
@ -181,6 +181,30 @@ mixin _$NativeVideoStore on NativeVideoStoreBase, Store {
|
|||
);
|
||||
}
|
||||
|
||||
late final _$_restrictedStreamQualitiesAtom = Atom(
|
||||
name: 'NativeVideoStoreBase._restrictedStreamQualities',
|
||||
context: context,
|
||||
);
|
||||
|
||||
List<String> get restrictedStreamQualities {
|
||||
_$_restrictedStreamQualitiesAtom.reportRead();
|
||||
return super._restrictedStreamQualities;
|
||||
}
|
||||
|
||||
@override
|
||||
List<String> get _restrictedStreamQualities => restrictedStreamQualities;
|
||||
|
||||
@override
|
||||
set _restrictedStreamQualities(List<String> value) {
|
||||
_$_restrictedStreamQualitiesAtom.reportWrite(
|
||||
value,
|
||||
super._restrictedStreamQualities,
|
||||
() {
|
||||
super._restrictedStreamQualities = value;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
late final _$_streamQualityIndexAtom = Atom(
|
||||
name: 'NativeVideoStoreBase._streamQualityIndex',
|
||||
context: context,
|
||||
|
|
|
|||
|
|
@ -99,26 +99,35 @@ class VideoOverlay extends StatelessWidget {
|
|||
builder: (context) => ListView(
|
||||
shrinkWrap: true,
|
||||
primary: false,
|
||||
children: videoStore.availableStreamQualities
|
||||
.map(
|
||||
(quality) => ListTile(
|
||||
trailing: videoStore.streamQuality == quality
|
||||
? const Icon(Icons.check_rounded)
|
||||
: null,
|
||||
title: Text(quality),
|
||||
onTap: () {
|
||||
videoStore.setStreamQuality(quality);
|
||||
SharedPreferences.getInstance().then(
|
||||
(prefs) => prefs.setString(
|
||||
lastStreamQualityKey(videoStore.userLogin),
|
||||
quality,
|
||||
),
|
||||
);
|
||||
Navigator.pop(context);
|
||||
},
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
children: [
|
||||
...videoStore.availableStreamQualities.map(
|
||||
(quality) => ListTile(
|
||||
trailing: videoStore.streamQuality == quality
|
||||
? const Icon(Icons.check_rounded)
|
||||
: null,
|
||||
title: Text(quality),
|
||||
onTap: () {
|
||||
videoStore.setStreamQuality(quality);
|
||||
SharedPreferences.getInstance().then(
|
||||
(prefs) => prefs.setString(
|
||||
lastStreamQualityKey(videoStore.userLogin),
|
||||
quality,
|
||||
),
|
||||
);
|
||||
Navigator.pop(context);
|
||||
},
|
||||
),
|
||||
),
|
||||
// Qualities the broadcaster offers only to subscribers
|
||||
// (from the playback token's restricted_bitrates).
|
||||
...videoStore.restrictedStreamQualities.map(
|
||||
(quality) => ListTile(
|
||||
enabled: false,
|
||||
title: Text(quality),
|
||||
subtitle: const Text('Subscribers only'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -20,6 +20,12 @@ abstract class VideoPlayerInterface {
|
|||
StreamTwitch? get streamInfo;
|
||||
Channel? get offlineChannelInfo;
|
||||
List<String> get availableStreamQualities;
|
||||
|
||||
/// Qualities that exist for this stream but require a subscription.
|
||||
/// Only the native player can know this (parsed from the playback token);
|
||||
/// the WebView player always reports none.
|
||||
List<String> get restrictedStreamQualities;
|
||||
|
||||
String get streamQuality;
|
||||
String? get latency;
|
||||
|
||||
|
|
|
|||
|
|
@ -237,6 +237,11 @@ abstract class VideoStoreBase with Store implements VideoPlayerInterface {
|
|||
@readonly
|
||||
List<String> _availableStreamQualities = [];
|
||||
|
||||
// The WebView player has no access to the playback token, so sub-only
|
||||
// qualities can't be detected here.
|
||||
@override
|
||||
List<String> get restrictedStreamQualities => const [];
|
||||
|
||||
// The current stream quality index
|
||||
@readonly
|
||||
int _streamQualityIndex = 0;
|
||||
|
|
|
|||
|
|
@ -102,7 +102,7 @@ packages:
|
|||
description:
|
||||
path: "."
|
||||
ref: frosty-patches
|
||||
resolved-ref: f088474cec180921e4d22214835d4e2f1b8f4ac0
|
||||
resolved-ref: "8556673c3939a16061433061106e2a2a1f9af602"
|
||||
url: "https://github.com/tommyxchow/flutter-native-video-player.git"
|
||||
source: git
|
||||
version: "0.4.11"
|
||||
|
|
|
|||
78
test/models/playback_access_token_test.dart
Normal file
78
test/models/playback_access_token_test.dart
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:frosty/models/playback_access_token.dart';
|
||||
|
||||
Map<String, dynamic> gqlResponse({required String value, String sig = 'sig'}) =>
|
||||
{
|
||||
'data': {
|
||||
'streamPlaybackAccessToken': {'value': value, 'signature': sig},
|
||||
},
|
||||
};
|
||||
|
||||
void main() {
|
||||
group('PlaybackAccessToken.fromGqlResponse', () {
|
||||
test('parses hide_ads and restricted bitrates from token payload', () {
|
||||
final value = jsonEncode({
|
||||
'hide_ads': true,
|
||||
'chansub': {
|
||||
'restricted_bitrates': [
|
||||
'1080p60',
|
||||
'720p60',
|
||||
'chunked',
|
||||
'archives',
|
||||
'sub_archives',
|
||||
'live',
|
||||
'audio_only',
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
final token = PlaybackAccessToken.fromGqlResponse(
|
||||
gqlResponse(value: value),
|
||||
);
|
||||
|
||||
expect(token.value, value);
|
||||
expect(token.signature, 'sig');
|
||||
expect(token.hideAds, isTrue);
|
||||
// chunked/archives/sub_archives/live are playlist-internal names, not
|
||||
// real qualities; names merely containing those words must survive.
|
||||
expect(token.restrictedQualities, [
|
||||
'1080p60',
|
||||
'720p60',
|
||||
'audio_only',
|
||||
]);
|
||||
});
|
||||
|
||||
test('does not filter qualities that merely contain reserved words', () {
|
||||
final value = jsonEncode({
|
||||
'chansub': {
|
||||
'restricted_bitrates': ['1080p60_live', 'olive', 'rearchives'],
|
||||
},
|
||||
});
|
||||
|
||||
final token = PlaybackAccessToken.fromGqlResponse(
|
||||
gqlResponse(value: value),
|
||||
);
|
||||
|
||||
expect(token.restrictedQualities, ['1080p60_live', 'olive', 'rearchives']);
|
||||
});
|
||||
|
||||
test('survives a non-JSON token payload', () {
|
||||
final token = PlaybackAccessToken.fromGqlResponse(
|
||||
gqlResponse(value: 'not-json'),
|
||||
);
|
||||
|
||||
expect(token.value, 'not-json');
|
||||
expect(token.hideAds, isFalse);
|
||||
expect(token.restrictedQualities, isEmpty);
|
||||
});
|
||||
|
||||
test('throws FormatException when token is missing', () {
|
||||
expect(
|
||||
() => PlaybackAccessToken.fromGqlResponse({'data': null}),
|
||||
throwsFormatException,
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue