import 'dart:convert'; import 'package:flutter/foundation.dart'; import 'package:frosty/constants.dart'; import 'package:frosty/models/badges.dart'; import 'package:frosty/models/category.dart'; import 'package:frosty/models/channel.dart'; import 'package:frosty/models/chatters.dart'; import 'package:frosty/models/emotes.dart'; import 'package:frosty/models/stream.dart'; import 'package:frosty/models/user.dart'; import 'package:http/http.dart'; /// The Twitch service for making API calls. class TwitchApi { final Client _client; const TwitchApi(this._client); /// Returns a list of all Twitch global emotes. Future> getEmotesGlobal({required Map headers}) async { final url = Uri.parse('https://api.twitch.tv/helix/chat/emotes/global'); final response = await _client.get(url, headers: headers); if (response.statusCode == 200) { final decoded = jsonDecode(response.body)['data'] as List; final emotes = decoded.map((emote) => EmoteTwitch.fromJson(emote)).toList(); return emotes.map((emote) => Emote.fromTwitch(emote, EmoteType.twitchGlobal)).toList(); } else { return Future.error('Failed to get Twitch global emotes'); } } /// Returns a list of a channel's Twitch emotes given their [id]. Future> getEmotesChannel({ required String id, required Map headers, }) async { final url = Uri.parse('https://api.twitch.tv/helix/chat/emotes?broadcaster_id=$id'); final response = await _client.get(url, headers: headers); if (response.statusCode == 200) { final decoded = jsonDecode(response.body)['data'] as List; final emotes = decoded.map((emote) => EmoteTwitch.fromJson(emote)).toList(); return emotes.map((emote) { switch (emote.emoteType) { case 'bitstier': return Emote.fromTwitch(emote, EmoteType.twitchBits); case 'follower': return Emote.fromTwitch(emote, EmoteType.twitchFollower); case 'subscriptions': return Emote.fromTwitch(emote, EmoteType.twitchChannel); default: return Emote.fromTwitch(emote, EmoteType.twitchChannel); } }).toList(); } else { return Future.error('Failed to get Twitch channel emotes'); } } /// Returns a list of Twitch emotes under the provided [setId]. Future> getEmotesSets({ required String setId, required Map headers, }) async { final url = Uri.parse('https://api.twitch.tv/helix/chat/emotes/set?emote_set_id=$setId'); final response = await _client.get(url, headers: headers); if (response.statusCode == 200) { final decoded = jsonDecode(response.body)['data'] as List; final emotes = decoded.map((emote) => EmoteTwitch.fromJson(emote)).toList(); return emotes.map((emote) { switch (emote.emoteType) { case 'globals': case 'smilies': return Emote.fromTwitch(emote, EmoteType.twitchGlobal); case 'subscriptions': return Emote.fromTwitch(emote, EmoteType.twitchSub); default: return Emote.fromTwitch(emote, EmoteType.twitchUnlocked); } }).toList(); } else { return Future.error('Failed to get Twitch emotes set'); } } /// Returns a map of global Twitch badges to their [Emote] object. Future> getBadgesGlobal() async { final url = Uri.parse('https://badges.twitch.tv/v1/badges/global/display'); final response = await _client.get(url); if (response.statusCode == 200) { final result = {}; final decoded = jsonDecode(response.body)['badge_sets'] as Map; decoded.forEach((id, versions) => (versions['versions'] as Map).forEach( (version, badgeInfo) => result['$id/$version'] = Badge.fromTwitch(BadgeInfoTwitch.fromJson(badgeInfo)))); return result; } else { return Future.error('Failed to get Twitch global badges'); } } /// Returns a map of a channel's Twitch badges to their [Emote] object. Future> getBadgesChannel({required String id}) async { final url = Uri.parse('https://badges.twitch.tv/v1/badges/channels/$id/display'); final response = await _client.get(url); if (response.statusCode == 200) { final result = {}; final decoded = jsonDecode(response.body)['badge_sets'] as Map; decoded.forEach((id, versions) => (versions['versions'] as Map).forEach( (version, badgeInfo) => result['$id/$version'] = Badge.fromTwitch(BadgeInfoTwitch.fromJson(badgeInfo)))); return result; } else { return Future.error('Failed to get Twitch channel badges'); } } /// Returns the user's info given their token through [headers]. Future getUserInfo({required Map headers}) async { final url = Uri.parse('https://api.twitch.tv/helix/users'); final response = await _client.get(url, headers: headers); if (response.statusCode == 200) { final userData = jsonDecode(response.body)['data'] as List; return UserTwitch.fromJson(userData.first); } else { return Future.error('Failed to get Twitch user info'); } } /// Returns a token for an anonymous user. Future getDefaultToken() async { final url = Uri( scheme: 'https', host: 'id.twitch.tv', path: '/oauth2/token', queryParameters: { 'client_id': clientId, 'client_secret': secret, 'grant_type': 'client_credentials', }, ); final response = await _client.post(url); if (response.statusCode == 200) { return jsonDecode(response.body)['access_token']; } else { return Future.error('Failed to get default token'); } } /// Returns a bool indicating the validity of the given token. Future validateToken({required String token}) async { final url = Uri.parse('https://id.twitch.tv/oauth2/validate'); final response = await _client.get(url, headers: {'Authorization': 'Bearer $token'}); if (response.statusCode == 200) { return true; } else { return false; } } /// Returns a [StreamsTwitch] object that contains the top 20 streams and a cursor for further requests. Future getTopStreams({ required Map headers, String? cursor, }) async { final url = Uri.parse( cursor == null ? 'https://api.twitch.tv/helix/streams' : 'https://api.twitch.tv/helix/streams?after=$cursor'); final response = await _client.get(url, headers: headers); final decoded = jsonDecode(response.body); if (response.statusCode == 200) { return StreamsTwitch.fromJson(decoded); } else { return Future.error('Failed to get top streams: ${decoded['message']}'); } } /// Returns a [StreamsTwitch] object that contains the given user ID's top 20 followed streams and a cursor for further requests. Future getFollowedStreams({ required String id, required Map headers, String? cursor, }) async { final url = Uri.parse(cursor == null ? 'https://api.twitch.tv/helix/streams/followed?user_id=$id' : 'https://api.twitch.tv/helix/streams/followed?user_id=$id&after=$cursor'); final response = await _client.get(url, headers: headers); final decoded = jsonDecode(response.body); if (response.statusCode == 200) { return StreamsTwitch.fromJson(decoded); } else { return Future.error('Failed to get followed streams: ${decoded['message']}'); } } /// Returns a [StreamsTwitch] object that contains the list of streams under the given game/category ID. Future getStreamsUnderCategory({ required String gameId, required Map headers, String? cursor, }) async { final url = Uri.parse(cursor == null ? 'https://api.twitch.tv/helix/streams?game_id=$gameId' : 'https://api.twitch.tv/helix/streams?game_id=$gameId&after=$cursor'); final response = await _client.get(url, headers: headers); final decoded = jsonDecode(response.body); if (response.statusCode == 200) { return StreamsTwitch.fromJson(decoded); } else { return Future.error('Failed to get streams under category: ${decoded['message']}'); } } /// Returns a [StreamTwitch] object containing the stream info associated with the given [userLogin]. Future getStream({ required String userLogin, required Map headers, }) async { final uri = Uri.parse('https://api.twitch.tv/helix/streams?user_login=$userLogin'); final response = await _client.get(uri, headers: headers); if (response.statusCode == 200) { final decoded = jsonDecode(response.body); final data = decoded['data'] as List; if (data.isNotEmpty) { return StreamTwitch.fromJson(data.first); } else { return Future.error('$userLogin is offline'); } } else { return Future.error('Failed to get stream info'); } } /// Returns a [UserTwitch] object containing the user info associated with the given [userLogin]. Future getUser({ String? userLogin, String? id, required Map headers, }) async { final url = Uri.parse( id != null ? 'https://api.twitch.tv/helix/users?id=$id' : 'https://api.twitch.tv/helix/users?login=$userLogin'); final response = await _client.get(url, headers: headers); final decoded = jsonDecode(response.body); if (response.statusCode == 200) { final userData = decoded['data'] as List; if (userData.isNotEmpty) { return UserTwitch.fromJson(userData.first); } else { return Future.error('User does not exist'); } } else { return Future.error('Failed to get user: ${decoded['message']}'); } } /// Returns a [Channel] object containing a channels's info associated with the given [userId]. Future getChannel({ required String userId, required Map headers, }) async { final url = Uri.parse('https://api.twitch.tv/helix/channels?broadcaster_id=$userId'); final response = await _client.get(url, headers: headers); final decoded = jsonDecode(response.body); if (response.statusCode == 200) { final channelData = decoded['data'] as List; if (channelData.isNotEmpty) { return Channel.fromJson(channelData.first); } else { return Future.error('Channel does not exist'); } } else { return Future.error('Failed to get channel: ${decoded['message']}'); } } /// Returns a list of [ChannelQuery] objects closest matching the given [query]. Future> searchChannels({ required String query, required Map headers, }) async { final url = Uri.parse('https://api.twitch.tv/helix/search/channels?first=8&query=$query'); final response = await _client.get(url, headers: headers); if (response.statusCode == 200) { final channelData = jsonDecode(response.body)['data'] as List; return channelData.map((e) => ChannelQuery.fromJson(e)).toList(); } else { return Future.error('Failed to get channels'); } } /// Returns a [CategoriesTwitch] object containing the next top 20 categories/games and a cursor for further requests. Future getTopCategories({ required Map headers, String? cursor, }) async { final url = Uri.parse(cursor == null ? 'https://api.twitch.tv/helix/games/top' : 'https://api.twitch.tv/helix/games/top?after=$cursor'); final response = await _client.get(url, headers: headers); final decoded = jsonDecode(response.body); if (response.statusCode == 200) { return CategoriesTwitch.fromJson(decoded); } else { return Future.error('Failed to get top categories: ${decoded['message']}'); } } /// Returns a [CategoriesTwitch] object containing the category info corresponding to the provided [gameId]. Future getCategory({ required Map headers, required String gameId, }) async { final url = Uri.parse('https://api.twitch.tv/helix/games?id=$gameId'); final response = await _client.get(url, headers: headers); final decoded = jsonDecode(response.body); if (response.statusCode == 200) { return CategoriesTwitch.fromJson(decoded); } else { return Future.error('Failed to get category: ${decoded['message']}'); } } /// Returns a [CategoriesTwitch] containing up to 20 categories/games closest matching the [query] and a cursor for further requests. Future searchCategories({ required Map headers, required String query, String? cursor, }) async { final url = Uri.parse(cursor == null ? 'https://api.twitch.tv/helix/search/categories?first=8&query=$query' : 'https://api.twitch.tv/helix/search/categories?first=8&query=$query&after=$cursor'); final response = await _client.get(url, headers: headers); if (response.statusCode == 200) { final decoded = jsonDecode(response.body); return CategoriesTwitch.fromJson(decoded); } else { return Future.error('Failed to get categories'); } } /// Returns the sub count associated with the given [userId]. Future getSubscriberCount({ required String userId, required Map headers, }) async { final uri = Uri.parse('https://api.twitch.tv/helix/subscriptions?broadcaster_id=$userId'); final response = await _client.get(uri, headers: headers); if (response.statusCode == 200) { final decoded = jsonDecode(response.body); return decoded['total'] as int; } else { return Future.error('Failed to get sub count'); } } /// Returns a [ChatUsers] object containing the names of chatters in the given [userLogin]'s chat. Future getChatters({required String userLogin}) async { final uri = Uri.parse('https://tmi.twitch.tv/group/user/$userLogin/chatters'); final response = await _client.get(uri); if (response.statusCode == 200) { final decoded = jsonDecode(response.body); return ChatUsers.fromJson(decoded); } else { return Future.error('Failed to get chatters'); } } /// Returns a user's list of blocked users given their id. Future> getUserBlockedList({ required String id, required Map headers, String? cursor, }) async { final url = Uri.parse(cursor == null ? 'https://api.twitch.tv/helix/users/blocks?first=100&broadcaster_id=$id' : 'https://api.twitch.tv/helix/users/blocks?first=100&broadcaster_id=$id&after=$cursor'); final response = await _client.get(url, headers: headers); if (response.statusCode == 200) { final decoded = jsonDecode(response.body); final cursor = decoded['pagination']['cursor']; final blockedList = decoded['data'] as List; if (blockedList.isNotEmpty) { final result = blockedList.map((e) => UserBlockedTwitch.fromJson(e)).toList(); if (cursor != null) { // Wait a bit (150 milliseconds) before recursively calling. // This will prevent going over the rate limit to due a massive blocked users list. // // With the Twitch API, we can make up to 800 requests per minute. // Waiting 150 milliseconds between requests will cap the rate here at 400 requests per minute. await Future.delayed(const Duration(milliseconds: 150)); result.addAll(await getUserBlockedList(id: id, cursor: cursor, headers: headers)); } return result; } else { debugPrint('User does not have anyone blocked'); return []; } } else { return Future.error('User does not exist'); } } // Blocks the user with the given ID and returns true on success or false on failure. Future blockUser({required String userId, required Map headers}) async { final url = Uri.parse('https://api.twitch.tv/helix/users/blocks?target_user_id=$userId'); final response = await _client.put(url, headers: headers); if (response.statusCode == 204) { return true; } else { return false; } } // Unblocks the user with the given ID and returns true on success or false on failure. Future unblockUser({required String userId, required Map headers}) async { final url = Uri.parse('https://api.twitch.tv/helix/users/blocks?target_user_id=$userId'); final response = await _client.delete(url, headers: headers); if (response.statusCode == 204) { return true; } else { return false; } } }