commit dd61066b3ab2815cb12e41737c2365062eee86e9 Author: renato97 Date: Wed Feb 25 13:01:25 2026 -0300 Initial commit: Xtream IPTV Player for Android TV diff --git a/.flutter-plugins-dependencies b/.flutter-plugins-dependencies new file mode 100644 index 0000000..7f80b09 --- /dev/null +++ b/.flutter-plugins-dependencies @@ -0,0 +1 @@ +{"info":"This is a generated file; do not edit or check into version control.","plugins":{"ios":[{"name":"package_info_plus","path":"/home/ren/.pub-cache/hosted/pub.dev/package_info_plus-9.0.0/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"shared_preferences_foundation","path":"/home/ren/.pub-cache/hosted/pub.dev/shared_preferences_foundation-2.5.6/","shared_darwin_source":true,"native_build":true,"dependencies":[],"dev_dependency":false},{"name":"video_player_avfoundation","path":"/home/ren/.pub-cache/hosted/pub.dev/video_player_avfoundation-2.9.3/","shared_darwin_source":true,"native_build":true,"dependencies":[],"dev_dependency":false},{"name":"wakelock_plus","path":"/home/ren/.pub-cache/hosted/pub.dev/wakelock_plus-1.4.0/","native_build":true,"dependencies":["package_info_plus"],"dev_dependency":false}],"android":[{"name":"package_info_plus","path":"/home/ren/.pub-cache/hosted/pub.dev/package_info_plus-9.0.0/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"shared_preferences_android","path":"/home/ren/.pub-cache/hosted/pub.dev/shared_preferences_android-2.4.20/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"video_player_android","path":"/home/ren/.pub-cache/hosted/pub.dev/video_player_android-2.9.4/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"wakelock_plus","path":"/home/ren/.pub-cache/hosted/pub.dev/wakelock_plus-1.4.0/","native_build":true,"dependencies":["package_info_plus"],"dev_dependency":false}],"macos":[{"name":"package_info_plus","path":"/home/ren/.pub-cache/hosted/pub.dev/package_info_plus-9.0.0/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"shared_preferences_foundation","path":"/home/ren/.pub-cache/hosted/pub.dev/shared_preferences_foundation-2.5.6/","shared_darwin_source":true,"native_build":true,"dependencies":[],"dev_dependency":false},{"name":"video_player_avfoundation","path":"/home/ren/.pub-cache/hosted/pub.dev/video_player_avfoundation-2.9.3/","shared_darwin_source":true,"native_build":true,"dependencies":[],"dev_dependency":false},{"name":"wakelock_plus","path":"/home/ren/.pub-cache/hosted/pub.dev/wakelock_plus-1.4.0/","native_build":true,"dependencies":["package_info_plus"],"dev_dependency":false}],"linux":[{"name":"package_info_plus","path":"/home/ren/.pub-cache/hosted/pub.dev/package_info_plus-9.0.0/","native_build":false,"dependencies":[],"dev_dependency":false},{"name":"path_provider_linux","path":"/home/ren/.pub-cache/hosted/pub.dev/path_provider_linux-2.2.1/","native_build":false,"dependencies":[],"dev_dependency":false},{"name":"shared_preferences_linux","path":"/home/ren/.pub-cache/hosted/pub.dev/shared_preferences_linux-2.4.1/","native_build":false,"dependencies":["path_provider_linux"],"dev_dependency":false},{"name":"wakelock_plus","path":"/home/ren/.pub-cache/hosted/pub.dev/wakelock_plus-1.4.0/","native_build":false,"dependencies":["package_info_plus"],"dev_dependency":false}],"windows":[{"name":"package_info_plus","path":"/home/ren/.pub-cache/hosted/pub.dev/package_info_plus-9.0.0/","native_build":false,"dependencies":[],"dev_dependency":false},{"name":"path_provider_windows","path":"/home/ren/.pub-cache/hosted/pub.dev/path_provider_windows-2.3.0/","native_build":false,"dependencies":[],"dev_dependency":false},{"name":"shared_preferences_windows","path":"/home/ren/.pub-cache/hosted/pub.dev/shared_preferences_windows-2.4.1/","native_build":false,"dependencies":["path_provider_windows"],"dev_dependency":false},{"name":"wakelock_plus","path":"/home/ren/.pub-cache/hosted/pub.dev/wakelock_plus-1.4.0/","native_build":false,"dependencies":["package_info_plus"],"dev_dependency":false}],"web":[{"name":"package_info_plus","path":"/home/ren/.pub-cache/hosted/pub.dev/package_info_plus-9.0.0/","dependencies":[],"dev_dependency":false},{"name":"shared_preferences_web","path":"/home/ren/.pub-cache/hosted/pub.dev/shared_preferences_web-2.4.3/","dependencies":[],"dev_dependency":false},{"name":"video_player_web","path":"/home/ren/.pub-cache/hosted/pub.dev/video_player_web-2.4.0/","dependencies":[],"dev_dependency":false},{"name":"wakelock_plus","path":"/home/ren/.pub-cache/hosted/pub.dev/wakelock_plus-1.4.0/","dependencies":["package_info_plus"],"dev_dependency":false}]},"dependencyGraph":[{"name":"package_info_plus","dependencies":[]},{"name":"path_provider_linux","dependencies":[]},{"name":"path_provider_windows","dependencies":[]},{"name":"shared_preferences","dependencies":["shared_preferences_android","shared_preferences_foundation","shared_preferences_linux","shared_preferences_web","shared_preferences_windows"]},{"name":"shared_preferences_android","dependencies":[]},{"name":"shared_preferences_foundation","dependencies":[]},{"name":"shared_preferences_linux","dependencies":["path_provider_linux"]},{"name":"shared_preferences_web","dependencies":[]},{"name":"shared_preferences_windows","dependencies":["path_provider_windows"]},{"name":"video_player","dependencies":["video_player_android","video_player_avfoundation","video_player_web"]},{"name":"video_player_android","dependencies":[]},{"name":"video_player_avfoundation","dependencies":[]},{"name":"video_player_web","dependencies":[]},{"name":"wakelock_plus","dependencies":["package_info_plus"]}],"date_created":"2026-02-25 12:49:06.890896","version":"3.41.2","swift_package_manager_enabled":{"ios":false,"macos":false}} \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ef19d0b --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +*.iml +.gradle +local.properties +.dart_tool +.packages +build/ +.idea/ +.vscode/ +*.lock +pubspec.lock diff --git a/.metadata b/.metadata new file mode 100644 index 0000000..05a325e --- /dev/null +++ b/.metadata @@ -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' diff --git a/README.md b/README.md new file mode 100644 index 0000000..5d3e40e --- /dev/null +++ b/README.md @@ -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. diff --git a/analysis_options.yaml b/analysis_options.yaml new file mode 100644 index 0000000..0d29021 --- /dev/null +++ b/analysis_options.yaml @@ -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 diff --git a/android/.gitignore b/android/.gitignore new file mode 100644 index 0000000..be3943c --- /dev/null +++ b/android/.gitignore @@ -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 diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts new file mode 100644 index 0000000..b8707ea --- /dev/null +++ b/android/app/build.gradle.kts @@ -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 = "../.." +} diff --git a/android/app/src/debug/AndroidManifest.xml b/android/app/src/debug/AndroidManifest.xml new file mode 100644 index 0000000..399f698 --- /dev/null +++ b/android/app/src/debug/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..55dcf45 --- /dev/null +++ b/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/kotlin/com/xstream/xstream_tv/MainActivity.kt b/android/app/src/main/kotlin/com/xstream/xstream_tv/MainActivity.kt new file mode 100644 index 0000000..3d02be8 --- /dev/null +++ b/android/app/src/main/kotlin/com/xstream/xstream_tv/MainActivity.kt @@ -0,0 +1,5 @@ +package com.xstream.xstream_tv + +import io.flutter.embedding.android.FlutterActivity + +class MainActivity : FlutterActivity() diff --git a/android/app/src/main/res/drawable-v21/launch_background.xml b/android/app/src/main/res/drawable-v21/launch_background.xml new file mode 100644 index 0000000..f74085f --- /dev/null +++ b/android/app/src/main/res/drawable-v21/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/android/app/src/main/res/drawable/launch_background.xml b/android/app/src/main/res/drawable/launch_background.xml new file mode 100644 index 0000000..304732f --- /dev/null +++ b/android/app/src/main/res/drawable/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000..db77bb4 Binary files /dev/null and b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000..17987b7 Binary files /dev/null and b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000..09d4391 Binary files /dev/null and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000..d5f1c8d Binary files /dev/null and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000..4d6372e Binary files /dev/null and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/values-night/styles.xml b/android/app/src/main/res/values-night/styles.xml new file mode 100644 index 0000000..06952be --- /dev/null +++ b/android/app/src/main/res/values-night/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/android/app/src/main/res/values/styles.xml b/android/app/src/main/res/values/styles.xml new file mode 100644 index 0000000..cb1ef88 --- /dev/null +++ b/android/app/src/main/res/values/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/android/app/src/profile/AndroidManifest.xml b/android/app/src/profile/AndroidManifest.xml new file mode 100644 index 0000000..399f698 --- /dev/null +++ b/android/app/src/profile/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/android/build.gradle.kts b/android/build.gradle.kts new file mode 100644 index 0000000..dbee657 --- /dev/null +++ b/android/build.gradle.kts @@ -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("clean") { + delete(rootProject.layout.buildDirectory) +} diff --git a/android/gradle.properties b/android/gradle.properties new file mode 100644 index 0000000..fbee1d8 --- /dev/null +++ b/android/gradle.properties @@ -0,0 +1,2 @@ +org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError +android.useAndroidX=true diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..e4ef43f --- /dev/null +++ b/android/gradle/wrapper/gradle-wrapper.properties @@ -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 diff --git a/android/settings.gradle.kts b/android/settings.gradle.kts new file mode 100644 index 0000000..ca7fe06 --- /dev/null +++ b/android/settings.gradle.kts @@ -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") diff --git a/lib/main.dart b/lib/main.dart new file mode 100644 index 0000000..3737078 --- /dev/null +++ b/lib/main.dart @@ -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 createState() => _AuthWrapperState(); +} + +class _AuthWrapperState extends State { + bool _isChecking = true; + + @override + void initState() { + super.initState(); + _checkAuth(); + } + + Future _checkAuth() async { + final provider = context.read(); + + // 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(); + + if (provider.userInfo != null) { + return const HomeScreen(); + } + + return const LoginScreen(); + } +} diff --git a/lib/models/xtream_models.dart b/lib/models/xtream_models.dart new file mode 100644 index 0000000..fa14308 --- /dev/null +++ b/lib/models/xtream_models.dart @@ -0,0 +1,158 @@ +class XtreamCategory { + final String id; + final String name; + + XtreamCategory({required this.id, required this.name}); + + factory XtreamCategory.fromJson(Map 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 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 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 seasons; + + XtreamSeries({ + required this.seriesId, + required this.name, + this.cover, + this.plot, + this.rating, + this.seasons = const [], + }); + + factory XtreamSeries.fromJson(Map 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 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 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 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'] ?? '', + ); + } +} diff --git a/lib/screens/home_screen.dart b/lib/screens/home_screen.dart new file mode 100644 index 0000000..cb2e9bc --- /dev/null +++ b/lib/screens/home_screen.dart @@ -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 createState() => _HomeScreenState(); +} + +class _HomeScreenState extends State { + int _selectedTab = 0; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) { + _loadInitialData(); + }); + } + + Future _loadInitialData() async { + final provider = context.read(); + 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( + 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().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( + 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( + 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( + 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( + 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 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( + value: selectedCategory.isEmpty ? null : selectedCategory, + hint: const Text('All Categories', style: TextStyle(color: Colors.white)), + dropdownColor: Colors.grey[800], + isExpanded: true, + items: [ + const DropdownMenuItem( + value: '', + child: Text('All Categories', style: TextStyle(color: Colors.white)), + ), + ...categories.map((c) => DropdownMenuItem( + value: c.id, + child: Text(c.name, style: const TextStyle(color: Colors.white)), + )), + ], + onChanged: (value) { + onCategorySelected(value ?? ''); + }, + ), + ); + } +} + +class _StreamList extends StatelessWidget { + final List 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), + ); + }, + ); + } +} diff --git a/lib/screens/login_screen.dart b/lib/screens/login_screen.dart new file mode 100644 index 0000000..294d21c --- /dev/null +++ b/lib/screens/login_screen.dart @@ -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 createState() => _LoginScreenState(); +} + +class _LoginScreenState extends State { + final _serverController = TextEditingController(); + final _usernameController = TextEditingController(); + final _passwordController = TextEditingController(); + bool _obscurePassword = true; + + @override + void dispose() { + _serverController.dispose(); + _usernameController.dispose(); + _passwordController.dispose(); + super.dispose(); + } + + Future _login() async { + final provider = context.read(); + 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( + 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( + 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), + ), + ); + }, + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/screens/player_screen.dart b/lib/screens/player_screen.dart new file mode 100644 index 0000000..6b549b1 --- /dev/null +++ b/lib/screens/player_screen.dart @@ -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 createState() => _PlayerScreenState(); +} + +class _PlayerScreenState extends State { + late VideoPlayerController _videoController; + ChewieController? _chewieController; + bool _isLoading = true; + String? _error; + + @override + void initState() { + super.initState(); + _initPlayer(); + } + + Future _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, + ), + ), + ], + ); + } +} diff --git a/lib/services/iptv_provider.dart b/lib/services/iptv_provider.dart new file mode 100644 index 0000000..4e80428 --- /dev/null +++ b/lib/services/iptv_provider.dart @@ -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 _liveCategories = []; + List _vodCategories = []; + List _seriesCategories = []; + + List _liveStreams = []; + List _vodStreams = []; + List _seriesList = []; + List _seriesEpisodes = []; + + String _selectedLiveCategory = ''; + String _selectedVodCategory = ''; + XtreamSeries? _selectedSeries; + + bool get isLoading => _isLoading; + String? get error => _error; + XtreamUserInfo? get userInfo => _userInfo; + XtreamApiService get api => _api; + + List get liveCategories => _liveCategories; + List get vodCategories => _vodCategories; + List get seriesCategories => _seriesCategories; + + List get liveStreams => _liveStreams; + List get vodStreams => _vodStreams; + List get seriesList => _seriesList; + List get seriesEpisodes => _seriesEpisodes; + + String get selectedLiveCategory => _selectedLiveCategory; + String get selectedVodCategory => _selectedVodCategory; + XtreamSeries? get selectedSeries => _selectedSeries; + + Future 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 _loadCategories() async { + try { + _liveCategories = await _api.getLiveCategories(); + _vodCategories = await _api.getVodCategories(); + _seriesCategories = await _api.getSeriesCategories(); + } catch (e) { + _error = e.toString(); + } + } + + Future loadLiveStreams([String categoryId = '']) async { + _isLoading = true; + notifyListeners(); + + try { + _liveStreams = await _api.getLiveStreams(categoryId); + _selectedLiveCategory = categoryId; + } catch (e) { + _error = e.toString(); + } + + _isLoading = false; + notifyListeners(); + } + + Future loadVodStreams([String categoryId = '']) async { + _isLoading = true; + notifyListeners(); + + try { + _vodStreams = await _api.getVodStreams(categoryId); + _selectedVodCategory = categoryId; + } catch (e) { + _error = e.toString(); + } + + _isLoading = false; + notifyListeners(); + } + + Future loadSeries() async { + _isLoading = true; + notifyListeners(); + + try { + _seriesList = await _api.getSeries(); + } catch (e) { + _error = e.toString(); + } + + _isLoading = false; + notifyListeners(); + } + + Future 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 _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 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 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(); + } +} diff --git a/lib/services/xtream_api.dart b/lib/services/xtream_api.dart new file mode 100644 index 0000000..c7fca0c --- /dev/null +++ b/lib/services/xtream_api.dart @@ -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> 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 getUserInfo() async { + final data = await authenticate(); + return XtreamUserInfo.fromJson(data); + } + + Future> 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 data = json.decode(response.body); + return data.map((e) => XtreamCategory.fromJson(e)).toList(); + } + throw Exception('Failed to load categories'); + } + + Future> 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 data = json.decode(response.body); + return data.map((e) => XtreamCategory.fromJson(e)).toList(); + } + throw Exception('Failed to load VOD categories'); + } + + Future> 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 data = json.decode(response.body); + return data.map((e) => XtreamCategory.fromJson(e)).toList(); + } + throw Exception('Failed to load series categories'); + } + + Future> 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 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> 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 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> 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 data = json.decode(response.body); + return data.map((e) => XtreamSeries.fromJson(e)).toList(); + } + throw Exception('Failed to load series'); + } + + Future> 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 episodesData = data['episodes'] ?? []; + + final List allEpisodes = []; + for (final seasonData in episodesData) { + final season = seasonData['season_number'] ?? 0; + final List 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'; + } +} diff --git a/pubspec.yaml b/pubspec.yaml new file mode 100644 index 0000000..97ac73b --- /dev/null +++ b/pubspec.yaml @@ -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 diff --git a/test/widget_test.dart b/test/widget_test.dart new file mode 100644 index 0000000..3893338 --- /dev/null +++ b/test/widget_test.dart @@ -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); + }); +}