From dd61066b3ab2815cb12e41737c2365062eee86e9 Mon Sep 17 00:00:00 2001 From: renato97 Date: Wed, 25 Feb 2026 13:01:25 -0300 Subject: [PATCH] Initial commit: Xtream IPTV Player for Android TV --- .flutter-plugins-dependencies | 1 + .gitignore | 10 + .metadata | 30 ++ README.md | 17 + analysis_options.yaml | 28 ++ android/.gitignore | 14 + android/app/build.gradle.kts | 38 ++ android/app/src/debug/AndroidManifest.xml | 7 + android/app/src/main/AndroidManifest.xml | 38 ++ .../com/xstream/xstream_tv/MainActivity.kt | 5 + .../res/drawable-v21/launch_background.xml | 12 + .../main/res/drawable/launch_background.xml | 12 + .../src/main/res/mipmap-hdpi/ic_launcher.png | Bin 0 -> 544 bytes .../src/main/res/mipmap-mdpi/ic_launcher.png | Bin 0 -> 442 bytes .../src/main/res/mipmap-xhdpi/ic_launcher.png | Bin 0 -> 721 bytes .../main/res/mipmap-xxhdpi/ic_launcher.png | Bin 0 -> 1031 bytes .../main/res/mipmap-xxxhdpi/ic_launcher.png | Bin 0 -> 1443 bytes .../app/src/main/res/values-night/styles.xml | 18 + android/app/src/main/res/values/styles.xml | 18 + android/app/src/profile/AndroidManifest.xml | 7 + android/build.gradle.kts | 24 ++ android/gradle.properties | 2 + .../gradle/wrapper/gradle-wrapper.properties | 5 + android/settings.gradle.kts | 26 ++ lib/main.dart | 102 +++++ lib/models/xtream_models.dart | 158 +++++++ lib/screens/home_screen.dart | 403 ++++++++++++++++++ lib/screens/login_screen.dart | 183 ++++++++ lib/screens/player_screen.dart | 159 +++++++ lib/services/iptv_provider.dart | 174 ++++++++ lib/services/xtream_api.dart | 161 +++++++ pubspec.yaml | 25 ++ test/widget_test.dart | 30 ++ 33 files changed, 1707 insertions(+) create mode 100644 .flutter-plugins-dependencies create mode 100644 .gitignore create mode 100644 .metadata create mode 100644 README.md create mode 100644 analysis_options.yaml create mode 100644 android/.gitignore create mode 100644 android/app/build.gradle.kts create mode 100644 android/app/src/debug/AndroidManifest.xml create mode 100644 android/app/src/main/AndroidManifest.xml create mode 100644 android/app/src/main/kotlin/com/xstream/xstream_tv/MainActivity.kt create mode 100644 android/app/src/main/res/drawable-v21/launch_background.xml create mode 100644 android/app/src/main/res/drawable/launch_background.xml create mode 100644 android/app/src/main/res/mipmap-hdpi/ic_launcher.png create mode 100644 android/app/src/main/res/mipmap-mdpi/ic_launcher.png create mode 100644 android/app/src/main/res/mipmap-xhdpi/ic_launcher.png create mode 100644 android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png create mode 100644 android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png create mode 100644 android/app/src/main/res/values-night/styles.xml create mode 100644 android/app/src/main/res/values/styles.xml create mode 100644 android/app/src/profile/AndroidManifest.xml create mode 100644 android/build.gradle.kts create mode 100644 android/gradle.properties create mode 100644 android/gradle/wrapper/gradle-wrapper.properties create mode 100644 android/settings.gradle.kts create mode 100644 lib/main.dart create mode 100644 lib/models/xtream_models.dart create mode 100644 lib/screens/home_screen.dart create mode 100644 lib/screens/login_screen.dart create mode 100644 lib/screens/player_screen.dart create mode 100644 lib/services/iptv_provider.dart create mode 100644 lib/services/xtream_api.dart create mode 100644 pubspec.yaml create mode 100644 test/widget_test.dart 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 0000000000000000000000000000000000000000..db77bb4b7b0906d62b1847e87f15cdcacf6a4f29 GIT binary patch literal 544 zcmeAS@N?(olHy`uVBq!ia0vp^9w5xY3?!3`olAj~WQl7;NpOBzNqJ&XDuZK6ep0G} zXKrG8YEWuoN@d~6R2!h8bpbvhu0Wd6uZuB!w&u2PAxD2eNXD>P5D~Wn-+_Wa#27Xc zC?Zj|6r#X(-D3u$NCt}(Ms06KgJ4FxJVv{GM)!I~&n8Bnc94O7-Hd)cjDZswgC;Qs zO=b+9!WcT8F?0rF7!Uys2bs@gozCP?z~o%U|N3vA*22NaGQG zlg@K`O_XuxvZ&Ks^m&R!`&1=spLvfx7oGDKDwpwW`#iqdw@AL`7MR}m`rwr|mZgU`8P7SBkL78fFf!WnuYWm$5Z0 zNXhDbCv&49sM544K|?c)WrFfiZvCi9h0O)B3Pgg&ebxsLQ05GG~ AQ2+n{ literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..17987b79bb8a35cc66c3c1fd44f5a5526c1b78be GIT binary patch literal 442 zcmeAS@N?(olHy`uVBq!ia0vp^1|ZDA3?vioaBc-sk|nMYCBgY=CFO}lsSJ)O`AMk? zp1FzXsX?iUDV2pMQ*D5Xx&nMcT!A!W`0S9QKQy;}1Cl^CgaH=;G9cpY;r$Q>i*pfB zP2drbID<_#qf;rPZx^FqH)F_D#*k@@q03KywUtLX8Ua?`H+NMzkczFPK3lFz@i_kW%1NOn0|D2I9n9wzH8m|-tHjsw|9>@K=iMBhxvkv6m8Y-l zytQ?X=U+MF$@3 zt`~i=@j|6y)RWMK--}M|=T`o&^Ni>IoWKHEbBXz7?A@mgWoL>!*SXo`SZH-*HSdS+ yn*9;$7;m`l>wYBC5bq;=U}IMqLzqbYCidGC!)_gkIk_C@Uy!y&wkt5C($~2D>~)O*cj@FGjOCM)M>_ixfudOh)?xMu#Fs z#}Y=@YDTwOM)x{K_j*Q;dPdJ?Mz0n|pLRx{4n|)f>SXlmV)XB04CrSJn#dS5nK2lM zrZ9#~WelCp7&e13Y$jvaEXHskn$2V!!DN-nWS__6T*l;H&Fopn?A6HZ-6WRLFP=R` zqG+CE#d4|IbyAI+rJJ`&x9*T`+a=p|0O(+s{UBcyZdkhj=yS1>AirP+0R;mf2uMgM zC}@~JfByORAh4SyRgi&!(cja>F(l*O+nd+@4m$|6K6KDn_&uvCpV23&>G9HJp{xgg zoq1^2_p9@|WEo z*X_Uko@K)qYYv~>43eQGMdbiGbo>E~Q& zrYBH{QP^@Sti!`2)uG{irBBq@y*$B zi#&(U-*=fp74j)RyIw49+0MRPMRU)+a2r*PJ$L5roHt2$UjExCTZSbq%V!HeS7J$N zdG@vOZB4v_lF7Plrx+hxo7(fCV&}fHq)$ literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..d5f1c8d34e7a88e3f88bea192c3a370d44689c3c GIT binary patch literal 1031 zcmeAS@N?(olHy`uVBq!ia0vp^6F``Q8Ax83A=Cw=BuiW)N`mv#O3D+9QW+dm@{>{( zJaZG%Q-e|yQz{EjrrIztFa`(sgt!6~Yi|1%a`XoT0ojZ}lNrNjb9xjc(B0U1_% zz5^97Xt*%oq$rQy4?0GKNfJ44uvxI)gC`h-NZ|&0-7(qS@?b!5r36oQ}zyZrNO3 zMO=Or+<~>+A&uN&E!^Sl+>xE!QC-|oJv`ApDhqC^EWD|@=#J`=d#Xzxs4ah}w&Jnc z$|q_opQ^2TrnVZ0o~wh<3t%W&flvYGe#$xqda2bR_R zvPYgMcHgjZ5nSA^lJr%;<&0do;O^tDDh~=pIxA#coaCY>&N%M2^tq^U%3DB@ynvKo}b?yu-bFc-u0JHzced$sg7S3zqI(2 z#Km{dPr7I=pQ5>FuK#)QwK?Y`E`B?nP+}U)I#c1+FM*1kNvWG|a(TpksZQ3B@sD~b zpQ2)*V*TdwjFOtHvV|;OsiDqHi=6%)o4b!)x$)%9pGTsE z-JL={-Ffv+T87W(Xpooq<`r*VzWQcgBN$$`u}f>-ZQI1BB8ykN*=e4rIsJx9>z}*o zo~|9I;xof literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..4d6372eebdb28e45604e46eeda8dd24651419bc0 GIT binary patch literal 1443 zcmb`G{WsKk6vsdJTdFg%tJav9_E4vzrOaqkWF|A724Nly!y+?N9`YV6wZ}5(X(D_N(?!*n3`|_r0Hc?=PQw&*vnU?QTFY zB_MsH|!j$PP;I}?dppoE_gA(4uc!jV&0!l7_;&p2^pxNo>PEcNJv za5_RT$o2Mf!<+r?&EbHH6nMoTsDOa;mN(wv8RNsHpG)`^ymG-S5By8=l9iVXzN_eG%Xg2@Xeq76tTZ*dGh~Lo9vl;Zfs+W#BydUw zCkZ$o1LqWQO$FC9aKlLl*7x9^0q%0}$OMlp@Kk_jHXOjofdePND+j!A{q!8~Jn+s3 z?~~w@4?egS02}8NuulUA=L~QQfm;MzCGd)XhiftT;+zFO&JVyp2mBww?;QByS_1w! zrQlx%{^cMj0|Bo1FjwY@Q8?Hx0cIPF*@-ZRFpPc#bBw{5@tD(5%sClzIfl8WU~V#u zm5Q;_F!wa$BSpqhN>W@2De?TKWR*!ujY;Yylk_X5#~V!L*Gw~;$%4Q8~Mad z@`-kG?yb$a9cHIApZDVZ^U6Xkp<*4rU82O7%}0jjHlK{id@?-wpN*fCHXyXh(bLt* zPc}H-x0e4E&nQ>y%B-(EL=9}RyC%MyX=upHuFhAk&MLbsF0LP-q`XnH78@fT+pKPW zu72MW`|?8ht^tz$iC}ZwLp4tB;Q49K!QCF3@!iB1qOI=?w z7In!}F~ij(18UYUjnbmC!qKhPo%24?8U1x{7o(+?^Zu0Hx81|FuS?bJ0jgBhEMzf< zCgUq7r2OCB(`XkKcN-TL>u5y#dD6D!)5W?`O5)V^>jb)P)GBdy%t$uUMpf$SNV31$ zb||OojAbvMP?T@$h_ZiFLFVHDmbyMhJF|-_)HX3%m=CDI+ID$0^C>kzxprBW)hw(v zr!Gmda);ICoQyhV_oP5+C%?jcG8v+D@9f?Dk*!BxY}dazmrT@64UrP3hlslANK)bq z$67n83eh}OeW&SV@HG95P|bjfqJ7gw$e+`Hxo!4cx`jdK1bJ>YDSpGKLPZ^1cv$ek zIB?0S<#tX?SJCLWdMd{-ME?$hc7A$zBOdIJ)4!KcAwb=VMov)nK;9z>x~rfT1>dS+ zZ6#`2v@`jgbqq)P22H)Tx2CpmM^o1$B+xT6`(v%5xJ(?j#>Q$+rx_R|7TzDZe{J6q zG1*EcU%tE?!kO%^M;3aM6JN*LAKUVb^xz8-Pxo#jR5(-KBeLJvA@-gxNHx0M-ZJLl z;#JwQoh~9V?`UVo#}{6ka@II>++D@%KqGpMdlQ}?9E*wFcf5(#XQnP$Dk5~%iX^>f z%$y;?M0BLp{O3a(-4A?ewryHrrD%cx#Q^%KY1H zNre$ve+vceSLZcNY4U(RBX&)oZn*Py()h)XkE?PL$!bNb{N5FVI2Y%LKEm%yvpyTP z(1P?z~7YxD~Rf<(a@_y` literal 0 HcmV?d00001 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); + }); +}