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

File diff suppressed because one or more lines are too long

10
.gitignore vendored Normal file
View File

@@ -0,0 +1,10 @@
*.iml
.gradle
local.properties
.dart_tool
.packages
build/
.idea/
.vscode/
*.lock
pubspec.lock

30
.metadata Normal file
View File

@@ -0,0 +1,30 @@
# This file tracks properties of this Flutter project.
# Used by Flutter tool to assess capabilities and perform upgrades etc.
#
# This file should be version controlled and should not be manually edited.
version:
revision: "90673a4eef275d1a6692c26ac80d6d746d41a73a"
channel: "stable"
project_type: app
# Tracks metadata for the flutter migrate command
migration:
platforms:
- platform: root
create_revision: 90673a4eef275d1a6692c26ac80d6d746d41a73a
base_revision: 90673a4eef275d1a6692c26ac80d6d746d41a73a
- platform: android
create_revision: 90673a4eef275d1a6692c26ac80d6d746d41a73a
base_revision: 90673a4eef275d1a6692c26ac80d6d746d41a73a
# User provided section
# List of Local paths (relative to this file) that should be
# ignored by the migrate tool.
#
# Files that are not part of the templates will be ignored by default.
unmanaged_files:
- 'lib/main.dart'
- 'ios/Runner.xcodeproj/project.pbxproj'

17
README.md Normal file
View File

@@ -0,0 +1,17 @@
# xstream_tv
A new Flutter project.
## Getting Started
This project is a starting point for a Flutter application.
A few resources to get you started if this is your first Flutter project:
- [Learn Flutter](https://docs.flutter.dev/get-started/learn-flutter)
- [Write your first Flutter app](https://docs.flutter.dev/get-started/codelab)
- [Flutter learning resources](https://docs.flutter.dev/reference/learning-resources)
For help getting started with Flutter development, view the
[online documentation](https://docs.flutter.dev/), which offers tutorials,
samples, guidance on mobile development, and a full API reference.

28
analysis_options.yaml Normal file
View File

@@ -0,0 +1,28 @@
# This file configures the analyzer, which statically analyzes Dart code to
# check for errors, warnings, and lints.
#
# The issues identified by the analyzer are surfaced in the UI of Dart-enabled
# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be
# invoked from the command line by running `flutter analyze`.
# The following line activates a set of recommended lints for Flutter apps,
# packages, and plugins designed to encourage good coding practices.
include: package:flutter_lints/flutter.yaml
linter:
# The lint rules applied to this project can be customized in the
# section below to disable rules from the `package:flutter_lints/flutter.yaml`
# included above or to enable additional rules. A list of all available lints
# and their documentation is published at https://dart.dev/lints.
#
# Instead of disabling a lint rule for the entire project in the
# section below, it can also be suppressed for a single line of code
# or a specific dart file by using the `// ignore: name_of_lint` and
# `// ignore_for_file: name_of_lint` syntax on the line or in the file
# producing the lint.
rules:
# avoid_print: false # Uncomment to disable the `avoid_print` rule
# prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule
# Additional information about this file can be found at
# https://dart.dev/guides/language/analysis-options

14
android/.gitignore vendored Normal file
View File

@@ -0,0 +1,14 @@
gradle-wrapper.jar
/.gradle
/captures/
/gradlew
/gradlew.bat
/local.properties
GeneratedPluginRegistrant.java
.cxx/
# Remember to never publicly share your keystore.
# See https://flutter.dev/to/reference-keystore
key.properties
**/*.keystore
**/*.jks

View File

@@ -0,0 +1,38 @@
plugins {
id("com.android.application")
id("kotlin-android")
id("dev.flutter.flutter-gradle-plugin")
}
android {
namespace = "com.xstream.xstream_tv"
compileSdk = flutter.compileSdkVersion
ndkVersion = flutter.ndkVersion
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = JavaVersion.VERSION_17.toString()
}
defaultConfig {
applicationId = "com.xstream.xstream_tv"
minSdk = flutter.minSdkVersion
targetSdk = flutter.targetSdkVersion
versionCode = flutter.versionCode
versionName = flutter.versionName
}
buildTypes {
release {
signingConfig = signingConfigs.getByName("debug")
}
}
}
flutter {
source = "../.."
}

View File

@@ -0,0 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- The INTERNET permission is required for development. Specifically,
the Flutter tool needs it to communicate with the running application
to allow setting breakpoints, to provide hot reload, etc.
-->
<uses-permission android:name="android.permission.INTERNET"/>
</manifest>

View File

@@ -0,0 +1,38 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
<application
android:label="XStream TV"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher"
android:usesCleartextTraffic="true">
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTop"
android:taskAffinity=""
android:theme="@style/LaunchTheme"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true"
android:windowSoftInputMode="adjustResize">
<meta-data
android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme"
/>
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity>
<meta-data
android:name="flutterEmbedding"
android:value="2" />
</application>
<queries>
<intent>
<action android:name="android.intent.action.PROCESS_TEXT"/>
<data android:mimeType="text/plain"/>
</intent>
</queries>
</manifest>

View File

@@ -0,0 +1,5 @@
package com.xstream.xstream_tv
import io.flutter.embedding.android.FlutterActivity
class MainActivity : FlutterActivity()

View File

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Modify this file to customize your launch splash screen -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="?android:colorBackground" />
<!-- You can insert your own image assets here -->
<!-- <item>
<bitmap
android:gravity="center"
android:src="@mipmap/launch_image" />
</item> -->
</layer-list>

View File

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Modify this file to customize your launch splash screen -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@android:color/white" />
<!-- You can insert your own image assets here -->
<!-- <item>
<bitmap
android:gravity="center"
android:src="@mipmap/launch_image" />
</item> -->
</layer-list>

Binary file not shown.

After

Width:  |  Height:  |  Size: 544 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 442 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 721 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is on -->
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
<!-- Show a splash screen on the activity. Automatically removed when
the Flutter engine draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
Flutter UI initializes, as well as behind your Flutter UI while its
running.
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style>
</resources>

View File

@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is off -->
<style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
<!-- Show a splash screen on the activity. Automatically removed when
the Flutter engine draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
Flutter UI initializes, as well as behind your Flutter UI while its
running.
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style>
</resources>

View File

@@ -0,0 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- The INTERNET permission is required for development. Specifically,
the Flutter tool needs it to communicate with the running application
to allow setting breakpoints, to provide hot reload, etc.
-->
<uses-permission android:name="android.permission.INTERNET"/>
</manifest>

24
android/build.gradle.kts Normal file
View File

@@ -0,0 +1,24 @@
allprojects {
repositories {
google()
mavenCentral()
}
}
val newBuildDir: Directory =
rootProject.layout.buildDirectory
.dir("../../build")
.get()
rootProject.layout.buildDirectory.value(newBuildDir)
subprojects {
val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name)
project.layout.buildDirectory.value(newSubprojectBuildDir)
}
subprojects {
project.evaluationDependsOn(":app")
}
tasks.register<Delete>("clean") {
delete(rootProject.layout.buildDirectory)
}

View File

@@ -0,0 +1,2 @@
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
android.useAndroidX=true

View File

@@ -0,0 +1,5 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-all.zip

View File

@@ -0,0 +1,26 @@
pluginManagement {
val flutterSdkPath =
run {
val properties = java.util.Properties()
file("local.properties").inputStream().use { properties.load(it) }
val flutterSdkPath = properties.getProperty("flutter.sdk")
require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" }
flutterSdkPath
}
includeBuild("$flutterSdkPath/packages/flutter_tools/gradle")
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
plugins {
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
id("com.android.application") version "8.11.1" apply false
id("org.jetbrains.kotlin.android") version "2.2.20" apply false
}
include(":app")

102
lib/main.dart Normal file
View 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();
}
}

View 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'] ?? '',
);
}
}

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,
),
),
],
);
}
}

View 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();
}
}

View 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';
}
}

25
pubspec.yaml Normal file
View File

@@ -0,0 +1,25 @@
name: xstream_tv
description: "Xtream IPTV Player for Android TV"
publish_to: 'none'
version: 1.0.0+1
environment:
sdk: ^3.11.0
dependencies:
flutter:
sdk: flutter
cupertino_icons: ^1.0.8
http: ^1.2.0
video_player: ^2.9.3
chewie: ^1.10.0
shared_preferences: ^2.3.5
provider: ^6.1.2
dev_dependencies:
flutter_test:
sdk: flutter
flutter_lints: ^6.0.0
flutter:
uses-material-design: true

30
test/widget_test.dart Normal file
View File

@@ -0,0 +1,30 @@
// This is a basic Flutter widget test.
//
// To perform an interaction with a widget in your test, use the WidgetTester
// utility in the flutter_test package. For example, you can send tap and scroll
// gestures. You can also use WidgetTester to find child widgets in the widget
// tree, read text, and verify that the values of widget properties are correct.
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:xstream_tv/main.dart';
void main() {
testWidgets('Counter increments smoke test', (WidgetTester tester) async {
// Build our app and trigger a frame.
await tester.pumpWidget(const MyApp());
// Verify that our counter starts at 0.
expect(find.text('0'), findsOneWidget);
expect(find.text('1'), findsNothing);
// Tap the '+' icon and trigger a frame.
await tester.tap(find.byIcon(Icons.add));
await tester.pump();
// Verify that our counter has incremented.
expect(find.text('0'), findsNothing);
expect(find.text('1'), findsOneWidget);
});
}