Refactor & rename relevant channels to streams

This commit is contained in:
tommyxchow 2021-11-12 00:04:39 -05:00
parent bbd810b0c5
commit 49b3385c12
14 changed files with 296 additions and 348 deletions

View file

@ -4,7 +4,7 @@ A [Twitch](https://www.twitch.tv/) client for iOS and Android, built with [Flutt
## Features
- Browse top and followed channels on Twitch
- Browse top and followed streams on Twitch
- Watch live streams with live chat
- Support for BTTV, FFZ, and 7TV emotes, including GIFs

View file

@ -141,14 +141,14 @@ class Twitch {
return false;
}
/// Returns a map with top 10 streamers and a cursor for further requests.
static Future<Map<String, dynamic>?> getTopChannels({required Map<String, String>? headers, required String? cursor}) async {
/// Returns a map containing top 20 streams and a cursor for further requests.
static Future<Map<String, dynamic>?> getTopStreams({required Map<String, String>? headers, required String? cursor}) async {
final Uri uri;
if (cursor == null) {
uri = Uri.parse('https://api.twitch.tv/helix/streams?first=10');
uri = Uri.parse('https://api.twitch.tv/helix/streams');
} else {
uri = Uri.parse('https://api.twitch.tv/helix/streams?first=10&after=$cursor');
uri = Uri.parse('https://api.twitch.tv/helix/streams?after=$cursor');
}
final response = await http.get(uri, headers: headers);
@ -157,20 +157,20 @@ class Twitch {
final decoded = jsonDecode(response.body);
final data = decoded['data'] as List;
return {'channels': data.map((channel) => Stream.fromJson(channel)).toList(), 'cursor': decoded['pagination']['cursor']};
return {'streams': data.map((stream) => Stream.fromJson(stream)).toList(), 'cursor': decoded['pagination']['cursor']};
} else {
debugPrint('Failed to update top channels');
debugPrint('Failed to update top streams');
}
}
/// Returns a map with the given user's top 10 followed streamers and a cursor for further requests.
static Future<Map<String, dynamic>?> getFollowedChannels({required String id, required Map<String, String>? headers, required String? cursor}) async {
/// Returns a map with the given user's top 20 followed streams and a cursor for further requests.
static Future<Map<String, dynamic>?> getFollowedStreams({required String id, required Map<String, String>? headers, required String? cursor}) async {
final Uri uri;
if (cursor == null) {
uri = Uri.parse('https://api.twitch.tv/helix/streams/followed?first=10&user_id=$id');
uri = Uri.parse('https://api.twitch.tv/helix/streams/followed?user_id=$id');
} else {
uri = Uri.parse('https://api.twitch.tv/helix/streams/followed?user_id=$id&first=10&after=$cursor');
uri = Uri.parse('https://api.twitch.tv/helix/streams/followed?user_id=$id&after=$cursor');
}
final response = await http.get(uri, headers: headers);
@ -179,9 +179,9 @@ class Twitch {
final decoded = jsonDecode(response.body);
final data = decoded['data'] as List;
return {'channels': data.map((channel) => Stream.fromJson(channel)).toList(), 'cursor': decoded['pagination']['cursor']};
return {'streams': data.map((stream) => Stream.fromJson(stream)).toList(), 'cursor': decoded['pagination']['cursor']};
} else {
debugPrint('Failed to update followed channels');
debugPrint('Failed to update followed streams');
}
}

View file

@ -1,54 +1,5 @@
import 'package:frosty/models/stream.dart';
/// Twitch API client ID.
const clientId = String.fromEnvironment('CLIENT_ID');
/// Twitch API client secret.
const secret = String.fromEnvironment('SECRET');
/// Sample channel objects for testing/preview purposes.
const sampleChannels = [
Stream(
id: '',
userId: '',
userLogin: 'xqcow',
userName: 'xQcOW',
gameId: '',
gameName: 'VALORANT',
type: 'live',
title: 'TITANIC MAN WITH UNREAL UNMATCHED UNBELIEVABLE STRENGHT AND APTITUDE ACCOMPLISHES ACHIEVEMENTS DEEMED IMPOSSIBLE BY THE PUBLIC (GONE WRONG)',
viewerCount: 76422,
startedAt: '',
language: 'en',
thumbnailUrl: 'https://static-cdn.jtvnw.net/previews-ttv/live_user_xqcow-{width}x{height}.jpg',
tagIds: [],
isMature: false),
Stream(
id: '',
userId: '',
userLogin: 'lirik',
userName: 'Lirik',
gameId: '',
gameName: 'Just Chatting',
type: 'live',
title: 'LOL',
viewerCount: 11933,
startedAt: '',
language: 'en',
thumbnailUrl: 'https://static-cdn.jtvnw.net/previews-ttv/live_user_lirik-{width}x{height}.jpg',
tagIds: [],
isMature: false),
Stream(
id: '',
userId: '',
userLogin: 'mizkif',
userName: 'Mizkif',
gameId: '',
gameName: 'Just Chatting',
type: 'live',
title: 'DRAMA DRAMA DRAMA',
viewerCount: 4547,
startedAt: '',
language: 'en',
thumbnailUrl: 'https://static-cdn.jtvnw.net/previews-ttv/live_user_mizkif-{width}x{height}.jpg',
tagIds: [],
isMature: false)
];

View file

@ -1,7 +1,7 @@
import 'package:flutter/material.dart';
import 'package:frosty/screens/home.dart';
import 'package:frosty/stores/auth_store.dart';
import 'package:frosty/stores/channel_list_store.dart';
import 'package:frosty/stores/stream_list_store.dart';
import 'package:frosty/stores/home_store.dart';
import 'package:frosty/stores/settings_store.dart';
import 'package:provider/provider.dart';
@ -39,7 +39,7 @@ class MyApp extends StatelessWidget {
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.done) {
return Home(
channelListStore: ChannelListStore(authStore: context.read<AuthStore>()),
streamListStore: StreamListStore(authStore: context.read<AuthStore>()),
homeStore: HomeStore(),
);
}

View file

@ -3,21 +3,21 @@ import 'package:flutter_mobx/flutter_mobx.dart';
import 'package:frosty/screens/search.dart';
import 'package:frosty/screens/settings.dart';
import 'package:frosty/stores/auth_store.dart';
import 'package:frosty/stores/channel_list_store.dart';
import 'package:frosty/stores/stream_list_store.dart';
import 'package:frosty/stores/home_store.dart';
import 'package:frosty/stores/settings_store.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:provider/provider.dart';
import '../widgets/channel_list.dart';
import '../widgets/stream_list.dart';
class Home extends StatefulWidget {
final HomeStore homeStore;
final ChannelListStore channelListStore;
final StreamListStore streamListStore;
const Home({
Key? key,
required this.homeStore,
required this.channelListStore,
required this.streamListStore,
}) : super(key: key);
@override
@ -30,8 +30,8 @@ class _HomeState extends State<Home> {
@override
Widget build(BuildContext context) {
final titles = [
if (context.read<AuthStore>().isLoggedIn) 'Followed Channels',
'Top Channels',
if (context.read<AuthStore>().isLoggedIn) 'Followed Streams',
'Top Streams',
'Categories',
'Search',
];
@ -78,13 +78,13 @@ class _HomeState extends State<Home> {
index: widget.homeStore.selectedIndex,
children: [
if (context.read<AuthStore>().isLoggedIn)
ChannelList(
category: ChannelCategory.followed,
channelListStore: widget.channelListStore,
StreamList(
category: StreamCategory.followed,
streamListStore: widget.streamListStore,
),
ChannelList(
category: ChannelCategory.top,
channelListStore: widget.channelListStore,
StreamList(
category: StreamCategory.top,
streamListStore: widget.streamListStore,
),
const Center(
child: Text('Games'),

View file

@ -1,113 +0,0 @@
import 'package:frosty/api/twitch_api.dart';
import 'package:mobx/mobx.dart';
import 'package:frosty/stores/auth_store.dart';
import 'package:frosty/models/stream.dart';
part 'channel_list_store.g.dart';
// TODO: Fix followed list refresh when log in.
class ChannelListStore = _ChannelListBase with _$ChannelListStore;
abstract class _ChannelListBase with Store {
/// The list of the fetched top channels
@observable
var _topChannels = ObservableList<Stream>();
/// The list of the fetched followed channels
@observable
var _followedChannels = ObservableList<Stream>();
/// The loading status for pagination.
bool _isLoading = false;
/// The pagination cursor for top channels.
String? _topChannelsCurrentCursor;
/// The pagination cursor for followed channels.
String? _followedChannelsCurrentCursor;
/// The authentication store.
final AuthStore authStore;
_ChannelListBase({required this.authStore}) {
getChannels(category: ChannelCategory.top);
if (authStore.user?.id != null) {
getChannels(category: ChannelCategory.followed);
}
}
/// Returns the appropriate channels the [category].
ObservableList<Stream> channels({required ChannelCategory category}) {
switch (category) {
case ChannelCategory.top:
return _topChannels;
case ChannelCategory.followed:
return _followedChannels;
}
}
/// Returns whether or not there are more channels for the [category].
bool hasMore({required ChannelCategory category}) {
switch (category) {
case ChannelCategory.top:
return _isLoading == false && _topChannelsCurrentCursor != null;
case ChannelCategory.followed:
return _isLoading == false && _followedChannelsCurrentCursor != null;
}
}
/// Resets the cursor and then fetches the channels for the [category].
@action
Future<void> refresh({required ChannelCategory category}) async {
switch (category) {
case ChannelCategory.top:
_topChannelsCurrentCursor = null;
break;
case ChannelCategory.followed:
_followedChannelsCurrentCursor = null;
break;
}
await getChannels(category: category);
}
/// Fetches the channels for the [category] based on the current cursor.
@action
Future<void> getChannels({required ChannelCategory category}) async {
_isLoading = true;
switch (category) {
case ChannelCategory.top:
final newTopChannels = await Twitch.getTopChannels(headers: authStore.headersTwitch, cursor: _topChannelsCurrentCursor);
if (newTopChannels != null) {
if (_topChannelsCurrentCursor == null) {
_topChannels = ObservableList.of(newTopChannels['channels']);
} else {
_topChannels.addAll(newTopChannels['channels']);
}
_topChannelsCurrentCursor = newTopChannels['cursor'];
}
break;
case ChannelCategory.followed:
final newFollowedChannels =
await Twitch.getFollowedChannels(id: authStore.user!.id, headers: authStore.headersTwitch, cursor: _followedChannelsCurrentCursor);
if (newFollowedChannels != null) {
if (_followedChannelsCurrentCursor == null) {
_followedChannels = ObservableList.of(newFollowedChannels['channels']);
} else {
_followedChannels.addAll(newFollowedChannels['channels']);
}
_followedChannelsCurrentCursor = newFollowedChannels['cursor'];
}
break;
}
_isLoading = false;
}
}
/// The type of category for channels.
enum ChannelCategory { top, followed }

View file

@ -1,64 +0,0 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'channel_list_store.dart';
// **************************************************************************
// StoreGenerator
// **************************************************************************
// ignore_for_file: non_constant_identifier_names, unnecessary_brace_in_string_interps, unnecessary_lambdas, prefer_expression_function_bodies, lines_longer_than_80_chars, avoid_as, avoid_annotating_with_dynamic
mixin _$ChannelListStore on _ChannelListBase, Store {
final _$_topChannelsAtom = Atom(name: '_ChannelListBase._topChannels');
@override
ObservableList<Stream> get _topChannels {
_$_topChannelsAtom.reportRead();
return super._topChannels;
}
@override
set _topChannels(ObservableList<Stream> value) {
_$_topChannelsAtom.reportWrite(value, super._topChannels, () {
super._topChannels = value;
});
}
final _$_followedChannelsAtom =
Atom(name: '_ChannelListBase._followedChannels');
@override
ObservableList<Stream> get _followedChannels {
_$_followedChannelsAtom.reportRead();
return super._followedChannels;
}
@override
set _followedChannels(ObservableList<Stream> value) {
_$_followedChannelsAtom.reportWrite(value, super._followedChannels, () {
super._followedChannels = value;
});
}
final _$refreshAsyncAction = AsyncAction('_ChannelListBase.refresh');
@override
Future<void> refresh({required ChannelCategory category}) {
return _$refreshAsyncAction.run(() => super.refresh(category: category));
}
final _$getChannelsAsyncAction = AsyncAction('_ChannelListBase.getChannels');
@override
Future<void> getChannels({required ChannelCategory category}) {
return _$getChannelsAsyncAction
.run(() => super.getChannels(category: category));
}
@override
String toString() {
return '''
''';
}
}

View file

@ -0,0 +1,113 @@
import 'package:frosty/api/twitch_api.dart';
import 'package:mobx/mobx.dart';
import 'package:frosty/stores/auth_store.dart';
import 'package:frosty/models/stream.dart';
part 'stream_list_store.g.dart';
// TODO: Fix followed list refresh when log in.
class StreamListStore = _StreamListBase with _$StreamListStore;
abstract class _StreamListBase with Store {
/// The list of the fetched top streams.
@observable
var _topStreams = ObservableList<Stream>();
/// The list of the fetched followed streams.
@observable
var _followedStreams = ObservableList<Stream>();
/// The loading status for pagination.
bool _isLoading = false;
/// The pagination cursor for top streams.
String? _topStreamsCurrentCursor;
/// The pagination cursor for followed streams.
String? _followedStreamsCurrentCursor;
/// The authentication store.
final AuthStore authStore;
_StreamListBase({required this.authStore}) {
getStreams(category: StreamCategory.top);
if (authStore.user?.id != null) {
getStreams(category: StreamCategory.followed);
}
}
/// Returns the appropriate streams the [category].
ObservableList<Stream> streams({required StreamCategory category}) {
switch (category) {
case StreamCategory.top:
return _topStreams;
case StreamCategory.followed:
return _followedStreams;
}
}
/// Returns whether or not there are more streams for the [category].
bool hasMore({required StreamCategory category}) {
switch (category) {
case StreamCategory.top:
return _isLoading == false && _topStreamsCurrentCursor != null;
case StreamCategory.followed:
return _isLoading == false && _followedStreamsCurrentCursor != null;
}
}
/// Resets the cursor and then fetches the streams for the [category].
@action
Future<void> refresh({required StreamCategory category}) async {
switch (category) {
case StreamCategory.top:
_topStreamsCurrentCursor = null;
break;
case StreamCategory.followed:
_followedStreamsCurrentCursor = null;
break;
}
await getStreams(category: category);
}
/// Fetches the streams for the [category] based on the current cursor.
@action
Future<void> getStreams({required StreamCategory category}) async {
_isLoading = true;
switch (category) {
case StreamCategory.top:
final newTopStreams = await Twitch.getTopStreams(headers: authStore.headersTwitch, cursor: _topStreamsCurrentCursor);
if (newTopStreams != null) {
if (_topStreamsCurrentCursor == null) {
_topStreams = ObservableList.of(newTopStreams['streams']);
} else {
_topStreams.addAll(newTopStreams['streams']);
}
_topStreamsCurrentCursor = newTopStreams['cursor'];
}
break;
case StreamCategory.followed:
final newFollowedStreams =
await Twitch.getFollowedStreams(id: authStore.user!.id, headers: authStore.headersTwitch, cursor: _followedStreamsCurrentCursor);
if (newFollowedStreams != null) {
if (_followedStreamsCurrentCursor == null) {
_followedStreams = ObservableList.of(newFollowedStreams['streams']);
} else {
_followedStreams.addAll(newFollowedStreams['streams']);
}
_followedStreamsCurrentCursor = newFollowedStreams['cursor'];
}
break;
}
_isLoading = false;
}
}
/// The type of category for streams.
enum StreamCategory { top, followed }

View file

@ -0,0 +1,63 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'stream_list_store.dart';
// **************************************************************************
// StoreGenerator
// **************************************************************************
// ignore_for_file: non_constant_identifier_names, unnecessary_brace_in_string_interps, unnecessary_lambdas, prefer_expression_function_bodies, lines_longer_than_80_chars, avoid_as, avoid_annotating_with_dynamic
mixin _$StreamListStore on _StreamListBase, Store {
final _$_topStreamsAtom = Atom(name: '_StreamListBase._topStreams');
@override
ObservableList<Stream> get _topStreams {
_$_topStreamsAtom.reportRead();
return super._topStreams;
}
@override
set _topStreams(ObservableList<Stream> value) {
_$_topStreamsAtom.reportWrite(value, super._topStreams, () {
super._topStreams = value;
});
}
final _$_followedStreamsAtom = Atom(name: '_StreamListBase._followedStreams');
@override
ObservableList<Stream> get _followedStreams {
_$_followedStreamsAtom.reportRead();
return super._followedStreams;
}
@override
set _followedStreams(ObservableList<Stream> value) {
_$_followedStreamsAtom.reportWrite(value, super._followedStreams, () {
super._followedStreams = value;
});
}
final _$refreshAsyncAction = AsyncAction('_StreamListBase.refresh');
@override
Future<void> refresh({required StreamCategory category}) {
return _$refreshAsyncAction.run(() => super.refresh(category: category));
}
final _$getStreamsAsyncAction = AsyncAction('_StreamListBase.getStreams');
@override
Future<void> getStreams({required StreamCategory category}) {
return _$getStreamsAsyncAction
.run(() => super.getStreams(category: category));
}
@override
String toString() {
return '''
''';
}
}

View file

@ -1,34 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter_mobx/flutter_mobx.dart';
import 'package:frosty/stores/channel_list_store.dart';
import 'package:frosty/widgets/channel_card.dart';
class ChannelList extends StatelessWidget {
final ChannelCategory category;
final ChannelListStore channelListStore;
const ChannelList({Key? key, required this.category, required this.channelListStore}) : super(key: key);
@override
Widget build(BuildContext context) {
debugPrint('Build channel list!');
return RefreshIndicator(
child: Observer(
builder: (_) {
return ListView.builder(
itemCount: channelListStore.channels(category: category).length,
padding: const EdgeInsets.all(5.0),
itemBuilder: (context, index) {
if (index > channelListStore.channels(category: category).length / 2 && channelListStore.hasMore(category: category)) {
channelListStore.getChannels(category: category);
}
return ChannelCard(channelInfo: channelListStore.channels(category: category).elementAt(index));
},
);
},
),
onRefresh: () => channelListStore.refresh(category: category),
);
}
}

View file

@ -4,11 +4,11 @@ import 'package:frosty/models/stream.dart';
import 'package:frosty/screens/video_chat.dart';
import 'package:intl/intl.dart';
/// A card widget that displays a live channel's thumbnail and details.
class ChannelCard extends StatelessWidget {
final Stream channelInfo;
/// A tappable card widget that displays a stream's thumbnail and details.
class StreamCard extends StatelessWidget {
final Stream streamInfo;
const ChannelCard({Key? key, required this.channelInfo}) : super(key: key);
const StreamCard({Key? key, required this.streamInfo}) : super(key: key);
@override
Widget build(BuildContext context) {
@ -20,8 +20,8 @@ class ChannelCard extends StatelessWidget {
MaterialPageRoute(
builder: (context) {
return VideoChat(
userLogin: channelInfo.userLogin,
userName: channelInfo.userName,
userLogin: streamInfo.userLogin,
userName: streamInfo.userName,
);
},
),
@ -36,7 +36,7 @@ class ChannelCard extends StatelessWidget {
child: Padding(
padding: const EdgeInsets.only(left: 10.0),
child: CachedNetworkImage(
imageUrl: channelInfo.thumbnailUrl.replaceFirst('-{width}x{height}', '-440x248'),
imageUrl: streamInfo.thumbnailUrl.replaceFirst('-{width}x{height}', '-440x248'),
),
),
),
@ -49,22 +49,22 @@ class ChannelCard extends StatelessWidget {
mainAxisSize: MainAxisSize.min,
children: [
Text(
channelInfo.userName,
streamInfo.userName,
style: const TextStyle(fontSize: 16),
),
const SizedBox(height: 5),
SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Text(channelInfo.title.replaceAll('\n', '')),
child: Text(streamInfo.title.replaceAll('\n', '')),
),
const SizedBox(height: 5),
Text(
channelInfo.gameName,
streamInfo.gameName,
style: const TextStyle(fontSize: 12),
),
const SizedBox(height: 5),
Text(
'${NumberFormat().format(channelInfo.viewerCount)} viewers',
'${NumberFormat().format(streamInfo.viewerCount)} viewers',
style: const TextStyle(fontSize: 12),
),
],

View file

@ -0,0 +1,32 @@
import 'package:flutter/material.dart';
import 'package:flutter_mobx/flutter_mobx.dart';
import 'package:frosty/stores/stream_list_store.dart';
import 'package:frosty/widgets/stream_card.dart';
class StreamList extends StatelessWidget {
final StreamCategory category;
final StreamListStore streamListStore;
const StreamList({Key? key, required this.category, required this.streamListStore}) : super(key: key);
@override
Widget build(BuildContext context) {
return RefreshIndicator(
child: Observer(
builder: (_) {
return ListView.builder(
itemCount: streamListStore.streams(category: category).length,
padding: const EdgeInsets.all(5.0),
itemBuilder: (context, index) {
if (index > streamListStore.streams(category: category).length / 2 && streamListStore.hasMore(category: category)) {
streamListStore.getStreams(category: category);
}
return StreamCard(streamInfo: streamListStore.streams(category: category).elementAt(index));
},
);
},
),
onRefresh: () => streamListStore.refresh(category: category),
);
}
}

View file

@ -1,48 +0,0 @@
import 'dart:convert';
import 'package:flutter_test/flutter_test.dart';
import 'package:frosty/models/stream.dart';
void main() {
test('Twitch channel should parse', () {
const sampleChannel = '''
{
"id":"43809710301",
"user_id":"71092938",
"user_login":"xqcow",
"user_name":"xQcOW",
"game_id":"508292",
"game_name":"Bloons TD 6",
"type":"live",
"title":"A shorter title...",
"viewer_count":50051,
"started_at":"2021-09-23T20:06:22Z",
"language":"en",
"thumbnail_url":"https://static-cdn.jtvnw.net/previews-ttv/live_user_xqcow-{width}x{height}.jpg",
"tag_ids":[
"e6bb8b34-4c28-4b5f-94ed-12c1ebf2d0e4",
"6ea6bca4-4712-4ab9-a906-e3336a9d8039",
"6606e54c-f92d-40f6-8257-74977889ccdd"
],
"is_mature":false
}
''';
final decoded = jsonDecode(sampleChannel);
final channel = Stream.fromJson(decoded);
expect(channel.id, '43809710301');
expect(channel.userId, '71092938');
expect(channel.userLogin, 'xqcow');
expect(channel.userName, 'xQcOW');
expect(channel.gameId, '508292');
expect(channel.gameName, 'Bloons TD 6');
expect(channel.type, 'live');
expect(channel.title, 'A shorter title...');
expect(channel.viewerCount, 50051);
expect(channel.startedAt, '2021-09-23T20:06:22Z');
expect(channel.language, 'en');
expect(channel.thumbnailUrl, 'https://static-cdn.jtvnw.net/previews-ttv/live_user_xqcow-{width}x{height}.jpg');
expect(channel.tagIds, ["e6bb8b34-4c28-4b5f-94ed-12c1ebf2d0e4", "6ea6bca4-4712-4ab9-a906-e3336a9d8039", "6606e54c-f92d-40f6-8257-74977889ccdd"]);
expect(channel.isMature, false);
});
}

48
test/stream_test.dart Normal file
View file

@ -0,0 +1,48 @@
import 'dart:convert';
import 'package:flutter_test/flutter_test.dart';
import 'package:frosty/models/stream.dart';
void main() {
test('Twitch stream should parse correctly', () {
const sampleStream = '''
{
"id":"43809710301",
"user_id":"71092938",
"user_login":"xqcow",
"user_name":"xQcOW",
"game_id":"508292",
"game_name":"Bloons TD 6",
"type":"live",
"title":"A shorter title...",
"viewer_count":50051,
"started_at":"2021-09-23T20:06:22Z",
"language":"en",
"thumbnail_url":"https://static-cdn.jtvnw.net/previews-ttv/live_user_xqcow-{width}x{height}.jpg",
"tag_ids":[
"e6bb8b34-4c28-4b5f-94ed-12c1ebf2d0e4",
"6ea6bca4-4712-4ab9-a906-e3336a9d8039",
"6606e54c-f92d-40f6-8257-74977889ccdd"
],
"is_mature":false
}
''';
final decoded = jsonDecode(sampleStream);
final stream = Stream.fromJson(decoded);
expect(stream.id, '43809710301');
expect(stream.userId, '71092938');
expect(stream.userLogin, 'xqcow');
expect(stream.userName, 'xQcOW');
expect(stream.gameId, '508292');
expect(stream.gameName, 'Bloons TD 6');
expect(stream.type, 'live');
expect(stream.title, 'A shorter title...');
expect(stream.viewerCount, 50051);
expect(stream.startedAt, '2021-09-23T20:06:22Z');
expect(stream.language, 'en');
expect(stream.thumbnailUrl, 'https://static-cdn.jtvnw.net/previews-ttv/live_user_xqcow-{width}x{height}.jpg');
expect(stream.tagIds, ["e6bb8b34-4c28-4b5f-94ed-12c1ebf2d0e4", "6ea6bca4-4712-4ab9-a906-e3336a9d8039", "6606e54c-f92d-40f6-8257-74977889ccdd"]);
expect(stream.isMature, false);
});
}