Initial commit: Xtream IPTV Player for Android TV
This commit is contained in:
102
lib/main.dart
Normal file
102
lib/main.dart
Normal file
@@ -0,0 +1,102 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'services/iptv_provider.dart';
|
||||
import 'screens/login_screen.dart';
|
||||
import 'screens/home_screen.dart';
|
||||
|
||||
void main() {
|
||||
runApp(const XStreamTVApp());
|
||||
}
|
||||
|
||||
class XStreamTVApp extends StatelessWidget {
|
||||
const XStreamTVApp({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ChangeNotifierProvider(
|
||||
create: (_) => IPTVProvider(),
|
||||
child: MaterialApp(
|
||||
title: 'XStream TV',
|
||||
debugShowCheckedModeBanner: false,
|
||||
theme: ThemeData(
|
||||
brightness: Brightness.dark,
|
||||
primarySwatch: Colors.red,
|
||||
scaffoldBackgroundColor: Colors.black,
|
||||
appBarTheme: const AppBarTheme(
|
||||
backgroundColor: Colors.red,
|
||||
foregroundColor: Colors.white,
|
||||
),
|
||||
),
|
||||
home: const AuthWrapper(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class AuthWrapper extends StatefulWidget {
|
||||
const AuthWrapper({super.key});
|
||||
|
||||
@override
|
||||
State<AuthWrapper> createState() => _AuthWrapperState();
|
||||
}
|
||||
|
||||
class _AuthWrapperState extends State<AuthWrapper> {
|
||||
bool _isChecking = true;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_checkAuth();
|
||||
}
|
||||
|
||||
Future<void> _checkAuth() async {
|
||||
final provider = context.read<IPTVProvider>();
|
||||
|
||||
// Auto-login with embedded credentials
|
||||
await provider.login(
|
||||
'http://kenmhzxn.fqvpnw.com',
|
||||
'55UDKCFH',
|
||||
'6ZNP8Y81',
|
||||
);
|
||||
|
||||
setState(() {
|
||||
_isChecking = false;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (_isChecking) {
|
||||
return const Scaffold(
|
||||
backgroundColor: Colors.black,
|
||||
body: Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(Icons.live_tv, size: 80, color: Colors.red),
|
||||
SizedBox(height: 24),
|
||||
Text(
|
||||
'XStream TV',
|
||||
style: TextStyle(
|
||||
fontSize: 32,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
SizedBox(height: 24),
|
||||
CircularProgressIndicator(color: Colors.red),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final provider = context.watch<IPTVProvider>();
|
||||
|
||||
if (provider.userInfo != null) {
|
||||
return const HomeScreen();
|
||||
}
|
||||
|
||||
return const LoginScreen();
|
||||
}
|
||||
}
|
||||
158
lib/models/xtream_models.dart
Normal file
158
lib/models/xtream_models.dart
Normal file
@@ -0,0 +1,158 @@
|
||||
class XtreamCategory {
|
||||
final String id;
|
||||
final String name;
|
||||
|
||||
XtreamCategory({required this.id, required this.name});
|
||||
|
||||
factory XtreamCategory.fromJson(Map<String, dynamic> json) {
|
||||
return XtreamCategory(
|
||||
id: json['category_id']?.toString() ?? '',
|
||||
name: json['category_name'] ?? '',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class XtreamStream {
|
||||
final int streamId;
|
||||
final String name;
|
||||
final String? streamIcon;
|
||||
final String? plot;
|
||||
final String? rating;
|
||||
final String? containerExtension;
|
||||
String? url;
|
||||
|
||||
XtreamStream({
|
||||
required this.streamId,
|
||||
required this.name,
|
||||
this.streamIcon,
|
||||
this.plot,
|
||||
this.rating,
|
||||
this.containerExtension,
|
||||
this.url,
|
||||
});
|
||||
|
||||
factory XtreamStream.fromJson(Map<String, dynamic> json) {
|
||||
return XtreamStream(
|
||||
streamId: json['stream_id'] ?? 0,
|
||||
name: json['name'] ?? '',
|
||||
streamIcon: json['stream_icon'],
|
||||
plot: json['plot'],
|
||||
rating: json['rating'],
|
||||
containerExtension: json['container_extension'],
|
||||
url: null,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'stream_id': streamId,
|
||||
'name': name,
|
||||
'stream_icon': streamIcon,
|
||||
'plot': plot,
|
||||
'rating': rating,
|
||||
'container_extension': containerExtension,
|
||||
'url': url,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
class XtreamSeries {
|
||||
final int seriesId;
|
||||
final String name;
|
||||
final String? cover;
|
||||
final String? plot;
|
||||
final String? rating;
|
||||
final List<XtreamSeason> seasons;
|
||||
|
||||
XtreamSeries({
|
||||
required this.seriesId,
|
||||
required this.name,
|
||||
this.cover,
|
||||
this.plot,
|
||||
this.rating,
|
||||
this.seasons = const [],
|
||||
});
|
||||
|
||||
factory XtreamSeries.fromJson(Map<String, dynamic> json) {
|
||||
return XtreamSeries(
|
||||
seriesId: json['series_id'] ?? 0,
|
||||
name: json['name'] ?? '',
|
||||
cover: json['cover'],
|
||||
plot: json['plot'],
|
||||
rating: json['rating'],
|
||||
seasons: [],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class XtreamSeason {
|
||||
final int seasonNumber;
|
||||
final List<XtreamEpisode> episodes;
|
||||
|
||||
XtreamSeason({required this.seasonNumber, this.episodes = const []});
|
||||
}
|
||||
|
||||
class XtreamEpisode {
|
||||
final int episodeId;
|
||||
final int seasonNumber;
|
||||
final int episodeNumber;
|
||||
final String title;
|
||||
final String? info;
|
||||
final String? containerExtension;
|
||||
String? url;
|
||||
|
||||
XtreamEpisode({
|
||||
required this.episodeId,
|
||||
required this.seasonNumber,
|
||||
required this.episodeNumber,
|
||||
required this.title,
|
||||
this.info,
|
||||
this.containerExtension,
|
||||
this.url,
|
||||
});
|
||||
|
||||
factory XtreamEpisode.fromJson(Map<String, dynamic> json) {
|
||||
return XtreamEpisode(
|
||||
episodeId: json['id'] ?? 0,
|
||||
seasonNumber: json['season'] ?? 0,
|
||||
episodeNumber: json['episode_num'] ?? 0,
|
||||
title: json['title'] ?? '',
|
||||
info: json['info'],
|
||||
containerExtension: json['container_extension'],
|
||||
url: null,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class XtreamUserInfo {
|
||||
final String username;
|
||||
final String password;
|
||||
final int maxConnections;
|
||||
final int activeCons;
|
||||
final bool isTrial;
|
||||
final int? expDate;
|
||||
final String status;
|
||||
|
||||
XtreamUserInfo({
|
||||
required this.username,
|
||||
required this.password,
|
||||
required this.maxConnections,
|
||||
required this.activeCons,
|
||||
required this.isTrial,
|
||||
this.expDate,
|
||||
required this.status,
|
||||
});
|
||||
|
||||
factory XtreamUserInfo.fromJson(Map<String, dynamic> json) {
|
||||
final userInfo = json['user_info'] ?? json;
|
||||
return XtreamUserInfo(
|
||||
username: userInfo['username'] ?? '',
|
||||
password: userInfo['password'] ?? '',
|
||||
maxConnections: int.tryParse(userInfo['max_connections']?.toString() ?? '1') ?? 1,
|
||||
activeCons: int.tryParse(userInfo['active_cons']?.toString() ?? '0') ?? 0,
|
||||
isTrial: userInfo['is_trial'] == '1',
|
||||
expDate: userInfo['exp_date'],
|
||||
status: userInfo['status'] ?? '',
|
||||
);
|
||||
}
|
||||
}
|
||||
403
lib/screens/home_screen.dart
Normal file
403
lib/screens/home_screen.dart
Normal 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),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
183
lib/screens/login_screen.dart
Normal file
183
lib/screens/login_screen.dart
Normal 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),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
159
lib/screens/player_screen.dart
Normal file
159
lib/screens/player_screen.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
174
lib/services/iptv_provider.dart
Normal file
174
lib/services/iptv_provider.dart
Normal file
@@ -0,0 +1,174 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import '../services/xtream_api.dart';
|
||||
import '../models/xtream_models.dart';
|
||||
|
||||
enum ContentType { live, movies, series }
|
||||
|
||||
class IPTVProvider extends ChangeNotifier {
|
||||
final XtreamApiService _api = XtreamApiService();
|
||||
|
||||
bool _isLoading = false;
|
||||
String? _error;
|
||||
XtreamUserInfo? _userInfo;
|
||||
|
||||
List<XtreamCategory> _liveCategories = [];
|
||||
List<XtreamCategory> _vodCategories = [];
|
||||
List<XtreamCategory> _seriesCategories = [];
|
||||
|
||||
List<XtreamStream> _liveStreams = [];
|
||||
List<XtreamStream> _vodStreams = [];
|
||||
List<XtreamSeries> _seriesList = [];
|
||||
List<XtreamEpisode> _seriesEpisodes = [];
|
||||
|
||||
String _selectedLiveCategory = '';
|
||||
String _selectedVodCategory = '';
|
||||
XtreamSeries? _selectedSeries;
|
||||
|
||||
bool get isLoading => _isLoading;
|
||||
String? get error => _error;
|
||||
XtreamUserInfo? get userInfo => _userInfo;
|
||||
XtreamApiService get api => _api;
|
||||
|
||||
List<XtreamCategory> get liveCategories => _liveCategories;
|
||||
List<XtreamCategory> get vodCategories => _vodCategories;
|
||||
List<XtreamCategory> get seriesCategories => _seriesCategories;
|
||||
|
||||
List<XtreamStream> get liveStreams => _liveStreams;
|
||||
List<XtreamStream> get vodStreams => _vodStreams;
|
||||
List<XtreamSeries> get seriesList => _seriesList;
|
||||
List<XtreamEpisode> get seriesEpisodes => _seriesEpisodes;
|
||||
|
||||
String get selectedLiveCategory => _selectedLiveCategory;
|
||||
String get selectedVodCategory => _selectedVodCategory;
|
||||
XtreamSeries? get selectedSeries => _selectedSeries;
|
||||
|
||||
Future<void> login(String server, String username, String password) async {
|
||||
_isLoading = true;
|
||||
_error = null;
|
||||
notifyListeners();
|
||||
|
||||
try {
|
||||
_api.setCredentials(server, username, password);
|
||||
_userInfo = await _api.getUserInfo();
|
||||
|
||||
await _loadCategories();
|
||||
await _saveCredentials(server, username, password);
|
||||
} catch (e) {
|
||||
_error = e.toString();
|
||||
}
|
||||
|
||||
_isLoading = false;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> _loadCategories() async {
|
||||
try {
|
||||
_liveCategories = await _api.getLiveCategories();
|
||||
_vodCategories = await _api.getVodCategories();
|
||||
_seriesCategories = await _api.getSeriesCategories();
|
||||
} catch (e) {
|
||||
_error = e.toString();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> loadLiveStreams([String categoryId = '']) async {
|
||||
_isLoading = true;
|
||||
notifyListeners();
|
||||
|
||||
try {
|
||||
_liveStreams = await _api.getLiveStreams(categoryId);
|
||||
_selectedLiveCategory = categoryId;
|
||||
} catch (e) {
|
||||
_error = e.toString();
|
||||
}
|
||||
|
||||
_isLoading = false;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> loadVodStreams([String categoryId = '']) async {
|
||||
_isLoading = true;
|
||||
notifyListeners();
|
||||
|
||||
try {
|
||||
_vodStreams = await _api.getVodStreams(categoryId);
|
||||
_selectedVodCategory = categoryId;
|
||||
} catch (e) {
|
||||
_error = e.toString();
|
||||
}
|
||||
|
||||
_isLoading = false;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> loadSeries() async {
|
||||
_isLoading = true;
|
||||
notifyListeners();
|
||||
|
||||
try {
|
||||
_seriesList = await _api.getSeries();
|
||||
} catch (e) {
|
||||
_error = e.toString();
|
||||
}
|
||||
|
||||
_isLoading = false;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> loadSeriesEpisodes(XtreamSeries series) async {
|
||||
_isLoading = true;
|
||||
notifyListeners();
|
||||
|
||||
try {
|
||||
_selectedSeries = series;
|
||||
_seriesEpisodes = await _api.getSeriesEpisodes(series.seriesId);
|
||||
} catch (e) {
|
||||
_error = e.toString();
|
||||
}
|
||||
|
||||
_isLoading = false;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void clearError() {
|
||||
_error = null;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> _saveCredentials(String server, String username, String password) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString('server', server);
|
||||
await prefs.setString('username', username);
|
||||
await prefs.setString('password', password);
|
||||
}
|
||||
|
||||
Future<bool> loadSavedCredentials() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final server = prefs.getString('server');
|
||||
final username = prefs.getString('username');
|
||||
final password = prefs.getString('password');
|
||||
|
||||
if (server != null && username != null && password != null) {
|
||||
await login(server, username, password);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
Future<void> logout() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.remove('server');
|
||||
await prefs.remove('username');
|
||||
await prefs.remove('password');
|
||||
|
||||
_userInfo = null;
|
||||
_liveCategories = [];
|
||||
_vodCategories = [];
|
||||
_seriesCategories = [];
|
||||
_liveStreams = [];
|
||||
_vodStreams = [];
|
||||
_seriesList = [];
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
161
lib/services/xtream_api.dart
Normal file
161
lib/services/xtream_api.dart
Normal file
@@ -0,0 +1,161 @@
|
||||
import 'dart:convert';
|
||||
import 'package:http/http.dart' as http;
|
||||
import '../models/xtream_models.dart';
|
||||
|
||||
class XtreamApiService {
|
||||
String? _server;
|
||||
String? _username;
|
||||
String? _password;
|
||||
String? _baseUrl;
|
||||
|
||||
void setCredentials(String server, String username, String password) {
|
||||
_server = server;
|
||||
_username = username;
|
||||
_password = password;
|
||||
_baseUrl = server.startsWith('http') ? server : 'http://$server';
|
||||
}
|
||||
|
||||
String? get server => _server;
|
||||
String? get username => _username;
|
||||
|
||||
Future<Map<String, dynamic>> authenticate() async {
|
||||
final url = '$_baseUrl/player_api.php';
|
||||
final response = await http.get(
|
||||
Uri.parse('$url?username=$_username&password=$_password'),
|
||||
);
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
return json.decode(response.body);
|
||||
}
|
||||
throw Exception('Authentication failed: ${response.statusCode}');
|
||||
}
|
||||
|
||||
Future<XtreamUserInfo> getUserInfo() async {
|
||||
final data = await authenticate();
|
||||
return XtreamUserInfo.fromJson(data);
|
||||
}
|
||||
|
||||
Future<List<XtreamCategory>> getLiveCategories() async {
|
||||
final url = '$_baseUrl/player_api.php';
|
||||
final response = await http.get(
|
||||
Uri.parse('$url?username=$_username&password=$_password&action=get_live_categories'),
|
||||
);
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
final List<dynamic> data = json.decode(response.body);
|
||||
return data.map((e) => XtreamCategory.fromJson(e)).toList();
|
||||
}
|
||||
throw Exception('Failed to load categories');
|
||||
}
|
||||
|
||||
Future<List<XtreamCategory>> getVodCategories() async {
|
||||
final url = '$_baseUrl/player_api.php';
|
||||
final response = await http.get(
|
||||
Uri.parse('$url?username=$_username&password=$_password&action=get_vod_categories'),
|
||||
);
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
final List<dynamic> data = json.decode(response.body);
|
||||
return data.map((e) => XtreamCategory.fromJson(e)).toList();
|
||||
}
|
||||
throw Exception('Failed to load VOD categories');
|
||||
}
|
||||
|
||||
Future<List<XtreamCategory>> getSeriesCategories() async {
|
||||
final url = '$_baseUrl/player_api.php';
|
||||
final response = await http.get(
|
||||
Uri.parse('$url?username=$_username&password=$_password&action=get_series_categories'),
|
||||
);
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
final List<dynamic> data = json.decode(response.body);
|
||||
return data.map((e) => XtreamCategory.fromJson(e)).toList();
|
||||
}
|
||||
throw Exception('Failed to load series categories');
|
||||
}
|
||||
|
||||
Future<List<XtreamStream>> getLiveStreams(String categoryId) async {
|
||||
final url = '$_baseUrl/player_api.php';
|
||||
String apiUrl = '$url?username=$_username&password=$_password&action=get_live_streams';
|
||||
if (categoryId.isNotEmpty) {
|
||||
apiUrl += '&category_id=$categoryId';
|
||||
}
|
||||
|
||||
final response = await http.get(Uri.parse(apiUrl));
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
final List<dynamic> data = json.decode(response.body);
|
||||
return data.map((e) {
|
||||
final stream = XtreamStream.fromJson(e);
|
||||
stream.url = '$_baseUrl/live/$_username/$_password/${stream.streamId}.ts';
|
||||
return stream;
|
||||
}).toList();
|
||||
}
|
||||
throw Exception('Failed to load live streams');
|
||||
}
|
||||
|
||||
Future<List<XtreamStream>> getVodStreams(String categoryId) async {
|
||||
final url = '$_baseUrl/player_api.php';
|
||||
String apiUrl = '$url?username=$_username&password=$_password&action=get_vod_streams';
|
||||
if (categoryId.isNotEmpty) {
|
||||
apiUrl += '&category_id=$categoryId';
|
||||
}
|
||||
|
||||
final response = await http.get(Uri.parse(apiUrl));
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
final List<dynamic> data = json.decode(response.body);
|
||||
return data.map((e) {
|
||||
final stream = XtreamStream.fromJson(e);
|
||||
final ext = stream.containerExtension ?? 'm3u8';
|
||||
stream.url = '$_baseUrl/vod/$_username/$_password/${stream.streamId}.$ext';
|
||||
return stream;
|
||||
}).toList();
|
||||
}
|
||||
throw Exception('Failed to load VOD streams');
|
||||
}
|
||||
|
||||
Future<List<XtreamSeries>> getSeries() async {
|
||||
final url = '$_baseUrl/player_api.php';
|
||||
final response = await http.get(
|
||||
Uri.parse('$url?username=$_username&password=$_password&action=get_series'),
|
||||
);
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
final List<dynamic> data = json.decode(response.body);
|
||||
return data.map((e) => XtreamSeries.fromJson(e)).toList();
|
||||
}
|
||||
throw Exception('Failed to load series');
|
||||
}
|
||||
|
||||
Future<List<XtreamEpisode>> getSeriesEpisodes(int seriesId) async {
|
||||
final url = '$_baseUrl/player_api.php';
|
||||
final response = await http.get(
|
||||
Uri.parse('$url?username=$_username&password=$_password&action=get_series_info&series_id=$seriesId'),
|
||||
);
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
final data = json.decode(response.body);
|
||||
final List<dynamic> episodesData = data['episodes'] ?? [];
|
||||
|
||||
final List<XtreamEpisode> allEpisodes = [];
|
||||
for (final seasonData in episodesData) {
|
||||
final season = seasonData['season_number'] ?? 0;
|
||||
final List<dynamic> episodes = seasonData['episodes'] ?? [];
|
||||
for (final ep in episodes) {
|
||||
final episode = XtreamEpisode.fromJson(ep);
|
||||
final ext = episode.containerExtension ?? 'm3u8';
|
||||
episode.url = '$_baseUrl/series/$_username/$_password/${episode.episodeId}.$ext';
|
||||
allEpisodes.add(episode);
|
||||
}
|
||||
}
|
||||
return allEpisodes;
|
||||
}
|
||||
throw Exception('Failed to load series episodes');
|
||||
}
|
||||
|
||||
String getStreamUrl(int streamId, {String type = 'live'}) {
|
||||
final ext = type == 'live' ? 'ts' : 'm3u8';
|
||||
return '$_baseUrl/$type/$_username/$_password/$streamId.$ext';
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user