Initial commit: Xtream IPTV Player for Android TV

This commit is contained in:
2026-02-25 13:01:25 -03:00
commit dd61066b3a
33 changed files with 1707 additions and 0 deletions

View File

@@ -0,0 +1,403 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../services/iptv_provider.dart';
import '../models/xtream_models.dart';
import 'player_screen.dart';
class HomeScreen extends StatefulWidget {
const HomeScreen({super.key});
@override
State<HomeScreen> createState() => _HomeScreenState();
}
class _HomeScreenState extends State<HomeScreen> {
int _selectedTab = 0;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
_loadInitialData();
});
}
Future<void> _loadInitialData() async {
final provider = context.read<IPTVProvider>();
await provider.loadLiveStreams();
await provider.loadVodStreams();
await provider.loadSeries();
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.black,
appBar: AppBar(
backgroundColor: Colors.red,
title: Consumer<IPTVProvider>(
builder: (context, provider, _) {
return Text(
provider.userInfo != null
? 'XStream TV - ${provider.userInfo!.username}'
: 'XStream TV',
);
},
),
actions: [
IconButton(
icon: const Icon(Icons.refresh),
onPressed: _loadInitialData,
),
IconButton(
icon: const Icon(Icons.logout),
onPressed: () {
context.read<IPTVProvider>().logout();
},
),
],
),
body: Row(
children: [
NavigationRail(
selectedIndex: _selectedTab,
onDestinationSelected: (index) {
setState(() => _selectedTab = index);
},
backgroundColor: Colors.grey[900],
selectedIconTheme: const IconThemeData(color: Colors.red),
unselectedIconTheme: const IconThemeData(color: Colors.grey),
labelType: NavigationRailLabelType.all,
destinations: const [
NavigationRailDestination(
icon: Icon(Icons.live_tv),
selectedIcon: Icon(Icons.live_tv, color: Colors.red),
label: Text('Live', style: TextStyle(color: Colors.white)),
),
NavigationRailDestination(
icon: Icon(Icons.movie),
selectedIcon: Icon(Icons.movie, color: Colors.red),
label: Text('Movies', style: TextStyle(color: Colors.white)),
),
NavigationRailDestination(
icon: Icon(Icons.tv),
selectedIcon: Icon(Icons.tv, color: Colors.red),
label: Text('Series', style: TextStyle(color: Colors.white)),
),
],
),
Expanded(
child: _buildContent(),
),
],
),
);
}
Widget _buildContent() {
switch (_selectedTab) {
case 0:
return _LiveTab();
case 1:
return _MoviesTab();
case 2:
return _SeriesTab();
default:
return _LiveTab();
}
}
}
class _LiveTab extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Consumer<IPTVProvider>(
builder: (context, provider, _) {
if (provider.isLoading) {
return const Center(child: CircularProgressIndicator(color: Colors.red));
}
return Column(
children: [
_CategoryDropdown(
categories: provider.liveCategories,
selectedCategory: provider.selectedLiveCategory,
onCategorySelected: (categoryId) {
provider.loadLiveStreams(categoryId);
},
),
Expanded(
child: _StreamList(
streams: provider.liveStreams,
onStreamSelected: (stream) {
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => PlayerScreen(stream: stream),
),
);
},
),
),
],
);
},
);
}
}
class _MoviesTab extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Consumer<IPTVProvider>(
builder: (context, provider, _) {
if (provider.isLoading) {
return const Center(child: CircularProgressIndicator(color: Colors.red));
}
return Column(
children: [
_CategoryDropdown(
categories: provider.vodCategories,
selectedCategory: provider.selectedVodCategory,
onCategorySelected: (categoryId) {
provider.loadVodStreams(categoryId);
},
),
Expanded(
child: _StreamList(
streams: provider.vodStreams,
onStreamSelected: (stream) {
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => PlayerScreen(stream: stream),
),
);
},
),
),
],
);
},
);
}
}
class _SeriesTab extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Consumer<IPTVProvider>(
builder: (context, provider, _) {
if (provider.isLoading) {
return const Center(child: CircularProgressIndicator(color: Colors.red));
}
if (provider.selectedSeries != null) {
return _SeriesDetailScreen(series: provider.selectedSeries!);
}
return _StreamList(
streams: provider.seriesList.map((s) => XtreamStream(
streamId: s.seriesId,
name: s.name,
streamIcon: s.cover,
plot: s.plot,
rating: s.rating,
)).toList(),
isSeries: true,
onStreamSelected: (stream) {
final series = provider.seriesList.firstWhere(
(s) => s.seriesId == stream.streamId,
);
provider.loadSeriesEpisodes(series);
},
);
},
);
}
}
class _SeriesDetailScreen extends StatelessWidget {
final XtreamSeries series;
const _SeriesDetailScreen({required this.series});
@override
Widget build(BuildContext context) {
return Consumer<IPTVProvider>(
builder: (context, provider, _) {
final episodes = provider.seriesEpisodes;
return Column(
children: [
Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
IconButton(
icon: const Icon(Icons.arrow_back, color: Colors.white),
onPressed: () {
provider.loadSeries();
},
),
Expanded(
child: Text(
series.name,
style: const TextStyle(
color: Colors.white,
fontSize: 20,
fontWeight: FontWeight.bold,
),
),
),
],
),
),
Expanded(
child: ListView.builder(
itemCount: episodes.length,
itemBuilder: (context, index) {
final episode = episodes[index];
return ListTile(
leading: const Icon(Icons.play_circle_outline, color: Colors.red),
title: Text(
'S${episode.seasonNumber}E${episode.episodeNumber} - ${episode.title}',
style: const TextStyle(color: Colors.white),
),
onTap: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => PlayerScreen(
stream: XtreamStream(
streamId: episode.episodeId,
name: episode.title,
containerExtension: episode.containerExtension,
url: episode.url,
),
isLive: false,
),
),
);
},
);
},
),
),
],
);
},
);
}
}
class _CategoryDropdown extends StatelessWidget {
final List<XtreamCategory> categories;
final String selectedCategory;
final Function(String) onCategorySelected;
const _CategoryDropdown({
required this.categories,
required this.selectedCategory,
required this.onCategorySelected,
});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(16),
color: Colors.grey[900],
child: DropdownButton<String>(
value: selectedCategory.isEmpty ? null : selectedCategory,
hint: const Text('All Categories', style: TextStyle(color: Colors.white)),
dropdownColor: Colors.grey[800],
isExpanded: true,
items: [
const DropdownMenuItem<String>(
value: '',
child: Text('All Categories', style: TextStyle(color: Colors.white)),
),
...categories.map((c) => DropdownMenuItem<String>(
value: c.id,
child: Text(c.name, style: const TextStyle(color: Colors.white)),
)),
],
onChanged: (value) {
onCategorySelected(value ?? '');
},
),
);
}
}
class _StreamList extends StatelessWidget {
final List<XtreamStream> streams;
final Function(XtreamStream) onStreamSelected;
final bool isSeries;
const _StreamList({
required this.streams,
required this.onStreamSelected,
this.isSeries = false,
});
@override
Widget build(BuildContext context) {
if (streams.isEmpty) {
return const Center(
child: Text(
'No content available',
style: TextStyle(color: Colors.grey),
),
);
}
return ListView.builder(
itemCount: streams.length,
itemBuilder: (context, index) {
final stream = streams[index];
return ListTile(
leading: isSeries && stream.streamIcon != null
? ClipRRect(
borderRadius: BorderRadius.circular(4),
child: Image.network(
stream.streamIcon!,
width: 40,
height: 60,
fit: BoxFit.cover,
errorBuilder: (_, __, ___) => const Icon(
Icons.tv,
color: Colors.red,
size: 40,
),
),
)
: Icon(
isSeries ? Icons.tv : Icons.play_circle_outline,
color: Colors.red,
size: 40,
),
title: Text(
stream.name,
style: const TextStyle(color: Colors.white),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
subtitle: stream.rating != null
? Row(
children: [
const Icon(Icons.star, color: Colors.amber, size: 14),
Text(
stream.rating ?? '',
style: const TextStyle(color: Colors.amber),
),
],
)
: null,
onTap: () => onStreamSelected(stream),
);
},
);
}
}

View File

@@ -0,0 +1,183 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../services/iptv_provider.dart';
class LoginScreen extends StatefulWidget {
const LoginScreen({super.key});
@override
State<LoginScreen> createState() => _LoginScreenState();
}
class _LoginScreenState extends State<LoginScreen> {
final _serverController = TextEditingController();
final _usernameController = TextEditingController();
final _passwordController = TextEditingController();
bool _obscurePassword = true;
@override
void dispose() {
_serverController.dispose();
_usernameController.dispose();
_passwordController.dispose();
super.dispose();
}
Future<void> _login() async {
final provider = context.read<IPTVProvider>();
await provider.login(
_serverController.text.trim(),
_usernameController.text.trim(),
_passwordController.text.trim(),
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.black,
body: Center(
child: Container(
constraints: const BoxConstraints(maxWidth: 400),
padding: const EdgeInsets.all(24),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(
Icons.live_tv,
size: 80,
color: Colors.red,
),
const SizedBox(height: 24),
const Text(
'XStream TV',
style: TextStyle(
fontSize: 32,
fontWeight: FontWeight.bold,
color: Colors.white,
),
),
const SizedBox(height: 8),
const Text(
'IPTV Player for Android TV',
style: TextStyle(
fontSize: 14,
color: Colors.grey,
),
),
const SizedBox(height: 48),
TextField(
controller: _serverController,
style: const TextStyle(color: Colors.white),
decoration: InputDecoration(
labelText: 'Server URL',
labelStyle: const TextStyle(color: Colors.grey),
hintText: 'http://example.com',
hintStyle: const TextStyle(color: Colors.grey),
prefixIcon: const Icon(Icons.dns, color: Colors.grey),
filled: true,
fillColor: Colors.grey[900],
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide.none,
),
),
),
const SizedBox(height: 16),
TextField(
controller: _usernameController,
style: const TextStyle(color: Colors.white),
decoration: InputDecoration(
labelText: 'Username',
labelStyle: const TextStyle(color: Colors.grey),
prefixIcon: const Icon(Icons.person, color: Colors.grey),
filled: true,
fillColor: Colors.grey[900],
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide.none,
),
),
),
const SizedBox(height: 16),
TextField(
controller: _passwordController,
obscureText: _obscurePassword,
style: const TextStyle(color: Colors.white),
decoration: InputDecoration(
labelText: 'Password',
labelStyle: const TextStyle(color: Colors.grey),
prefixIcon: const Icon(Icons.lock, color: Colors.grey),
suffixIcon: IconButton(
icon: Icon(
_obscurePassword ? Icons.visibility : Icons.visibility_off,
color: Colors.grey,
),
onPressed: () {
setState(() {
_obscurePassword = !_obscurePassword;
});
},
),
filled: true,
fillColor: Colors.grey[900],
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide.none,
),
),
),
const SizedBox(height: 24),
Consumer<IPTVProvider>(
builder: (context, provider, _) {
if (provider.error != null) {
return Padding(
padding: const EdgeInsets.only(bottom: 16),
child: Text(
provider.error!,
style: const TextStyle(color: Colors.red),
textAlign: TextAlign.center,
),
);
}
return const SizedBox.shrink();
},
),
SizedBox(
width: double.infinity,
height: 50,
child: Consumer<IPTVProvider>(
builder: (context, provider, _) {
return ElevatedButton(
onPressed: provider.isLoading ? null : _login,
style: ElevatedButton.styleFrom(
backgroundColor: Colors.red,
foregroundColor: Colors.white,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
child: provider.isLoading
? const SizedBox(
width: 24,
height: 24,
child: CircularProgressIndicator(
color: Colors.white,
strokeWidth: 2,
),
)
: const Text(
'Login',
style: TextStyle(fontSize: 16),
),
);
},
),
),
],
),
),
),
);
}
}

View File

@@ -0,0 +1,159 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:video_player/video_player.dart';
import 'package:chewie/chewie.dart';
import '../models/xtream_models.dart';
class PlayerScreen extends StatefulWidget {
final XtreamStream stream;
final bool isLive;
const PlayerScreen({
super.key,
required this.stream,
this.isLive = true,
});
@override
State<PlayerScreen> createState() => _PlayerScreenState();
}
class _PlayerScreenState extends State<PlayerScreen> {
late VideoPlayerController _videoController;
ChewieController? _chewieController;
bool _isLoading = true;
String? _error;
@override
void initState() {
super.initState();
_initPlayer();
}
Future<void> _initPlayer() async {
try {
final url = widget.stream.url;
if (url == null || url.isEmpty) {
throw Exception('No stream URL available');
}
_videoController = VideoPlayerController.networkUrl(Uri.parse(url));
await _videoController.initialize();
_chewieController = ChewieController(
videoPlayerController: _videoController,
autoPlay: true,
looping: widget.isLive,
aspectRatio: _videoController.value.aspectRatio,
allowFullScreen: true,
allowMuting: true,
showControls: true,
placeholder: Container(
color: Colors.black,
child: const Center(
child: CircularProgressIndicator(color: Colors.red),
),
),
errorBuilder: (context, errorMessage) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.error, color: Colors.red, size: 48),
const SizedBox(height: 16),
Text(
errorMessage,
style: const TextStyle(color: Colors.white),
textAlign: TextAlign.center,
),
],
),
);
},
);
setState(() {
_isLoading = false;
});
_videoController.addListener(() {
setState(() {});
});
} catch (e) {
setState(() {
_error = e.toString();
_isLoading = false;
});
}
}
@override
void dispose() {
_videoController.dispose();
_chewieController?.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.black,
appBar: AppBar(
backgroundColor: Colors.transparent,
elevation: 0,
title: Text(
widget.stream.name,
style: const TextStyle(color: Colors.white),
),
iconTheme: const IconThemeData(color: Colors.white),
),
body: Center(
child: _isLoading
? const CircularProgressIndicator(color: Colors.red)
: _error != null
? _buildError()
: _chewieController != null
? Chewie(controller: _chewieController!)
: const Text(
'No video available',
style: TextStyle(color: Colors.white),
),
),
);
}
Widget _buildError() {
return Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.error_outline, color: Colors.red, size: 64),
const SizedBox(height: 16),
Padding(
padding: const EdgeInsets.all(16),
child: Text(
_error ?? 'Unknown error',
style: const TextStyle(color: Colors.white),
textAlign: TextAlign.center,
),
),
const SizedBox(height: 24),
ElevatedButton.icon(
onPressed: () {
setState(() {
_isLoading = true;
_error = null;
});
_initPlayer();
},
icon: const Icon(Icons.refresh),
label: const Text('Retry'),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.red,
foregroundColor: Colors.white,
),
),
],
);
}
}