Initial commit: Xtream IPTV Player for Android TV
This commit is contained in:
1
.flutter-plugins-dependencies
Normal file
1
.flutter-plugins-dependencies
Normal file
File diff suppressed because one or more lines are too long
10
.gitignore
vendored
Normal file
10
.gitignore
vendored
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
*.iml
|
||||||
|
.gradle
|
||||||
|
local.properties
|
||||||
|
.dart_tool
|
||||||
|
.packages
|
||||||
|
build/
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.lock
|
||||||
|
pubspec.lock
|
||||||
30
.metadata
Normal file
30
.metadata
Normal 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
17
README.md
Normal 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
28
analysis_options.yaml
Normal 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
14
android/.gitignore
vendored
Normal 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
|
||||||
38
android/app/build.gradle.kts
Normal file
38
android/app/build.gradle.kts
Normal 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 = "../.."
|
||||||
|
}
|
||||||
7
android/app/src/debug/AndroidManifest.xml
Normal file
7
android/app/src/debug/AndroidManifest.xml
Normal 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>
|
||||||
38
android/app/src/main/AndroidManifest.xml
Normal file
38
android/app/src/main/AndroidManifest.xml
Normal 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>
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
package com.xstream.xstream_tv
|
||||||
|
|
||||||
|
import io.flutter.embedding.android.FlutterActivity
|
||||||
|
|
||||||
|
class MainActivity : FlutterActivity()
|
||||||
12
android/app/src/main/res/drawable-v21/launch_background.xml
Normal file
12
android/app/src/main/res/drawable-v21/launch_background.xml
Normal 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>
|
||||||
12
android/app/src/main/res/drawable/launch_background.xml
Normal file
12
android/app/src/main/res/drawable/launch_background.xml
Normal 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>
|
||||||
BIN
android/app/src/main/res/mipmap-hdpi/ic_launcher.png
Normal file
BIN
android/app/src/main/res/mipmap-hdpi/ic_launcher.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 544 B |
BIN
android/app/src/main/res/mipmap-mdpi/ic_launcher.png
Normal file
BIN
android/app/src/main/res/mipmap-mdpi/ic_launcher.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 442 B |
BIN
android/app/src/main/res/mipmap-xhdpi/ic_launcher.png
Normal file
BIN
android/app/src/main/res/mipmap-xhdpi/ic_launcher.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 721 B |
BIN
android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png
Normal file
BIN
android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.0 KiB |
BIN
android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
Normal file
BIN
android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.4 KiB |
18
android/app/src/main/res/values-night/styles.xml
Normal file
18
android/app/src/main/res/values-night/styles.xml
Normal 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>
|
||||||
18
android/app/src/main/res/values/styles.xml
Normal file
18
android/app/src/main/res/values/styles.xml
Normal 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>
|
||||||
7
android/app/src/profile/AndroidManifest.xml
Normal file
7
android/app/src/profile/AndroidManifest.xml
Normal 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
24
android/build.gradle.kts
Normal 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)
|
||||||
|
}
|
||||||
2
android/gradle.properties
Normal file
2
android/gradle.properties
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
|
||||||
|
android.useAndroidX=true
|
||||||
5
android/gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
5
android/gradle/wrapper/gradle-wrapper.properties
vendored
Normal 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
|
||||||
26
android/settings.gradle.kts
Normal file
26
android/settings.gradle.kts
Normal 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
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';
|
||||||
|
}
|
||||||
|
}
|
||||||
25
pubspec.yaml
Normal file
25
pubspec.yaml
Normal 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
30
test/widget_test.dart
Normal 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);
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user