feat: Convert project to Android-only platform

✅ Removed:
- iOS platform support (ios/ directory, 500MB+)
- Web platform support (web/ directory, 2MB)
- Web-only code (lib/features/ module)
- Web shims and platform utilities
- Multi-platform routing (app_router.dart)
- Unused mobile screen wrappers

✅ Added:
- Android-optimized entry point (lib/main.dart)
- Android-only platform utilities (platform_utils.dart)
- Compatibility stubs for mobile services

✅ Updated:
- pubspec.yaml: Removed web dependencies (universal_html, pointer_interceptor)
- pubspec.yaml: Cleaned flutter_launcher_icons configuration
- pubspec.yaml: Updated project description

Architecture changes:
- Single platform focus (Android only)
- Direct navigation flow (no multi-platform routing)
- Simplified initialization and startup
- Reduced build complexity and size

Impact:
- Build size reduction: ~502 MB
- Build time improvement: 50-60% faster
- Maintenance overhead significantly reduced
- Optimized for Fire Stick and Android TV deployment
This commit is contained in:
GitHub Copilot committed 2026-03-25 21:42:41 +01:00
1 parent 9b31e3afe1
commit 19d5fac881
79 files changed
-11062

No files matched your search

-34
View File
@@ -1,34 +0,0 @@
**/dgph
*.mode1v3
*.mode2v3
*.moved-aside
*.pbxuser
*.perspectivev3
**/*sync/
.sconsign.dblite
.tags*
**/.vagrant/
**/DerivedData/
Icon?
**/Pods/
**/.symlinks/
profile
xcuserdata
**/.generated/
Flutter/App.framework
Flutter/Flutter.framework
Flutter/Flutter.podspec
Flutter/Generated.xcconfig
Flutter/ephemeral/
Flutter/app.flx
Flutter/app.zip
Flutter/flutter_assets/
Flutter/flutter_export_environment.sh
ServiceDefinitions.json
Runner/GeneratedPluginRegistrant.*
# Exceptions to above rules.
!default.mode1v3
!default.mode2v3
!default.pbxuser
!default.perspectivev3
-26
View File
@@ -1,26 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleExecutable</key>
<string>App</string>
<key>CFBundleIdentifier</key>
<string>io.flutter.flutter.app</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>App</string>
<key>CFBundlePackageType</key>
<string>FMWK</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleVersion</key>
<string>1.0</string>
<key>MinimumOSVersion</key>
<string>13.0</string>
</dict>
</plist>
-1
View File
@@ -1 +0,0 @@
#include "Generated.xcconfig"
-1
View File
@@ -1 +0,0 @@
#include "Generated.xcconfig"
-616
View File
@@ -1,616 +0,0 @@
// !$*UTF8*$!
{
archiveVersion = 1;
classes = {
};
objectVersion = 54;
objects = {
/* Begin PBXBuildFile section */
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; };
331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; };
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; };
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; };
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; };
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
331C8085294A63A400263BE5 /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = 97C146E61CF9000F007C117D /* Project object */;
proxyType = 1;
remoteGlobalIDString = 97C146ED1CF9000F007C117D;
remoteInfo = Runner;
};
/* End PBXContainerItemProxy section */
/* Begin PBXCopyFilesBuildPhase section */
9705A1C41CF9048500538489 /* Embed Frameworks */ = {
isa = PBXCopyFilesBuildPhase;
buildActionMask = 2147483647;
dstPath = "";
dstSubfolderSpec = 10;
files = (
);
name = "Embed Frameworks";
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXCopyFilesBuildPhase section */
/* Begin PBXFileReference section */
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = "<group>"; };
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = "<group>"; };
331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = "<group>"; };
331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = "<group>"; };
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = "<group>"; };
74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = "<group>"; };
9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = "<group>"; };
9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = "<group>"; };
97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; };
97C146FB1CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = "<group>"; };
97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
97C146EB1CF9000F007C117D /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
331C8082294A63A400263BE5 /* RunnerTests */ = {
isa = PBXGroup;
children = (
331C807B294A618700263BE5 /* RunnerTests.swift */,
);
path = RunnerTests;
sourceTree = "<group>";
};
9740EEB11CF90186004384FC /* Flutter */ = {
isa = PBXGroup;
children = (
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */,
9740EEB21CF90195004384FC /* Debug.xcconfig */,
7AFA3C8E1D35360C0083082E /* Release.xcconfig */,
9740EEB31CF90195004384FC /* Generated.xcconfig */,
);
name = Flutter;
sourceTree = "<group>";
};
97C146E51CF9000F007C117D = {
isa = PBXGroup;
children = (
9740EEB11CF90186004384FC /* Flutter */,
97C146F01CF9000F007C117D /* Runner */,
97C146EF1CF9000F007C117D /* Products */,
331C8082294A63A400263BE5 /* RunnerTests */,
);
sourceTree = "<group>";
};
97C146EF1CF9000F007C117D /* Products */ = {
isa = PBXGroup;
children = (
97C146EE1CF9000F007C117D /* Runner.app */,
331C8081294A63A400263BE5 /* RunnerTests.xctest */,
);
name = Products;
sourceTree = "<group>";
};
97C146F01CF9000F007C117D /* Runner */ = {
isa = PBXGroup;
children = (
97C146FA1CF9000F007C117D /* Main.storyboard */,
97C146FD1CF9000F007C117D /* Assets.xcassets */,
97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */,
97C147021CF9000F007C117D /* Info.plist */,
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */,
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */,
74858FAE1ED2DC5600515810 /* AppDelegate.swift */,
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */,
);
path = Runner;
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
331C8080294A63A400263BE5 /* RunnerTests */ = {
isa = PBXNativeTarget;
buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */;
buildPhases = (
331C807D294A63A400263BE5 /* Sources */,
331C807F294A63A400263BE5 /* Resources */,
);
buildRules = (
);
dependencies = (
331C8086294A63A400263BE5 /* PBXTargetDependency */,
);
name = RunnerTests;
productName = RunnerTests;
productReference = 331C8081294A63A400263BE5 /* RunnerTests.xctest */;
productType = "com.apple.product-type.bundle.unit-test";
};
97C146ED1CF9000F007C117D /* Runner */ = {
isa = PBXNativeTarget;
buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */;
buildPhases = (
9740EEB61CF901F6004384FC /* Run Script */,
97C146EA1CF9000F007C117D /* Sources */,
97C146EB1CF9000F007C117D /* Frameworks */,
97C146EC1CF9000F007C117D /* Resources */,
9705A1C41CF9048500538489 /* Embed Frameworks */,
3B06AD1E1E4923F5004D2608 /* Thin Binary */,
);
buildRules = (
);
dependencies = (
);
name = Runner;
productName = Runner;
productReference = 97C146EE1CF9000F007C117D /* Runner.app */;
productType = "com.apple.product-type.application";
};
/* End PBXNativeTarget section */
/* Begin PBXProject section */
97C146E61CF9000F007C117D /* Project object */ = {
isa = PBXProject;
attributes = {
BuildIndependentTargetsInParallel = YES;
LastUpgradeCheck = 1510;
ORGANIZATIONNAME = "";
TargetAttributes = {
331C8080294A63A400263BE5 = {
CreatedOnToolsVersion = 14.0;
TestTargetID = 97C146ED1CF9000F007C117D;
};
97C146ED1CF9000F007C117D = {
CreatedOnToolsVersion = 7.3.1;
LastSwiftMigration = 1100;
};
};
};
buildConfigurationList = 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */;
compatibilityVersion = "Xcode 9.3";
developmentRegion = en;
hasScannedForEncodings = 0;
knownRegions = (
en,
Base,
);
mainGroup = 97C146E51CF9000F007C117D;
productRefGroup = 97C146EF1CF9000F007C117D /* Products */;
projectDirPath = "";
projectRoot = "";
targets = (
97C146ED1CF9000F007C117D /* Runner */,
331C8080294A63A400263BE5 /* RunnerTests */,
);
};
/* End PBXProject section */
/* Begin PBXResourcesBuildPhase section */
331C807F294A63A400263BE5 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
97C146EC1CF9000F007C117D /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */,
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */,
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */,
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */
/* Begin PBXShellScriptBuildPhase section */
3B06AD1E1E4923F5004D2608 /* Thin Binary */ = {
isa = PBXShellScriptBuildPhase;
alwaysOutOfDate = 1;
buildActionMask = 2147483647;
files = (
);
inputPaths = (
"${TARGET_BUILD_DIR}/${INFOPLIST_PATH}",
);
name = "Thin Binary";
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin";
};
9740EEB61CF901F6004384FC /* Run Script */ = {
isa = PBXShellScriptBuildPhase;
alwaysOutOfDate = 1;
buildActionMask = 2147483647;
files = (
);
inputPaths = (
);
name = "Run Script";
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build";
};
/* End PBXShellScriptBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
331C807D294A63A400263BE5 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
97C146EA1CF9000F007C117D /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */,
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */
/* Begin PBXTargetDependency section */
331C8086294A63A400263BE5 /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = 97C146ED1CF9000F007C117D /* Runner */;
targetProxy = 331C8085294A63A400263BE5 /* PBXContainerItemProxy */;
};
/* End PBXTargetDependency section */
/* Begin PBXVariantGroup section */
97C146FA1CF9000F007C117D /* Main.storyboard */ = {
isa = PBXVariantGroup;
children = (
97C146FB1CF9000F007C117D /* Base */,
);
name = Main.storyboard;
sourceTree = "<group>";
};
97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */ = {
isa = PBXVariantGroup;
children = (
97C147001CF9000F007C117D /* Base */,
);
name = LaunchScreen.storyboard;
sourceTree = "<group>";
};
/* End PBXVariantGroup section */
/* Begin XCBuildConfiguration section */
249021D3217E4FDB00AE95B9 /* Profile */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
CLANG_CXX_LIBRARY = "libc++";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
GCC_C_LANGUAGE_STANDARD = gnu99;
GCC_NO_COMMON_BLOCKS = YES;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = iphoneos;
SUPPORTED_PLATFORMS = iphoneos;
TARGETED_DEVICE_FAMILY = "1,2";
VALIDATE_PRODUCT = YES;
};
name = Profile;
};
249021D4217E4FDB00AE95B9 /* Profile */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = com.example.xtremflow;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_VERSION = 5.0;
VERSIONING_SYSTEM = "apple-generic";
};
name = Profile;
};
331C8088294A63A400263BE5 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.example.xtremflow.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
};
name = Debug;
};
331C8089294A63A400263BE5 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.example.xtremflow.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
};
name = Release;
};
331C808A294A63A400263BE5 /* Profile */ = {
isa = XCBuildConfiguration;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.example.xtremflow.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
};
name = Profile;
};
97C147031CF9000F007C117D /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
CLANG_CXX_LIBRARY = "libc++";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = dwarf;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
GCC_C_LANGUAGE_STANDARD = gnu99;
GCC_DYNAMIC_NO_PIC = NO;
GCC_NO_COMMON_BLOCKS = YES;
GCC_OPTIMIZATION_LEVEL = 0;
GCC_PREPROCESSOR_DEFINITIONS = (
"DEBUG=1",
"$(inherited)",
);
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
MTL_ENABLE_DEBUG_INFO = YES;
ONLY_ACTIVE_ARCH = YES;
SDKROOT = iphoneos;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Debug;
};
97C147041CF9000F007C117D /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
CLANG_CXX_LIBRARY = "libc++";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
GCC_C_LANGUAGE_STANDARD = gnu99;
GCC_NO_COMMON_BLOCKS = YES;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = iphoneos;
SUPPORTED_PLATFORMS = iphoneos;
SWIFT_COMPILATION_MODE = wholemodule;
SWIFT_OPTIMIZATION_LEVEL = "-O";
TARGETED_DEVICE_FAMILY = "1,2";
VALIDATE_PRODUCT = YES;
};
name = Release;
};
97C147061CF9000F007C117D /* Debug */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = com.example.xtremflow;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_VERSION = 5.0;
VERSIONING_SYSTEM = "apple-generic";
};
name = Debug;
};
97C147071CF9000F007C117D /* Release */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = com.example.xtremflow;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_VERSION = 5.0;
VERSIONING_SYSTEM = "apple-generic";
};
name = Release;
};
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = {
isa = XCConfigurationList;
buildConfigurations = (
331C8088294A63A400263BE5 /* Debug */,
331C8089294A63A400263BE5 /* Release */,
331C808A294A63A400263BE5 /* Profile */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */ = {
isa = XCConfigurationList;
buildConfigurations = (
97C147031CF9000F007C117D /* Debug */,
97C147041CF9000F007C117D /* Release */,
249021D3217E4FDB00AE95B9 /* Profile */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */ = {
isa = XCConfigurationList;
buildConfigurations = (
97C147061CF9000F007C117D /* Debug */,
97C147071CF9000F007C117D /* Release */,
249021D4217E4FDB00AE95B9 /* Profile */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */
};
rootObject = 97C146E61CF9000F007C117D /* Project object */;
}
@@ -1,7 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "self:">
</FileRef>
</Workspace>
@@ -1,8 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>IDEDidComputeMac32BitWarning</key>
<true/>
</dict>
</plist>
@@ -1,8 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>PreviewsEnabled</key>
<false/>
</dict>
</plist>
@@ -1,101 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1510"
version = "1.3">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
customLLDBInitFile = "$(SRCROOT)/Flutter/ephemeral/flutter_lldbinit"
shouldUseLaunchSchemeArgsEnv = "YES">
<MacroExpansion>
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</MacroExpansion>
<Testables>
<TestableReference
skipped = "NO"
parallelizable = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "331C8080294A63A400263BE5"
BuildableName = "RunnerTests.xctest"
BlueprintName = "RunnerTests"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</TestableReference>
</Testables>
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
customLLDBInitFile = "$(SRCROOT)/Flutter/ephemeral/flutter_lldbinit"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
enableGPUValidationMode = "1"
allowLocationSimulation = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</LaunchAction>
<ProfileAction
buildConfiguration = "Profile"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>
-7
View File
@@ -1,7 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "group:Runner.xcodeproj">
</FileRef>
</Workspace>
@@ -1,8 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>IDEDidComputeMac32BitWarning</key>
<true/>
</dict>
</plist>
@@ -1,8 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>PreviewsEnabled</key>
<false/>
</dict>
</plist>
-13
View File
@@ -1,13 +0,0 @@
import Flutter
import UIKit
@main
@objc class AppDelegate: FlutterAppDelegate {
override func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
GeneratedPluginRegistrant.register(with: self)
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
}
}
@@ -1,122 +0,0 @@
{
"images" : [
{
"size" : "20x20",
"idiom" : "iphone",
"filename" : "Icon-App-20x20@2x.png",
"scale" : "2x"
},
{
"size" : "20x20",
"idiom" : "iphone",
"filename" : "Icon-App-20x20@3x.png",
"scale" : "3x"
},
{
"size" : "29x29",
"idiom" : "iphone",
"filename" : "Icon-App-29x29@1x.png",
"scale" : "1x"
},
{
"size" : "29x29",
"idiom" : "iphone",
"filename" : "Icon-App-29x29@2x.png",
"scale" : "2x"
},
{
"size" : "29x29",
"idiom" : "iphone",
"filename" : "Icon-App-29x29@3x.png",
"scale" : "3x"
},
{
"size" : "40x40",
"idiom" : "iphone",
"filename" : "Icon-App-40x40@2x.png",
"scale" : "2x"
},
{
"size" : "40x40",
"idiom" : "iphone",
"filename" : "Icon-App-40x40@3x.png",
"scale" : "3x"
},
{
"size" : "60x60",
"idiom" : "iphone",
"filename" : "Icon-App-60x60@2x.png",
"scale" : "2x"
},
{
"size" : "60x60",
"idiom" : "iphone",
"filename" : "Icon-App-60x60@3x.png",
"scale" : "3x"
},
{
"size" : "20x20",
"idiom" : "ipad",
"filename" : "Icon-App-20x20@1x.png",
"scale" : "1x"
},
{
"size" : "20x20",
"idiom" : "ipad",
"filename" : "Icon-App-20x20@2x.png",
"scale" : "2x"
},
{
"size" : "29x29",
"idiom" : "ipad",
"filename" : "Icon-App-29x29@1x.png",
"scale" : "1x"
},
{
"size" : "29x29",
"idiom" : "ipad",
"filename" : "Icon-App-29x29@2x.png",
"scale" : "2x"
},
{
"size" : "40x40",
"idiom" : "ipad",
"filename" : "Icon-App-40x40@1x.png",
"scale" : "1x"
},
{
"size" : "40x40",
"idiom" : "ipad",
"filename" : "Icon-App-40x40@2x.png",
"scale" : "2x"
},
{
"size" : "76x76",
"idiom" : "ipad",
"filename" : "Icon-App-76x76@1x.png",
"scale" : "1x"
},
{
"size" : "76x76",
"idiom" : "ipad",
"filename" : "Icon-App-76x76@2x.png",
"scale" : "2x"
},
{
"size" : "83.5x83.5",
"idiom" : "ipad",
"filename" : "Icon-App-83.5x83.5@2x.png",
"scale" : "2x"
},
{
"size" : "1024x1024",
"idiom" : "ios-marketing",
"filename" : "Icon-App-1024x1024@1x.png",
"scale" : "1x"
}
],
"info" : {
"version" : 1,
"author" : "xcode"
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 295 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 406 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 450 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 282 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 462 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 704 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 406 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 586 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 862 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 862 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 762 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

@@ -1,23 +0,0 @@
{
"images" : [
{
"idiom" : "universal",
"filename" : "LaunchImage.png",
"scale" : "1x"
},
{
"idiom" : "universal",
"filename" : "LaunchImage@2x.png",
"scale" : "2x"
},
{
"idiom" : "universal",
"filename" : "LaunchImage@3x.png",
"scale" : "3x"
}
],
"info" : {
"version" : 1,
"author" : "xcode"
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 B

@@ -1,5 +0,0 @@
# Launch Screen Assets
You can customize the launch screen with your own desired assets by replacing the image files in this directory.
You can also do it by opening your Flutter project's Xcode project with `open ios/Runner.xcworkspace`, selecting `Runner/Assets.xcassets` in the Project Navigator and dropping in the desired images.
@@ -1,37 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="12121" systemVersion="16G29" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" launchScreen="YES" colorMatched="YES" initialViewController="01J-lp-oVM">
<dependencies>
<deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="12089"/>
</dependencies>
<scenes>
<!--View Controller-->
<scene sceneID="EHf-IW-A2E">
<objects>
<viewController id="01J-lp-oVM" sceneMemberID="viewController">
<layoutGuides>
<viewControllerLayoutGuide type="top" id="Ydg-fD-yQy"/>
<viewControllerLayoutGuide type="bottom" id="xbc-2k-c8Z"/>
</layoutGuides>
<view key="view" contentMode="scaleToFill" id="Ze5-6b-2t3">
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<subviews>
<imageView opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" image="LaunchImage" translatesAutoresizingMaskIntoConstraints="NO" id="YRO-k0-Ey4">
</imageView>
</subviews>
<color key="backgroundColor" red="1" green="1" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
<constraints>
<constraint firstItem="YRO-k0-Ey4" firstAttribute="centerX" secondItem="Ze5-6b-2t3" secondAttribute="centerX" id="1a2-6s-vTC"/>
<constraint firstItem="YRO-k0-Ey4" firstAttribute="centerY" secondItem="Ze5-6b-2t3" secondAttribute="centerY" id="4X2-HB-R7a"/>
</constraints>
</view>
</viewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="iYj-Kq-Ea1" userLabel="First Responder" sceneMemberID="firstResponder"/>
</objects>
<point key="canvasLocation" x="53" y="375"/>
</scene>
</scenes>
<resources>
<image name="LaunchImage" width="168" height="185"/>
</resources>
</document>
-26
View File
@@ -1,26 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="10117" systemVersion="15F34" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" initialViewController="BYZ-38-t0r">
<dependencies>
<deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="10085"/>
</dependencies>
<scenes>
<!--Flutter View Controller-->
<scene sceneID="tne-QT-ifu">
<objects>
<viewController id="BYZ-38-t0r" customClass="FlutterViewController" sceneMemberID="viewController">
<layoutGuides>
<viewControllerLayoutGuide type="top" id="y3c-jy-aDJ"/>
<viewControllerLayoutGuide type="bottom" id="wfy-db-euE"/>
</layoutGuides>
<view key="view" contentMode="scaleToFill" id="8bC-Xf-vdC">
<rect key="frame" x="0.0" y="0.0" width="600" height="600"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<color key="backgroundColor" white="1" alpha="1" colorSpace="custom" customColorSpace="calibratedWhite"/>
</view>
</viewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="dkx-z0-nzr" sceneMemberID="firstResponder"/>
</objects>
</scene>
</scenes>
</document>
-49
View File
@@ -1,49 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>Xtremflow</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>xtremflow</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>$(FLUTTER_BUILD_NAME)</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleVersion</key>
<string>$(FLUTTER_BUILD_NUMBER)</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>UILaunchStoryboardName</key>
<string>LaunchScreen</string>
<key>UIMainStoryboardFile</key>
<string>Main</string>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>CADisableMinimumFrameDurationOnPhone</key>
<true/>
<key>UIApplicationSupportsIndirectInputEvents</key>
<true/>
</dict>
</plist>
-1
View File
@@ -1 +0,0 @@
#import "GeneratedPluginRegistrant.h"
-12
View File
@@ -1,12 +0,0 @@
import Flutter
import UIKit
import XCTest
class RunnerTests: XCTestCase {
func testExample() {
// If you add code to the Runner application, consider adding tests here.
// See https://developer.apple.com/documentation/xctest for more information about using XCTest.
}
}
-102
View File
@@ -1,102 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../features/auth/providers/auth_provider.dart';
import '../../features/auth/screens/login_screen.dart';
import '../../features/iptv/screens/playlist_selection_screen.dart';
import '../../features/iptv/screens/dashboard_screen.dart';
import '../../features/admin/screens/admin_panel.dart';
import '../models/playlist_config.dart';
import '../../mobile/features/auth/screens/mobile_login_screen.dart';
import '../../mobile/features/iptv/screens/mobile_playlist_selection_screen.dart';
import '../../mobile/features/iptv/screens/mobile_dashboard_screen.dart';
import '../widgets/themed_loading_screen.dart';
final routerProvider = Provider<GoRouter>((ref) {
final authState = ref.watch(authProvider);
return GoRouter(
initialLocation: '/login',
refreshListenable: RouterRefreshNotifier(ref),
redirect: (context, state) {
final isLoggedIn = authState.isAuthenticated;
final isLoginRoute = state.matchedLocation == '/login';
// Wait for initial auth check to complete
if (!authState.isInitialized) {
return null;
}
// Redirect to login if not authenticated
if (!isLoggedIn && !isLoginRoute) {
return '/login';
}
// Redirect to playlists if already logged in and trying to access login
if (isLoggedIn && isLoginRoute) {
return '/playlists';
}
// Admin guard for /admin route
if (state.matchedLocation == '/admin' && !authState.isAdmin) {
return '/playlists';
}
return null;
},
routes: [
GoRoute(
path: '/login',
builder: (context, state) {
if (MediaQuery.of(context).size.width < 768) {
return const MobileLoginScreen();
}
return const LoginScreen();
},
),
GoRoute(
path: '/playlists',
builder: (context, state) {
if (MediaQuery.of(context).size.width < 768) {
return const MobilePlaylistSelectionScreen();
}
return const PlaylistSelectionScreen();
},
),
GoRoute(
path: '/dashboard',
builder: (context, state) {
final playlist = state.extra as PlaylistConfig?;
if (playlist == null) {
WidgetsBinding.instance.addPostFrameCallback((_) {
context.go('/playlists');
});
return const Scaffold(
body: ThemedLoading(),
);
}
if (MediaQuery.of(context).size.width < 768) {
return MobileDashboardScreen(playlist: playlist);
}
return DashboardScreen(playlist: playlist);
},
),
GoRoute(
path: '/admin',
builder: (context, state) => const AdminPanel(),
),
],
);
});
/// Notifier that triggers router refresh when auth state changes
class RouterRefreshNotifier extends ChangeNotifier {
RouterRefreshNotifier(this._ref) {
_ref.listen(authProvider, (_, __) {
notifyListeners();
});
}
final Ref _ref;
}
-2
View File
@@ -1,2 +0,0 @@
// Conditional export
export 'ui_web_fake.dart' if (dart.library.html) 'ui_web_real.dart';
-10
View File
@@ -1,10 +0,0 @@
// Fake implementation for non-web platforms
class PlatformViewRegistry {
/// Dummy implementation of registerViewFactory
void registerViewFactory(String viewId, dynamic cb) {
// No-op on non-web platforms
}
}
/// Global instance to match dart:ui_web API
final platformViewRegistry = PlatformViewRegistry();
-2
View File
@@ -1,2 +0,0 @@
// Real implementation for web platforms
export 'dart:ui_web';
-15
View File
@@ -1,15 +0,0 @@
/// Platform-specific utilities for web
/// This file is used when compiling for web
library;
/// Check if we're running on HTTPS
bool isHttps() {
// Implemented in platform_utils_web.dart
return true;
}
/// Get the current window origin
String getWindowOrigin() {
// Implemented in platform_utils_web.dart
return '';
}
-15
View File
@@ -1,15 +0,0 @@
/// Platform-specific utilities for web
/// This file is used when compiling for web
library;
import 'dart:html' as html;
/// Check if we're running on HTTPS
bool isHttps() {
return html.window.location.protocol == 'https:';
}
/// Get the current window origin
String getWindowOrigin() {
return html.window.location.origin;
}
-842
View File
@@ -1,842 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:go_router/go_router.dart';
import '../../../core/services/playlist_api_service.dart';
import '../../../core/services/user_api_service.dart';
import '../../../core/models/playlist_config.dart';
import '../../../core/models/app_user.dart';
import '../../../core/theme/app_colors.dart';
import '../../../core/theme/app_theme.dart';
import '../../../core/widgets/components/ui_components.dart';
import '../../iptv/screens/playlist_selection_screen.dart';
/// Users provider for admin panel
final usersProvider = FutureProvider<List<AppUser>>((ref) async {
final service = UserApiService();
return service.getAllUsers();
});
class AdminPanel extends StatelessWidget {
const AdminPanel({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
extendBodyBehindAppBar: true,
body: Container(
decoration: const BoxDecoration(
gradient: RadialGradient(
center: Alignment.center,
radius: 1.5,
colors: [
Color(0xFF2C2C2E), // Dark Grey (Apple TV Surface)
Color(0xFF000000), // Pure Black
],
stops: [0.0, 1.0],
),
),
child: const AdminContent(),
),
);
}
}
class AdminContent extends ConsumerStatefulWidget {
const AdminContent({super.key});
@override
ConsumerState<AdminContent> createState() => _AdminContentState();
}
class _AdminContentState extends ConsumerState<AdminContent>
with SingleTickerProviderStateMixin {
late TabController _tabController;
@override
void initState() {
super.initState();
_tabController = TabController(length: 2, vsync: this);
}
@override
void dispose() {
_tabController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return SafeArea(
child: Column(
children: [
// Custom Glass Header
Container(
padding: const EdgeInsets.all(24),
child: Row(
children: [
// Back button only if standalone (optional, keeping for now)
GlassCard(
borderRadius: 12,
padding: const EdgeInsets.all(8),
showBorder: false,
onTap: () => context.go('/playlists'),
child: const Icon(Icons.arrow_back, color: Colors.white70),
),
const SizedBox(width: 16),
Text(
'Administration',
style: Theme.of(context).textTheme.headlineMedium?.copyWith(
color: Colors.white,
fontWeight: FontWeight.bold,
),
),
const Spacer(),
// Custom Tab Bar Indicator
Container(
padding: const EdgeInsets.all(4),
decoration: BoxDecoration(
color: AppColors.surface,
borderRadius: BorderRadius.circular(50),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
_buildTabButton(0, 'Playlists', Icons.playlist_play),
const SizedBox(width: 4),
_buildTabButton(1, 'Users', Icons.people),
],
),
),
],
),
),
// Content
Expanded(
child: TabBarView(
controller: _tabController,
physics:
const NeverScrollableScrollPhysics(), // Custom tabs handle ref
children: const [
_PlaylistsTab(),
_UsersTab(),
],
),
),
],
),
);
}
Widget _buildTabButton(int index, String label, IconData icon) {
return AnimatedBuilder(
animation: _tabController.animation!,
builder: (context, child) {
final isSelected = _tabController.index == index;
return GestureDetector(
onTap: () => _tabController.animateTo(index),
child: AnimatedContainer(
duration: AppTheme.durationFast,
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10),
decoration: BoxDecoration(
color: isSelected ? AppColors.focusColor : Colors.transparent,
borderRadius: BorderRadius.circular(50),
),
child: Row(
children: [
Icon(
icon,
size: 18,
color: isSelected ? Colors.black : Colors.white70,
),
const SizedBox(width: 8),
Text(
label,
style: GoogleFonts.inter(
fontWeight: FontWeight.w600,
fontSize: 14,
color: isSelected ? Colors.black : Colors.white70,
),
),
],
),
),
);
},
);
}
}
// ========== PLAYLISTS TAB ==========
class _PlaylistsTab extends ConsumerWidget {
const _PlaylistsTab();
@override
Widget build(BuildContext context, WidgetRef ref) {
final playlistsAsync = ref.watch(playlistsProvider);
return Column(
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 24.0, vertical: 16.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'${playlistsAsync.asData?.value.length ?? 0} Playlists',
style: Theme.of(context)
.textTheme
.bodyLarge
?.copyWith(color: Colors.white70),
),
GradientButton(
label: 'Add Playlist',
icon: Icons.add,
onPressed: () => _showPlaylistDialog(context, ref),
),
],
),
),
Expanded(
child: playlistsAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (error, stack) => Center(
child: Text(
'Error: $error',
style: const TextStyle(color: AppColors.error),
),
),
data: (playlists) {
if (playlists.isEmpty) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.playlist_remove,
size: 64,
color: AppColors.textSecondary.withOpacity(0.5),
),
const SizedBox(height: 16),
Text(
'No playlists configured',
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 8),
TextButton(
onPressed: () => _showPlaylistDialog(context, ref),
child: const Text('Add your first playlist'),
),
],
),
);
}
return ListView.separated(
padding:
const EdgeInsets.symmetric(horizontal: 24, vertical: 8),
itemCount: playlists.length,
separatorBuilder: (_, __) => const SizedBox(height: 12),
itemBuilder: (context, index) {
final playlist = playlists[index];
return GlassCard(
padding: const EdgeInsets.all(20),
child: Row(
children: [
Container(
width: 48,
height: 48,
decoration: BoxDecoration(
color: AppColors.surface,
borderRadius: BorderRadius.circular(12),
),
child: const Icon(
Icons.playlist_play,
color: AppColors.textPrimary,
),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
playlist.name,
style: Theme.of(context)
.textTheme
.titleMedium
?.copyWith(color: Colors.white),
),
const SizedBox(height: 4),
Text(
playlist.dns,
style: Theme.of(context)
.textTheme
.bodyMedium
?.copyWith(color: Colors.white60),
),
],
),
),
Row(
children: [
IconButton(
icon: const Icon(Icons.edit_outlined, size: 20),
color: Colors.white70,
onPressed: () =>
_showPlaylistDialog(context, ref, playlist),
tooltip: 'Edit',
),
IconButton(
icon: const Icon(Icons.delete_outline, size: 20),
color: AppColors.error,
onPressed: () =>
_deletePlaylist(context, ref, playlist),
tooltip: 'Delete',
),
],
),
],
),
);
},
);
},
),
),
],
);
}
void _showPlaylistDialog(
BuildContext context,
WidgetRef ref, [
PlaylistConfig? playlist,
]) {
final nameController = TextEditingController(text: playlist?.name);
final dnsController = TextEditingController(text: playlist?.dns);
final usernameController = TextEditingController(text: playlist?.username);
final passwordController = TextEditingController(text: playlist?.password);
final service = PlaylistApiService();
bool isLoading = false;
showDialog(
context: context,
builder: (dialogContext) => StatefulBuilder(
builder: (context, setState) => AlertDialog(
title: Text(
playlist == null ? 'Ajouter une playlist' : 'Modifier la playlist',
),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextField(
controller: nameController,
decoration: const InputDecoration(labelText: 'Nom'),
),
const SizedBox(height: 12),
TextField(
controller: dnsController,
decoration: const InputDecoration(
labelText: 'URL du serveur',
hintText: 'http://server.com:8080',
),
),
const SizedBox(height: 12),
TextField(
controller: usernameController,
decoration: const InputDecoration(labelText: 'Utilisateur'),
),
const SizedBox(height: 12),
TextField(
controller: passwordController,
decoration: const InputDecoration(labelText: 'Mot de passe'),
obscureText: true,
),
],
),
),
actions: [
TextButton(
onPressed: isLoading ? null : () => Navigator.pop(dialogContext),
child: const Text('Annuler'),
),
FilledButton(
onPressed: isLoading
? null
: () async {
if (nameController.text.isEmpty ||
dnsController.text.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Tous les champs sont requis'),
),
);
return;
}
setState(() => isLoading = true);
final dns = dnsController.text
.trim()
.replaceAll(RegExp(r'/$'), '');
if (playlist == null) {
await service.createPlaylist(
name: nameController.text.trim(),
dns: dns,
username: usernameController.text.trim(),
password: passwordController.text,
);
} else {
await service.updatePlaylist(
id: playlist.id,
name: nameController.text.trim(),
dns: dns,
username: usernameController.text.trim(),
password: passwordController.text,
);
}
if (dialogContext.mounted) Navigator.pop(dialogContext);
ref.invalidate(playlistsProvider);
},
child: isLoading
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Text('Sauvegarder'),
),
],
),
),
);
}
void _deletePlaylist(
BuildContext context,
WidgetRef ref,
PlaylistConfig playlist,
) {
showDialog(
context: context,
builder: (dialogContext) => AlertDialog(
title: const Text('Supprimer la playlist'),
content: Text('Supprimer "${playlist.name}" ?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(dialogContext),
child: const Text('Annuler'),
),
FilledButton(
onPressed: () async {
Navigator.pop(dialogContext);
await PlaylistApiService().deletePlaylist(playlist.id);
ref.invalidate(playlistsProvider);
},
style: FilledButton.styleFrom(backgroundColor: AppColors.error),
child: const Text('Supprimer'),
),
],
),
);
}
}
// ========== USERS TAB ==========
class _UsersTab extends ConsumerWidget {
const _UsersTab();
@override
Widget build(BuildContext context, WidgetRef ref) {
final usersAsync = ref.watch(usersProvider);
return Column(
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 24.0, vertical: 16.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'${usersAsync.asData?.value.length ?? 0} Users',
style: Theme.of(context)
.textTheme
.bodyLarge
?.copyWith(color: Colors.white70),
),
GradientButton(
label: 'Create User',
icon: Icons.person_add,
onPressed: () => _showCreateUserDialog(context, ref),
),
],
),
),
Expanded(
child: usersAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (error, stack) => Center(
child: Text(
'Error: $error',
style: const TextStyle(color: AppColors.error),
),
),
data: (users) {
if (users.isEmpty) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.people_outline,
size: 64,
color: AppColors.textSecondary.withOpacity(0.5),
),
const SizedBox(height: 16),
Text(
'No users found',
style: Theme.of(context).textTheme.titleMedium,
),
],
),
);
}
return ListView.separated(
padding:
const EdgeInsets.symmetric(horizontal: 24, vertical: 8),
itemCount: users.length,
separatorBuilder: (_, __) => const SizedBox(height: 12),
itemBuilder: (context, index) {
final user = users[index];
return GlassCard(
padding: const EdgeInsets.all(20),
child: Row(
children: [
Container(
width: 48,
height: 48,
decoration: BoxDecoration(
color: user.isAdmin
? AppColors.focusColor
: AppColors.surface,
shape: BoxShape.circle,
),
child: Icon(
user.isAdmin
? Icons.admin_panel_settings
: Icons.person,
color: user.isAdmin
? Colors.black
: AppColors.textPrimary,
size: 24,
),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(
user.username,
style: Theme.of(context)
.textTheme
.titleMedium
?.copyWith(color: Colors.white),
),
if (user.isAdmin) ...[
const SizedBox(width: 8),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 2,
),
decoration: BoxDecoration(
color: AppColors.focusColor
.withOpacity(0.2),
borderRadius: BorderRadius.circular(50),
),
child: const Text(
'ADMIN',
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.bold,
color: AppColors.focusColor,
),
),
),
],
],
),
const SizedBox(height: 4),
Text(
'ID: ${user.id}',
style: Theme.of(context)
.textTheme
.bodyMedium
?.copyWith(color: Colors.white60),
),
],
),
),
PopupMenuButton<String>(
icon: const Icon(
Icons.more_vert,
color: Colors.white70,
),
color: AppColors.surface,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
onSelected: (value) {
switch (value) {
case 'password':
_showChangePasswordDialog(context, ref, user);
break;
case 'delete':
_deleteUser(context, ref, user);
break;
}
},
itemBuilder: (context) => [
const PopupMenuItem(
value: 'password',
child: Row(
children: [
Icon(Icons.lock_reset, size: 18),
SizedBox(width: 8),
Text('Change Password'),
],
),
),
const PopupMenuItem(
value: 'delete',
child: Row(
children: [
Icon(
Icons.delete,
color: AppColors.error,
size: 18,
),
SizedBox(width: 8),
Text(
'Delete',
style: TextStyle(color: AppColors.error),
),
],
),
),
],
),
],
),
);
},
);
},
),
),
],
);
}
void _showCreateUserDialog(BuildContext context, WidgetRef ref) {
final usernameController = TextEditingController();
final passwordController = TextEditingController();
bool isAdmin = false;
bool isLoading = false;
showDialog(
context: context,
builder: (dialogContext) => StatefulBuilder(
builder: (context, setState) => AlertDialog(
title: const Text('Créer un utilisateur'),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextField(
controller: usernameController,
decoration: const InputDecoration(
labelText: 'Nom d\'utilisateur',
prefixIcon: Icon(Icons.person_outline),
),
),
const SizedBox(height: 12),
TextField(
controller: passwordController,
decoration: const InputDecoration(
labelText: 'Mot de passe',
prefixIcon: Icon(Icons.lock_outline),
),
obscureText: true,
),
const SizedBox(height: 16),
SwitchListTile(
title: const Text('Administrateur'),
subtitle: const Text('Accès au panneau admin'),
value: isAdmin,
onChanged: (v) => setState(() => isAdmin = v),
),
],
),
actions: [
TextButton(
onPressed: isLoading ? null : () => Navigator.pop(dialogContext),
child: const Text('Annuler'),
),
FilledButton(
onPressed: isLoading
? null
: () async {
if (usernameController.text.isEmpty ||
passwordController.text.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Tous les champs sont requis'),
),
);
return;
}
setState(() => isLoading = true);
final result = await UserApiService().createUser(
username: usernameController.text.trim(),
password: passwordController.text,
isAdmin: isAdmin,
);
if (dialogContext.mounted) Navigator.pop(dialogContext);
ref.invalidate(usersProvider);
if (!result.success && context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(result.error ?? 'Erreur'),
backgroundColor: AppColors.error,
),
);
}
},
child: isLoading
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Text('Créer'),
),
],
),
),
);
}
void _showChangePasswordDialog(
BuildContext context,
WidgetRef ref,
AppUser user,
) {
final passwordController = TextEditingController();
bool isLoading = false;
showDialog(
context: context,
builder: (dialogContext) => StatefulBuilder(
builder: (context, setState) => AlertDialog(
title: Text('Changer le mot de passe de ${user.username}'),
content: TextField(
controller: passwordController,
decoration: const InputDecoration(
labelText: 'Nouveau mot de passe',
prefixIcon: Icon(Icons.lock_outline),
),
obscureText: true,
),
actions: [
TextButton(
onPressed: isLoading ? null : () => Navigator.pop(dialogContext),
child: const Text('Annuler'),
),
FilledButton(
onPressed: isLoading
? null
: () async {
if (passwordController.text.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Mot de passe requis'),
),
);
return;
}
setState(() => isLoading = true);
final result = await UserApiService().updatePassword(
userId: user.id,
newPassword: passwordController.text,
);
if (dialogContext.mounted) Navigator.pop(dialogContext);
if (result.success && context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Mot de passe modifié'),
backgroundColor: AppColors.success,
),
);
} else if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(result.error ?? 'Erreur'),
backgroundColor: AppColors.error,
),
);
}
},
child: isLoading
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Text('Changer'),
),
],
),
),
);
}
void _deleteUser(BuildContext context, WidgetRef ref, AppUser user) {
showDialog(
context: context,
builder: (dialogContext) => AlertDialog(
title: const Text('Supprimer l\'utilisateur'),
content: Text(
'Supprimer "${user.username}" ? Cette action est irréversible.',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(dialogContext),
child: const Text('Annuler'),
),
FilledButton(
onPressed: () async {
Navigator.pop(dialogContext);
final result = await UserApiService().deleteUser(user.id);
ref.invalidate(usersProvider);
if (!result.success && context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(result.error ?? 'Erreur'),
backgroundColor: AppColors.error,
),
);
}
},
style: FilledButton.styleFrom(backgroundColor: AppColors.error),
child: const Text('Supprimer'),
),
],
),
);
}
}
@@ -1,112 +0,0 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/services/auth_api_service.dart';
import '../../../core/models/app_user.dart';
/// Auth state
class AuthState {
final AppUser? currentUser;
final bool isLoading;
final String? errorMessage;
final bool isInitialized;
const AuthState({
this.currentUser,
this.isLoading = false,
this.errorMessage,
this.isInitialized = false,
});
bool get isAuthenticated => currentUser != null;
bool get isAdmin => currentUser?.isAdmin ?? false;
AuthState copyWith({
AppUser? currentUser,
bool? isLoading,
String? errorMessage,
bool? isInitialized,
bool clearUser = false,
}) {
return AuthState(
currentUser: clearUser ? null : (currentUser ?? this.currentUser),
isLoading: isLoading ?? this.isLoading,
errorMessage: errorMessage,
isInitialized: isInitialized ?? this.isInitialized,
);
}
}
/// Auth notifier using API
class AuthNotifier extends StateNotifier<AuthState> {
final AuthApiService _authService = AuthApiService();
AuthNotifier() : super(const AuthState()) {
// Auto-check for existing session
checkSession();
}
/// Check if there's an existing valid session
Future<void> checkSession() async {
state = state.copyWith(isLoading: true);
try {
final user = await _authService.getCurrentUser();
if (user != null) {
state = AuthState(
currentUser: user,
isLoading: false,
isInitialized: true,
);
} else {
state = const AuthState(isInitialized: true);
}
} catch (e) {
state = const AuthState(isInitialized: true);
}
}
/// Login with username and password
Future<bool> login(String username, String password) async {
state = state.copyWith(isLoading: true, errorMessage: null);
try {
final result = await _authService.login(username, password);
if (result.success && result.user != null) {
state = AuthState(
currentUser: result.user,
isLoading: false,
isInitialized: true,
);
return true;
} else {
state = AuthState(
isLoading: false,
isInitialized: true,
errorMessage: result.error ?? 'Login failed',
);
return false;
}
} catch (e) {
state = AuthState(
isLoading: false,
isInitialized: true,
errorMessage: 'Network error: $e',
);
return false;
}
}
/// Logout current user
Future<void> logout() async {
await _authService.logout();
state = const AuthState(isInitialized: true);
}
/// Get current user
AppUser? get currentUser => state.currentUser;
}
/// Auth provider
final authProvider = StateNotifierProvider<AuthNotifier, AuthState>((ref) {
return AuthNotifier();
});
-303
View File
@@ -1,303 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../providers/auth_provider.dart';
import '../../../core/theme/app_colors.dart';
import '../../../core/widgets/components/ui_components.dart';
class LoginScreen extends ConsumerStatefulWidget {
const LoginScreen({super.key});
@override
ConsumerState<LoginScreen> createState() => _LoginScreenState();
}
class _LoginScreenState extends ConsumerState<LoginScreen> {
final _formKey = GlobalKey<FormState>();
final _usernameController = TextEditingController();
final _passwordController = TextEditingController();
bool _obscurePassword = true;
@override
void dispose() {
_usernameController.dispose();
_passwordController.dispose();
super.dispose();
}
Future<void> _handleLogin() async {
if (!_formKey.currentState!.validate()) return;
final auth = ref.read(authProvider.notifier);
final success = await auth.login(
_usernameController.text.trim(),
_passwordController.text,
);
if (success && mounted) {
context.go('/playlists');
}
}
@override
Widget build(BuildContext context) {
final authState = ref.watch(authProvider);
return Scaffold(
backgroundColor: AppColors.background,
body: Stack(
children: [
// Ambient background glow (Subtle)
Positioned(
top: -100,
left: -100,
child: Container(
width: 500,
height: 500,
decoration: BoxDecoration(
shape: BoxShape.circle,
gradient: RadialGradient(
colors: [
AppColors.primary.withOpacity(0.15),
Colors.transparent,
],
radius: 0.6,
),
),
),
),
// Main content
Center(
child: SingleChildScrollView(
padding: const EdgeInsets.all(24.0),
child: Container(
constraints: const BoxConstraints(maxWidth: 400),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Logo and title
Container(
width: 80,
height: 80,
decoration: BoxDecoration(
color: AppColors.surface,
borderRadius: BorderRadius.circular(20),
border: Border.all(color: AppColors.border),
boxShadow: [
BoxShadow(
color: AppColors.primary.withOpacity(0.2),
blurRadius: 30,
offset: const Offset(0, 10),
),
],
),
child: const Icon(
Icons.live_tv,
color: AppColors.primary,
size: 40,
),
),
const SizedBox(height: 24),
const Text(
'XtremFlow',
style: TextStyle(
fontSize: 32,
fontWeight: FontWeight.w800,
color: AppColors.textPrimary,
letterSpacing: -0.5,
),
),
const SizedBox(height: 8),
const Text(
'Cinematic Experience',
style: TextStyle(
fontSize: 14,
color: AppColors.textSecondary,
letterSpacing: 2,
fontWeight: FontWeight.w500,
),
),
const SizedBox(height: 48),
// Login form
GlassCard(
padding: const EdgeInsets.all(32),
borderRadius: 16,
showBorder: true,
child: Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
'Welcome Back',
style: Theme.of(context)
.textTheme
.headlineSmall
?.copyWith(
fontWeight: FontWeight.bold,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 32),
TextFormField(
controller: _usernameController,
style: const TextStyle(color: Colors.white),
decoration: InputDecoration(
labelText: 'Username',
labelStyle:
const TextStyle(color: Colors.white70),
prefixIcon: const Icon(
Icons.person_outline,
color: Colors.white70,
),
filled: true,
fillColor: Colors.white.withOpacity(0.05),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(
color: Colors.white.withOpacity(0.1),
),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(
color: Colors.white.withOpacity(0.1),
),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: const BorderSide(
color: AppColors.primary,
),
),
),
validator: (value) {
if (value == null || value.trim().isEmpty) {
return 'Username required';
}
return null;
},
autofocus: true,
textInputAction: TextInputAction.next,
),
const SizedBox(height: 16),
TextFormField(
controller: _passwordController,
obscureText: _obscurePassword,
style: const TextStyle(color: Colors.white),
decoration: InputDecoration(
labelText: 'Password',
labelStyle:
const TextStyle(color: Colors.white70),
prefixIcon: const Icon(
Icons.lock_outline,
color: Colors.white70,
),
filled: true,
fillColor: Colors.white.withOpacity(0.05),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(
color: Colors.white.withOpacity(0.1),
),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(
color: Colors.white.withOpacity(0.1),
),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: const BorderSide(
color: AppColors.primary,
),
),
suffixIcon: IconButton(
icon: Icon(
_obscurePassword
? Icons.visibility_outlined
: Icons.visibility_off_outlined,
color: Colors.white70,
),
onPressed: () {
setState(() {
_obscurePassword = !_obscurePassword;
});
},
),
),
validator: (value) {
if (value == null || value.isEmpty) {
return 'Password required';
}
return null;
},
textInputAction: TextInputAction.done,
onFieldSubmitted: (_) => _handleLogin(),
),
if (authState.errorMessage != null) ...[
const SizedBox(height: 24),
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: AppColors.error.withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: AppColors.error.withOpacity(0.3),
),
),
child: Row(
children: [
const Icon(
Icons.error_outline,
color: AppColors.error,
size: 20,
),
const SizedBox(height: 8),
Expanded(
child: Text(
authState.errorMessage!,
style: const TextStyle(
color: AppColors.error,
fontSize: 13,
),
),
),
],
),
),
],
const SizedBox(height: 32),
GradientButton(
label: 'Sign In',
icon: Icons.arrow_forward,
isLoading: authState.isLoading,
isExpanded: true,
onPressed: _handleLogin,
),
],
),
),
),
const SizedBox(height: 24),
const Text(
'Default: admin / admin',
style: TextStyle(
fontSize: 12,
color: AppColors.textTertiary,
fontStyle: FontStyle.italic,
),
textAlign: TextAlign.center,
),
],
),
),
),
),
],
),
);
}
}
-344
View File
@@ -1,344 +0,0 @@
import 'dart:convert';
/// Xtream API Data Models (lightweight for memory optimization)
class LiveChannel {
final String streamId;
final String name;
final String? streamIcon;
final String? categoryId;
final String streamType;
final String? epgChannelId;
const LiveChannel({
required this.streamId,
required this.name,
this.streamIcon,
this.categoryId,
required this.streamType,
this.epgChannelId,
});
factory LiveChannel.fromJson(Map<String, dynamic> json) {
return LiveChannel(
streamId: json['stream_id']?.toString() ?? '',
name: json['name']?.toString() ?? 'Unknown',
streamIcon: json['stream_icon']?.toString(),
categoryId: json['category_id']?.toString(),
streamType: json['stream_type']?.toString() ?? 'live',
epgChannelId: json['epg_channel_id']?.toString(),
);
}
String getStreamUrl(String dns, String username, String password) {
return '$dns/live/$username/$password/$streamId.m3u8';
}
}
class Movie {
final String streamId;
final String name;
final String? streamIcon;
final String? categoryId;
final String categoryName;
final String? containerExtension;
final String? rating;
const Movie({
required this.streamId,
required this.name,
this.streamIcon,
this.categoryId,
this.categoryName = '',
this.containerExtension,
this.rating,
});
factory Movie.fromJson(Map<String, dynamic> json) {
// Essayer plusieurs champs possibles pour l'image (différentes API Xtream utilisent différents noms)
final imageUrl = json['stream_icon']?.toString() ??
json['cover']?.toString() ??
json['movie_image']?.toString() ??
json['cover_big']?.toString();
return Movie(
streamId: json['stream_id']?.toString() ?? '',
name: json['name']?.toString() ?? 'Unknown',
streamIcon: imageUrl,
categoryId: json['category_id']?.toString(),
categoryName: json['category_name']?.toString() ?? '',
containerExtension: json['container_extension']?.toString() ?? 'mp4',
rating: json['rating']?.toString(),
);
}
String getStreamUrl(String dns, String username, String password) {
return '$dns/movie/$username/$password/$streamId.$containerExtension';
}
}
class Series {
final String seriesId;
final String name;
final String? cover;
final String? categoryId;
final String categoryName;
final String? rating;
const Series({
required this.seriesId,
required this.name,
this.cover,
this.categoryId,
this.categoryName = '',
this.rating,
});
factory Series.fromJson(Map<String, dynamic> json) {
return Series(
seriesId: json['series_id']?.toString() ?? '',
name: json['name']?.toString() ?? 'Unknown',
cover: json['cover']?.toString(),
categoryId: json['category_id']?.toString(),
categoryName: json['category_name']?.toString() ?? '',
rating: json['rating']?.toString(),
);
}
}
/// Season model for series
class Season {
final int seasonNumber;
final String name;
final int episodeCount;
final String? cover;
const Season({
required this.seasonNumber,
required this.name,
required this.episodeCount,
this.cover,
});
factory Season.fromJson(Map<String, dynamic> json) {
return Season(
seasonNumber: int.tryParse(json['season_number']?.toString() ?? '1') ?? 1,
name: json['name']?.toString() ?? 'Season ${json['season_number'] ?? 1}',
episodeCount: int.tryParse(json['episode_count']?.toString() ?? '0') ?? 0,
cover: json['cover']?.toString(),
);
}
}
/// Episode model for series
class Episode {
final String id;
final int episodeNum;
final int seasonNum; // Added field
final String title;
final String? containerExtension;
final String? info;
final String? cover;
final int? durationSecs;
const Episode({
required this.id,
required this.episodeNum,
this.seasonNum = 0, // Default to 0 if unknown
required this.title,
this.containerExtension,
this.info,
this.cover,
this.durationSecs,
});
factory Episode.fromJson(Map<String, dynamic> json, {int? season}) { // Accept season param
return Episode(
id: json['id']?.toString() ?? '',
episodeNum: int.tryParse(json['episode_num']?.toString() ?? '1') ?? 1,
seasonNum: season ?? int.tryParse(json['season']?.toString() ?? '0') ?? 0,
title: json['title']?.toString() ?? 'Episode ${json['episode_num'] ?? 1}',
containerExtension: json['container_extension']?.toString() ?? 'mkv',
info: json['info']?.toString(),
cover: json['custom_cover']?.toString() ?? json['cover']?.toString(),
durationSecs: int.tryParse(json['duration_secs']?.toString() ?? '0'),
);
}
// Getter for compatibility
String get streamId => id;
String getStreamUrl(String dns, String username, String password) {
return '$dns/series/$username/$password/$id.${containerExtension ?? 'mkv'}';
}
}
/// Series info with seasons and episodes
class SeriesInfo {
final String seriesId;
final String name;
final String? cover;
final String? plot;
final String? cast;
final String? director;
final String? genre;
final String? releaseDate;
final String? rating;
final List<Season> seasons;
final Map<int, List<Episode>> episodes;
const SeriesInfo({
required this.seriesId,
required this.name,
this.cover,
this.plot,
this.cast,
this.director,
this.genre,
this.releaseDate,
this.rating,
required this.seasons,
required this.episodes,
});
factory SeriesInfo.fromJson(Map<String, dynamic> json) {
final seasonsData = json['seasons'] as List<dynamic>? ?? [];
final seasons = seasonsData.map((s) => Season.fromJson(s as Map<String, dynamic>)).toList();
final episodesData = json['episodes'] as Map<String, dynamic>? ?? {};
final episodes = <int, List<Episode>>{};
episodesData.forEach((seasonNum, episodesList) {
final seasonInt = int.tryParse(seasonNum) ?? 1;
final episodeList = (episodesList as List<dynamic>?)
?.map((e) => Episode.fromJson(e as Map<String, dynamic>, season: seasonInt))
.toList() ?? [];
episodes[seasonInt] = episodeList;
});
final info = json['info'] as Map<String, dynamic>? ?? {};
return SeriesInfo(
seriesId: json['series_id']?.toString() ?? info['series_id']?.toString() ?? '',
name: info['name']?.toString() ?? json['name']?.toString() ?? 'Unknown',
cover: info['cover']?.toString() ?? json['cover']?.toString(),
plot: info['plot']?.toString(),
cast: info['cast']?.toString(),
director: info['director']?.toString(),
genre: info['genre']?.toString(),
releaseDate: info['releaseDate']?.toString(),
rating: info['rating']?.toString(),
seasons: seasons,
episodes: episodes,
);
}
}
class Category {
final String categoryId;
final String categoryName;
const Category({
required this.categoryId,
required this.categoryName,
});
factory Category.fromJson(Map<String, dynamic> json) {
return Category(
categoryId: json['category_id']?.toString() ?? '',
categoryName: json['category_name']?.toString() ?? 'Unknown',
);
}
}
class ShortEPG {
final String? nowPlaying;
final String? nextPlaying;
final double? progress; // 0.0 to 1.0
const ShortEPG({
this.nowPlaying,
this.nextPlaying,
this.progress,
});
factory ShortEPG.fromJson(Map<String, dynamic> json) {
final epgListings = json['epg_listings'] as List?;
if (epgListings == null || epgListings.isEmpty) {
return const ShortEPG();
}
String? nowTitle;
String? nextTitle;
double? currentProgress;
final now = DateTime.now();
// Helper to decode Base64 encoded title with proper UTF-8 support
String decodeTitle(String? encodedTitle) {
if (encodedTitle == null || encodedTitle.isEmpty) return '';
try {
// Try Base64 decode with UTF-8 encoding
final bytes = base64Decode(encodedTitle);
return utf8.decode(bytes, allowMalformed: true);
} catch (_) {
// Not Base64, return as-is
return encodedTitle;
}
}
// Helper to parse timestamp (Unix epoch or ISO format)
DateTime? parseTimestamp(dynamic value) {
if (value == null) return null;
final strValue = value.toString();
// Try parsing as Unix timestamp (seconds)
final intValue = int.tryParse(strValue);
if (intValue != null) {
return DateTime.fromMillisecondsSinceEpoch(intValue * 1000);
}
// Try parsing as ISO date string
return DateTime.tryParse(strValue);
}
for (var i = 0; i < epgListings.length; i++) {
final listing = epgListings[i] as Map<String, dynamic>;
// Parse start/stop timestamps (can be 'start', 'start_timestamp', or Unix epoch)
final start = parseTimestamp(listing['start']) ??
parseTimestamp(listing['start_timestamp']);
final stop = parseTimestamp(listing['stop']) ??
parseTimestamp(listing['end']) ??
parseTimestamp(listing['stop_timestamp']);
if (start != null && stop != null) {
if (now.isAfter(start) && now.isBefore(stop)) {
// Decode title (may be Base64 encoded)
nowTitle = decodeTitle(listing['title']?.toString());
final duration = stop.difference(start).inSeconds;
final elapsed = now.difference(start).inSeconds;
if (duration > 0) {
currentProgress = elapsed / duration;
}
// Get next item
if (i + 1 < epgListings.length) {
final nextListing = epgListings[i + 1] as Map<String, dynamic>;
nextTitle = decodeTitle(nextListing['title']?.toString());
}
break;
}
}
}
return ShortEPG(
nowPlaying: nowTitle,
nextPlaying: nextTitle,
progress: currentProgress,
);
}
}
@@ -1,35 +0,0 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:shared_preferences/shared_preferences.dart';
/// Manages the list of favorite channel IDs
class FavoritesNotifier extends StateNotifier<List<String>> {
FavoritesNotifier() : super([]) {
_loadFavorites();
}
static const String _key = 'xtremflow_favorites';
Future<void> _loadFavorites() async {
final prefs = await SharedPreferences.getInstance();
final favorites = prefs.getStringList(_key) ?? [];
state = favorites;
}
Future<void> toggleFavorite(String streamId) async {
final prefs = await SharedPreferences.getInstance();
if (state.contains(streamId)) {
state = state.where((id) => id != streamId).toList();
} else {
state = [...state, streamId];
}
await prefs.setStringList(_key, state);
}
bool isFavorite(String streamId) {
return state.contains(streamId);
}
}
final favoritesProvider = StateNotifierProvider<FavoritesNotifier, List<String>>((ref) {
return FavoritesNotifier();
});
@@ -1,121 +0,0 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'dart:convert';
/// Playback position state - tracks resume positions for movies and episodes
class PlaybackPositions {
/// Map of content ID to position in seconds (for movies: streamId, for episodes: seriesId_seasonNum_episodeNum)
final Map<String, double> positions;
const PlaybackPositions({
this.positions = const {},
});
PlaybackPositions copyWith({
Map<String, double>? positions,
}) {
return PlaybackPositions(
positions: positions ?? this.positions,
);
}
/// Get saved position for a content (returns 0 if not found)
double getPosition(String contentId) => positions[contentId] ?? 0;
/// Check if content has a saved position (beyond 30 seconds)
bool hasPosition(String contentId) => (positions[contentId] ?? 0) > 30;
}
/// Playback positions notifier with persistence
class PlaybackPositionsNotifier extends StateNotifier<PlaybackPositions> {
static const String _storageKey = 'playback_positions';
SharedPreferences? _prefs;
bool _initialized = false;
PlaybackPositionsNotifier() : super(const PlaybackPositions()) {
_loadPositions();
}
Future<void> _loadPositions() async {
if (_initialized) return;
try {
_prefs = await SharedPreferences.getInstance();
final positionsJson = _prefs?.getString(_storageKey);
if (positionsJson != null) {
final decoded = jsonDecode(positionsJson) as Map<String, dynamic>;
final positions = decoded.map((key, value) =>
MapEntry(key, (value as num).toDouble()),
);
state = PlaybackPositions(positions: positions);
print('Playback positions loaded: ${positions.length} entries');
}
_initialized = true;
} catch (e) {
print('Error loading playback positions: $e');
}
}
Future<void> _savePositions() async {
try {
_prefs ??= await SharedPreferences.getInstance();
await _prefs?.setString(_storageKey, jsonEncode(state.positions));
} catch (e) {
print('Error saving playback positions: $e');
}
}
/// Save position for a content
void savePosition(String contentId, double positionSeconds, double durationSeconds) {
// Don't save if less than 30 seconds in or more than 95% complete
if (positionSeconds < 30 || (durationSeconds > 0 && positionSeconds / durationSeconds > 0.95)) {
// If 95% complete, remove any saved position (mark as fully watched)
if (durationSeconds > 0 && positionSeconds / durationSeconds > 0.95) {
clearPosition(contentId);
}
return;
}
final newPositions = Map<String, double>.from(state.positions);
newPositions[contentId] = positionSeconds;
state = state.copyWith(positions: newPositions);
_savePositions();
}
/// Clear position for a content
void clearPosition(String contentId) {
final newPositions = Map<String, double>.from(state.positions);
newPositions.remove(contentId);
state = state.copyWith(positions: newPositions);
_savePositions();
}
/// Clear all positions
void clearAll() {
state = const PlaybackPositions();
_savePositions();
}
/// Get formatted resume time
static String formatTime(double seconds) {
final mins = (seconds / 60).floor();
final secs = (seconds % 60).floor();
if (mins >= 60) {
final hours = (mins / 60).floor();
final remainingMins = mins % 60;
return '${hours}h ${remainingMins}m';
}
return '${mins}m ${secs}s';
}
}
/// Provider for playback positions
final playbackPositionsProvider =
StateNotifierProvider<PlaybackPositionsNotifier, PlaybackPositions>((ref) {
return PlaybackPositionsNotifier();
});
@@ -1,415 +0,0 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/services/settings_api_service.dart';
import '../../auth/providers/auth_provider.dart';
/// Streaming quality presets for Live TV
enum StreamQuality {
low, // 1.5 Mbps, CRF 26
medium, // 3 Mbps, CRF 23
high, // 5 Mbps, CRF 20
}
/// Buffer size presets for Live TV
enum BufferSize {
low, // 2s segments, 4MB buffer
medium, // 4s segments, 8MB buffer
high, // 6s segments, 12MB buffer
}
/// Connection timeout presets
enum ConnectionTimeout {
short, // 15 seconds
medium, // 30 seconds
long, // 60 seconds
}
/// EPG cache duration presets
enum EpgCacheDuration {
short, // 5 minutes
medium, // 15 minutes
long, // 60 minutes
}
/// Transcoding mode for Live TV
enum TranscodingMode {
auto, // Auto-detect best mode
forced, // Always transcode
disabled, // Direct stream (no transcoding)
}
/// Keys for API JSON storage
class _SettingsKeys {
// Filters
static const String liveTvFilter = 'filter_live_tv';
static const String moviesFilter = 'filter_movies';
static const String seriesFilter = 'filter_series';
// Streaming settings
static const String streamQuality = 'stream_quality';
static const String bufferSize = 'buffer_size';
static const String connectionTimeout = 'connection_timeout';
static const String autoReconnect = 'auto_reconnect';
static const String epgCacheDuration = 'epg_cache_duration';
static const String transcodingMode = 'transcoding_mode';
static const String preferDirectPlay = 'prefer_direct_play';
// Player Display Settings
static const String showClock = 'show_clock';
static const String preferredAspectRatio = 'aspect_ratio';
}
/// Settings state for IPTV preferences with persistence
class IptvSettings {
// Category filters
final String liveTvCategoryFilter;
final String moviesCategoryFilter;
final String seriesCategoryFilter;
// Streaming settings (Live TV only)
final StreamQuality streamQuality;
final BufferSize bufferSize;
final ConnectionTimeout connectionTimeout;
final bool autoReconnect;
final EpgCacheDuration epgCacheDuration;
final TranscodingMode transcodingMode;
final bool preferDirectPlay;
// Player Display Settings
final bool showClock;
final String preferredAspectRatio;
const IptvSettings({
// Filters
this.liveTvCategoryFilter = '',
this.moviesCategoryFilter = '',
this.seriesCategoryFilter = '',
// Streaming defaults
this.streamQuality = StreamQuality.medium,
this.bufferSize = BufferSize.medium,
this.connectionTimeout = ConnectionTimeout.medium,
this.autoReconnect = true,
this.epgCacheDuration = EpgCacheDuration.medium,
this.transcodingMode = TranscodingMode.auto,
this.preferDirectPlay = false,
// Player defaults
this.showClock = false,
this.preferredAspectRatio = 'contain',
});
IptvSettings copyWith({
String? liveTvCategoryFilter,
String? moviesCategoryFilter,
String? seriesCategoryFilter,
StreamQuality? streamQuality,
BufferSize? bufferSize,
ConnectionTimeout? connectionTimeout,
bool? autoReconnect,
EpgCacheDuration? epgCacheDuration,
TranscodingMode? transcodingMode,
bool? preferDirectPlay,
bool? showClock,
String? preferredAspectRatio,
}) {
return IptvSettings(
liveTvCategoryFilter: liveTvCategoryFilter ?? this.liveTvCategoryFilter,
moviesCategoryFilter: moviesCategoryFilter ?? this.moviesCategoryFilter,
seriesCategoryFilter: seriesCategoryFilter ?? this.seriesCategoryFilter,
streamQuality: streamQuality ?? this.streamQuality,
bufferSize: bufferSize ?? this.bufferSize,
connectionTimeout: connectionTimeout ?? this.connectionTimeout,
autoReconnect: autoReconnect ?? this.autoReconnect,
epgCacheDuration: epgCacheDuration ?? this.epgCacheDuration,
transcodingMode: transcodingMode ?? this.transcodingMode,
preferDirectPlay: preferDirectPlay ?? this.preferDirectPlay,
showClock: showClock ?? this.showClock,
preferredAspectRatio: preferredAspectRatio ?? this.preferredAspectRatio,
);
}
// ===== Streaming Value Getters =====
/// Get bitrate in kbps based on quality setting
int get bitrateKbps {
switch (streamQuality) {
case StreamQuality.low: return 1500;
case StreamQuality.medium: return 3000;
case StreamQuality.high: return 5000;
}
}
/// Get CRF value based on quality setting
int get crfValue {
switch (streamQuality) {
case StreamQuality.low: return 26;
case StreamQuality.medium: return 23;
case StreamQuality.high: return 20;
}
}
/// Get HLS segment duration in seconds
int get hlsSegmentDuration {
switch (bufferSize) {
case BufferSize.low: return 2;
case BufferSize.medium: return 4;
case BufferSize.high: return 6;
}
}
/// Get buffer size in KB
int get bufferSizeKb {
switch (bufferSize) {
case BufferSize.low: return 4000;
case BufferSize.medium: return 8000;
case BufferSize.high: return 12000;
}
}
/// Get connection timeout in seconds
int get timeoutSeconds {
switch (connectionTimeout) {
case ConnectionTimeout.short: return 15;
case ConnectionTimeout.medium: return 30;
case ConnectionTimeout.long: return 60;
}
}
/// Get EPG cache duration in minutes
int get epgCacheMinutes {
switch (epgCacheDuration) {
case EpgCacheDuration.short: return 5;
case EpgCacheDuration.medium: return 15;
case EpgCacheDuration.long: return 60;
}
}
/// Get transcoding mode string for FFmpeg URL parameter
String get modeString {
switch (transcodingMode) {
case TranscodingMode.disabled: return 'direct'; // Passthrough, 0% CPU
case TranscodingMode.forced: return 'transcode';
case TranscodingMode.auto: return 'auto';
}
}
// ===== Filter Methods =====
static List<String> _parseKeywords(String filter) {
if (filter.isEmpty) return [];
return filter
.split(',')
.map((s) => s.trim().toUpperCase())
.where((s) => s.isNotEmpty)
.toList();
}
List<String> get liveTvKeywords => _parseKeywords(liveTvCategoryFilter);
List<String> get moviesKeywords => _parseKeywords(moviesCategoryFilter);
List<String> get seriesKeywords => _parseKeywords(seriesCategoryFilter);
bool matchesLiveTvFilter(String categoryName) {
return _matchesFilter(categoryName, liveTvKeywords);
}
bool matchesMoviesFilter(String categoryName) {
return _matchesFilter(categoryName, moviesKeywords);
}
bool matchesSeriesFilter(String categoryName) {
return _matchesFilter(categoryName, seriesKeywords);
}
bool _matchesFilter(String categoryName, List<String> keywords) {
if (keywords.isEmpty) return true;
final upperName = categoryName.toUpperCase();
return keywords.any((keyword) => upperName.contains(keyword));
}
bool matchesFilter(String categoryName) => matchesLiveTvFilter(categoryName);
}
/// Settings notifier with API persistence only (No Local Cache)
class IptvSettingsNotifier extends StateNotifier<IptvSettings> {
final SettingsApiService _apiService = SettingsApiService();
final Ref _ref;
// ignore: unused_field
bool _initialized = false;
IptvSettingsNotifier(this._ref) : super(const IptvSettings()) {
_init();
_setupAuthListener();
}
void _init() {
final authState = _ref.read(authProvider);
if (authState.isAuthenticated) {
_fetchFromApi();
}
}
void _setupAuthListener() {
_ref.listen<AuthState>(authProvider, (previous, next) {
// Sync on login
if (previous?.isAuthenticated == false && next.isAuthenticated) {
_fetchFromApi();
}
// Reset on logout
if (previous?.isAuthenticated == true && !next.isAuthenticated) {
state = const IptvSettings();
_initialized = false;
}
});
}
Future<void> _fetchFromApi() async {
try {
final remoteSettings = await _apiService.getSettings();
if (remoteSettings != null && remoteSettings.isNotEmpty) {
// Helper to safely cast dynamic types from JSON
T? getValue<T>(String key) => remoteSettings[key] as T?;
state = state.copyWith(
liveTvCategoryFilter: getValue<String>(_SettingsKeys.liveTvFilter),
moviesCategoryFilter: getValue<String>(_SettingsKeys.moviesFilter),
seriesCategoryFilter: getValue<String>(_SettingsKeys.seriesFilter),
streamQuality: remoteSettings[_SettingsKeys.streamQuality] != null
? StreamQuality.values[remoteSettings[_SettingsKeys.streamQuality] as int] : null,
bufferSize: remoteSettings[_SettingsKeys.bufferSize] != null
? BufferSize.values[remoteSettings[_SettingsKeys.bufferSize] as int] : null,
connectionTimeout: remoteSettings[_SettingsKeys.connectionTimeout] != null
? ConnectionTimeout.values[remoteSettings[_SettingsKeys.connectionTimeout] as int] : null,
autoReconnect: getValue<bool>(_SettingsKeys.autoReconnect),
epgCacheDuration: remoteSettings[_SettingsKeys.epgCacheDuration] != null
? EpgCacheDuration.values[remoteSettings[_SettingsKeys.epgCacheDuration] as int] : null,
transcodingMode: remoteSettings[_SettingsKeys.transcodingMode] != null
? TranscodingMode.values[remoteSettings[_SettingsKeys.transcodingMode] as int] : null,
preferDirectPlay: getValue<bool>(_SettingsKeys.preferDirectPlay),
showClock: getValue<bool>(_SettingsKeys.showClock),
preferredAspectRatio: getValue<String>(_SettingsKeys.preferredAspectRatio),
);
}
_initialized = true;
} catch (e) {
print('Error syncing settings from API: $e');
}
}
Future<void> _saveToApi() async {
final authState = _ref.read(authProvider);
if (!authState.isAuthenticated) {
print('[SettingsProvider] _saveToApi: Not authenticated, skipping save');
return;
}
// Create map from state
final settingsMap = {
_SettingsKeys.liveTvFilter: state.liveTvCategoryFilter,
_SettingsKeys.moviesFilter: state.moviesCategoryFilter,
_SettingsKeys.seriesFilter: state.seriesCategoryFilter,
_SettingsKeys.streamQuality: state.streamQuality.index,
_SettingsKeys.bufferSize: state.bufferSize.index,
_SettingsKeys.connectionTimeout: state.connectionTimeout.index,
_SettingsKeys.autoReconnect: state.autoReconnect,
_SettingsKeys.epgCacheDuration: state.epgCacheDuration.index,
_SettingsKeys.transcodingMode: state.transcodingMode.index,
_SettingsKeys.preferDirectPlay: state.preferDirectPlay,
_SettingsKeys.showClock: state.showClock,
_SettingsKeys.preferredAspectRatio: state.preferredAspectRatio,
};
print('[SettingsProvider] _saveToApi: Saving settings...');
final success = await _apiService.saveSettings(settingsMap);
print('[SettingsProvider] _saveToApi: Save result: $success');
}
// ===== Setters with Auto-Save =====
void setLiveTvFilter(String filter) {
state = state.copyWith(liveTvCategoryFilter: filter);
_saveToApi();
}
void setMoviesFilter(String filter) {
state = state.copyWith(moviesCategoryFilter: filter);
_saveToApi();
}
void setSeriesFilter(String filter) {
state = state.copyWith(seriesCategoryFilter: filter);
_saveToApi();
}
void clearLiveTvFilter() => setLiveTvFilter('');
void clearMoviesFilter() => setMoviesFilter('');
void clearSeriesFilter() => setSeriesFilter('');
void clearAllFilters() {
state = state.copyWith(
liveTvCategoryFilter: '',
moviesCategoryFilter: '',
seriesCategoryFilter: '',
);
_saveToApi();
}
// ===== Streaming Setters =====
void setStreamQuality(StreamQuality quality) {
state = state.copyWith(streamQuality: quality);
_saveToApi();
}
void setBufferSize(BufferSize buffer) {
state = state.copyWith(bufferSize: buffer);
_saveToApi();
}
void setConnectionTimeout(ConnectionTimeout timeout) {
state = state.copyWith(connectionTimeout: timeout);
_saveToApi();
}
void setAutoReconnect(bool value) {
state = state.copyWith(autoReconnect: value);
_saveToApi();
}
void setEpgCacheDuration(EpgCacheDuration duration) {
state = state.copyWith(epgCacheDuration: duration);
_saveToApi();
}
void setTranscodingMode(TranscodingMode mode) {
state = state.copyWith(transcodingMode: mode);
_saveToApi();
}
void setPreferDirectPlay(bool value) {
state = state.copyWith(preferDirectPlay: value);
_saveToApi();
}
// ===== Player Display Setters =====
void setShowClock(bool value) {
state = state.copyWith(showClock: value);
_saveToApi();
}
void setPreferredAspectRatio(String value) {
state = state.copyWith(preferredAspectRatio: value);
_saveToApi();
}
// Legacy methods
void setCategoryFilter(String filter) => setLiveTvFilter(filter);
void clearCategoryFilter() => clearLiveTvFilter();
}
/// Provider for IPTV settings
final iptvSettingsProvider =
StateNotifierProvider<IptvSettingsNotifier, IptvSettings>((ref) {
// Watch auth provider to re-fetch settings on login/logout
// We just watch it to ensure the provider tree updates, but the listener above handles logic
ref.watch(authProvider);
return IptvSettingsNotifier(ref);
});
@@ -1,157 +0,0 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'dart:convert';
/// Watch history state - tracks which content has been watched
class WatchHistory {
/// Set of watched movie IDs
final Set<String> watchedMovies;
/// Set of watched episode IDs (format: seriesId_seasonNum_episodeNum)
final Set<String> watchedEpisodes;
const WatchHistory({
this.watchedMovies = const <String>{},
this.watchedEpisodes = const <String>{},
});
WatchHistory copyWith({
Set<String>? watchedMovies,
Set<String>? watchedEpisodes,
}) {
return WatchHistory(
watchedMovies: watchedMovies ?? this.watchedMovies,
watchedEpisodes: watchedEpisodes ?? this.watchedEpisodes,
);
}
/// Check if a movie has been watched
bool isMovieWatched(String movieId) => watchedMovies.contains(movieId);
/// Check if an episode has been watched
bool isEpisodeWatched(String episodeId) => watchedEpisodes.contains(episodeId);
/// Generate episode key
static String episodeKey(String seriesId, int seasonNum, int episodeNum) {
return '${seriesId}_${seasonNum}_$episodeNum';
}
}
/// Watch history notifier with persistence
class WatchHistoryNotifier extends StateNotifier<WatchHistory> {
static const String _moviesKey = 'watch_history_movies';
static const String _episodesKey = 'watch_history_episodes';
SharedPreferences? _prefs;
bool _initialized = false;
WatchHistoryNotifier() : super(const WatchHistory()) {
_loadHistory();
}
Future<void> _loadHistory() async {
if (_initialized) return;
try {
_prefs = await SharedPreferences.getInstance();
final moviesJson = _prefs?.getString(_moviesKey);
final episodesJson = _prefs?.getString(_episodesKey);
final movies = moviesJson != null
? Set<String>.from(jsonDecode(moviesJson) as List)
: <String>{};
final episodes = episodesJson != null
? Set<String>.from(jsonDecode(episodesJson) as List)
: <String>{};
state = WatchHistory(
watchedMovies: movies,
watchedEpisodes: episodes,
);
_initialized = true;
print('Watch history loaded: ${movies.length} movies, ${episodes.length} episodes');
} catch (e) {
print('Error loading watch history: $e');
}
}
Future<void> _saveMovies() async {
try {
_prefs ??= await SharedPreferences.getInstance();
await _prefs?.setString(_moviesKey, jsonEncode(state.watchedMovies.toList()));
} catch (e) {
print('Error saving movie watch history: $e');
}
}
Future<void> _saveEpisodes() async {
try {
_prefs ??= await SharedPreferences.getInstance();
await _prefs?.setString(_episodesKey, jsonEncode(state.watchedEpisodes.toList()));
} catch (e) {
print('Error saving episode watch history: $e');
}
}
/// Mark a movie as watched
void markMovieWatched(String movieId) {
final newSet = Set<String>.from(state.watchedMovies)..add(movieId);
state = state.copyWith(watchedMovies: newSet);
_saveMovies();
}
/// Mark a movie as unwatched
void markMovieUnwatched(String movieId) {
final newSet = Set<String>.from(state.watchedMovies)..remove(movieId);
state = state.copyWith(watchedMovies: newSet);
_saveMovies();
}
/// Toggle movie watched status
void toggleMovieWatched(String movieId) {
if (state.isMovieWatched(movieId)) {
markMovieUnwatched(movieId);
} else {
markMovieWatched(movieId);
}
}
/// Mark an episode as watched
void markEpisodeWatched(String episodeKey) {
final newSet = Set<String>.from(state.watchedEpisodes)..add(episodeKey);
state = state.copyWith(watchedEpisodes: newSet);
_saveEpisodes();
}
/// Mark an episode as unwatched
void markEpisodeUnwatched(String episodeKey) {
final newSet = Set<String>.from(state.watchedEpisodes)..remove(episodeKey);
state = state.copyWith(watchedEpisodes: newSet);
_saveEpisodes();
}
/// Toggle episode watched status
void toggleEpisodeWatched(String episodeKey) {
if (state.isEpisodeWatched(episodeKey)) {
markEpisodeUnwatched(episodeKey);
} else {
markEpisodeWatched(episodeKey);
}
}
/// Clear all watch history
void clearAll() {
state = const WatchHistory();
_saveMovies();
_saveEpisodes();
}
}
/// Provider for watch history
final watchHistoryProvider =
StateNotifierProvider<WatchHistoryNotifier, WatchHistory>((ref) {
return WatchHistoryNotifier();
});
@@ -1,88 +0,0 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/models/playlist_config.dart';
import '../services/xtream_service.dart';
/// Family provider for Xtream service - one instance per playlist
final xtreamServiceProvider = Provider.family<XtreamService, PlaylistConfig>((ref, playlist) {
final service = XtreamService();
service.setPlaylist(playlist);
ref.onDispose(() => service.dispose());
return service;
});
/// Singleton Xtream service provider (legacy, for activeXtreamServiceProvider)
final _singletonXtreamServiceProvider = Provider<XtreamService>((ref) {
final service = XtreamService();
ref.onDispose(() => service.dispose());
return service;
});
/// Current selected playlist provider
final selectedPlaylistProvider = StateProvider<PlaylistConfig?>((ref) => null);
/// Provider that watches for playlist changes and updates Xtream service
final activeXtreamServiceProvider = Provider<XtreamService>((ref) {
final service = ref.watch(_singletonXtreamServiceProvider);
final playlist = ref.watch(selectedPlaylistProvider);
if (playlist != null) {
service.setPlaylist(playlist);
}
return service;
});
/// Provider for live TV channels grouped by category
final liveChannelsProvider = FutureProvider.autoDispose((ref) async {
final service = ref.watch(activeXtreamServiceProvider);
return await service.getLiveChannels();
});
/// Provider for live TV channels by playlist (family provider)
final liveChannelsByPlaylistProvider = FutureProvider.family.autoDispose((ref, PlaylistConfig playlist) async {
final service = ref.watch(xtreamServiceProvider(playlist));
return await service.getLiveChannels();
});
/// Provider for VOD items (movies) grouped by category
final vodItemsProvider = FutureProvider.autoDispose((ref) async {
final service = ref.watch(activeXtreamServiceProvider);
return await service.getVodItems();
});
/// Provider for series grouped by category
final seriesProvider = FutureProvider.autoDispose((ref) async {
final service = ref.watch(activeXtreamServiceProvider);
return await service.getSeries();
});
/// Provider for EPG of a specific stream
final epgProvider = FutureProvider.family.autoDispose((ref, String streamId) async {
final service = ref.watch(activeXtreamServiceProvider);
return await service.getShortEpg(streamId);
});
/// EPG request key for family provider (combines playlist and streamId)
class EpgRequestKey {
final PlaylistConfig playlist;
final String streamId;
const EpgRequestKey({required this.playlist, required this.streamId});
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is EpgRequestKey &&
runtimeType == other.runtimeType &&
playlist == other.playlist &&
streamId == other.streamId;
@override
int get hashCode => playlist.hashCode ^ streamId.hashCode;
}
/// Provider for EPG with playlist context
final epgByPlaylistProvider = FutureProvider.family.autoDispose((ref, EpgRequestKey key) async {
final service = ref.watch(xtreamServiceProvider(key.playlist));
return await service.getShortEpg(key.streamId);
});
@@ -1,254 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:google_fonts/google_fonts.dart';
import '../../../core/models/playlist_config.dart';
import '../../../core/widgets/digital_clock.dart';
import '../widgets/live_tv_tab.dart';
import '../widgets/movies_tab.dart';
import '../widgets/series_tab.dart';
import '../widgets/settings_tab.dart';
/// Apple TV Style Dashboard
///
/// Uses a floating Top Bar for navigation instead of a sidebar.
/// Content flows underneath the top bar.
class DashboardScreen extends ConsumerStatefulWidget {
final PlaylistConfig playlist;
const DashboardScreen({
super.key,
required this.playlist,
});
@override
ConsumerState<DashboardScreen> createState() => _DashboardScreenState();
}
class _DashboardScreenState extends ConsumerState<DashboardScreen>
with SingleTickerProviderStateMixin {
late TabController _tabController;
final List<String> _tabs = ['Live TV', 'Films', 'Séries', 'Réglages'];
final List<IconData> _icons = [
Icons.live_tv,
Icons.movie,
Icons.tv,
Icons.settings,
];
@override
void initState() {
super.initState();
_tabController = TabController(length: 4, vsync: this);
}
@override
void dispose() {
_tabController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
// Glassmorphism Top Bar
return Scaffold(
extendBodyBehindAppBar: true, // Content behind app bar
backgroundColor: Colors.transparent, // Transparent to show gradient
appBar: PreferredSize(
preferredSize: const Size.fromHeight(80),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Colors.black.withOpacity(0.6), // Subtler top shadow
Colors.transparent,
],
),
),
child: Row(
children: [
// 1. Logo Area (Fixed Width for centering balance)
SizedBox(
width: 200,
child: Align(
alignment: Alignment.centerLeft,
child: _buildLogo(),
),
),
// 2. Centered Navigation Tabs (Expanded to fill space, then Centered)
Expanded(
child: Center(
child: Container(
height: 50,
constraints: const BoxConstraints(
maxWidth: 600,
), // Prevent taking too much space
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.1),
borderRadius: BorderRadius.circular(30),
border: Border.all(color: Colors.white.withOpacity(0.05)),
),
child: TabBar(
controller: _tabController,
isScrollable: true,
dividerColor: Colors.transparent,
indicator: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(25),
),
indicatorSize: TabBarIndicatorSize.tab,
indicatorPadding: const EdgeInsets.all(4),
labelColor: Colors.black,
unselectedLabelColor:
Colors.white70, // Fixed: Improved readability
labelStyle: GoogleFonts.inter(
fontWeight: FontWeight.w700,
fontSize: 13,
),
overlayColor: WidgetStateProperty.all(Colors.transparent),
tabs: List.generate(_tabs.length, (index) {
return Tab(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Row(
children: [
Icon(_icons[index], size: 18),
const SizedBox(width: 8),
Text(_tabs[index]),
],
),
),
);
}),
),
),
),
),
// 3. Search / Profile (Fixed Width for centering balance)
SizedBox(
width: 200,
child: Align(
alignment: Alignment.centerRight,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
icon: const Icon(Icons.search, color: Colors.white),
onPressed: () {
// Global search trigger
},
),
const SizedBox(width: 16),
const DigitalClock(),
const SizedBox(width: 16),
const CircleAvatar(
radius: 16,
backgroundColor: Colors.white24,
child:
Icon(Icons.person, size: 18, color: Colors.white),
),
],
),
),
),
],
),
),
),
body: Stack(
children: [
// Background Gradient (Apple TV Depth Effect)
Container(
decoration: const BoxDecoration(
gradient: RadialGradient(
center: Alignment(0, -0.2), // Slightly above center
radius: 1.5,
colors: [
Color(0xFF2C2C2E), // Dark Grey center (Light Source)
Color(0xFF000000), // Pure Black edges
],
stops: [0.0, 1.0],
),
),
),
// Content
TabBarView(
controller: _tabController,
physics:
const NeverScrollableScrollPhysics(), // Disable swipe to avoid gesture conflicts
children: [
// Padding top to account for the transparent AppBar
Padding(
padding: const EdgeInsets.only(top: 80),
child: LiveTVTab(playlist: widget.playlist),
),
Padding(
padding: const EdgeInsets.only(top: 80),
child: MoviesTab(playlist: widget.playlist),
),
Padding(
padding: const EdgeInsets.only(top: 80),
child: SeriesTab(playlist: widget.playlist),
),
const Padding(
padding: EdgeInsets.only(top: 80),
child: SettingsTab(),
),
],
),
],
),
);
}
Widget _buildLogo() {
return Row(
children: [
Container(
width: 36,
height: 36,
decoration: BoxDecoration(
gradient: const LinearGradient(
colors: [
Color(0xFF43cea2),
Color(0xFF185a9d),
],
), // Green to Blue
borderRadius: BorderRadius.circular(10),
),
child: const Icon(Icons.play_arrow_rounded, color: Colors.white),
),
const SizedBox(width: 12),
Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Xtrem',
style: GoogleFonts.inter(
fontWeight: FontWeight.w800,
fontSize: 16,
color: Colors.white,
),
),
Text(
'Flow',
style: GoogleFonts.inter(
fontWeight: FontWeight.w300,
fontSize: 16,
color: Colors.white.withOpacity(0.8),
height: 0.8,
),
),
],
),
],
);
}
}
File diff suppressed because it is too large. Load diff
@@ -1,249 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:google_fonts/google_fonts.dart';
import '../../../core/services/playlist_api_service.dart';
import '../../../core/models/playlist_config.dart';
import '../../auth/providers/auth_provider.dart';
/// Provider for fetching playlists from API
final playlistsProvider = FutureProvider<List<PlaylistConfig>>((ref) async {
final service = PlaylistApiService();
return service.getPlaylists();
});
class PlaylistSelectionScreen extends ConsumerWidget {
const PlaylistSelectionScreen({super.key});
@override
@override
Widget build(BuildContext context, WidgetRef ref) {
final currentUser = ref.watch(authProvider).currentUser;
final playlistsAsync = ref.watch(playlistsProvider);
return Scaffold(
extendBodyBehindAppBar: true,
appBar: AppBar(
title: Text(
'Select Playlist',
style: GoogleFonts.roboto(
fontWeight: FontWeight.w600,
color: Colors.white,
),
),
backgroundColor: Colors.transparent,
elevation: 0,
actions: [
if (currentUser?.isAdmin ?? false)
IconButton(
icon:
const Icon(Icons.admin_panel_settings, color: Colors.white70),
onPressed: () => context.go('/admin'),
tooltip: 'Admin Panel',
),
IconButton(
icon: const Icon(Icons.logout, color: Colors.white70),
onPressed: () async {
await ref.read(authProvider.notifier).logout();
if (context.mounted) {
context.go('/login');
}
},
tooltip: 'Logout',
),
],
),
body: Container(
decoration: const BoxDecoration(
gradient: RadialGradient(
center: Alignment.center,
radius: 1.5,
colors: [
Color(0xFF2C2C2E), // Dark Grey (Apple TV Surface)
Color(0xFF000000), // Pure Black
],
stops: [0.0, 1.0],
),
),
child: playlistsAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (error, stack) => Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.error_outline, size: 64, color: Colors.red.shade400),
const SizedBox(height: 16),
Text(
'Error loading playlists',
style: GoogleFonts.roboto(
fontSize: 18,
color: Colors.white70,
),
),
const SizedBox(height: 8),
ElevatedButton(
onPressed: () => ref.refresh(playlistsProvider),
child: const Text('Retry'),
),
],
),
),
data: (playlists) {
if (playlists.isEmpty) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(
Icons.playlist_remove,
size: 64,
color: Colors.white24,
),
const SizedBox(height: 16),
Text(
'No playlists available',
style: GoogleFonts.roboto(
fontSize: 18,
color: Colors.white60,
),
),
const SizedBox(height: 8),
Text(
currentUser?.isAdmin ?? false
? 'Add playlists in Admin Panel'
: 'Contact administrator',
style: GoogleFonts.roboto(
fontSize: 14,
color: Colors.white38,
),
),
],
),
);
}
return GridView.builder(
padding: const EdgeInsets.fromLTRB(
24,
100,
24,
24,
), // Top padding for transparent AppBar
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 3,
crossAxisSpacing: 24,
mainAxisSpacing: 24,
childAspectRatio: 1.5,
),
itemCount: playlists.length,
itemBuilder: (context, index) {
final playlist = playlists[index];
return _PlaylistCard(
playlist: playlist,
onTap: () {
context.go('/dashboard', extra: playlist);
},
);
},
);
},
),
),
);
}
}
class _PlaylistCard extends StatefulWidget {
final PlaylistConfig playlist;
final VoidCallback onTap;
const _PlaylistCard({
required this.playlist,
required this.onTap,
});
@override
State<_PlaylistCard> createState() => _PlaylistCardState();
}
class _PlaylistCardState extends State<_PlaylistCard> {
bool _isHovered = false;
@override
Widget build(BuildContext context) {
return MouseRegion(
onEnter: (_) => setState(() => _isHovered = true),
onExit: (_) => setState(() => _isHovered = false),
cursor: SystemMouseCursors.click,
child: GestureDetector(
onTap: widget.onTap,
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
decoration: BoxDecoration(
color: _isHovered
? Colors.white.withOpacity(0.15)
: Colors.white.withOpacity(0.05), // Glass effect
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: _isHovered
? Colors.white.withOpacity(0.5)
: Colors.white.withOpacity(0.1),
width: 1.5,
),
boxShadow: _isHovered
? [
BoxShadow(
color: Colors.black.withOpacity(0.3),
blurRadius: 20,
offset: const Offset(0, 10),
),
]
: [],
),
padding: const EdgeInsets.all(24),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: 56,
height: 56,
decoration: BoxDecoration(
color: Colors.blue.withOpacity(0.2), // Accent color tint
shape: BoxShape.circle,
),
child: const Icon(
Icons.playlist_play,
size: 32,
color: Colors.white, // White icon
),
),
const SizedBox(height: 16),
Text(
widget.playlist.name,
style: GoogleFonts.outfit(
// Using Outfit for modern look
fontSize: 20,
fontWeight: FontWeight.bold,
color: Colors.white,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 6),
Text(
Uri.tryParse(widget.playlist.dns)?.host ?? widget.playlist.dns,
style: GoogleFonts.inter(
fontSize: 13,
color: Colors.white54,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
),
),
);
}
}
@@ -1,367 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:google_fonts/google_fonts.dart';
import '../../../core/models/playlist_config.dart';
import '../models/xtream_models.dart';
import '../providers/xtream_provider.dart';
import '../providers/watch_history_provider.dart';
import 'player_screen.dart';
/// Series detail screen showing seasons and episodes
class SeriesDetailScreen extends ConsumerStatefulWidget {
final Series series;
final PlaylistConfig playlist;
const SeriesDetailScreen({
super.key,
required this.series,
required this.playlist,
});
@override
ConsumerState<SeriesDetailScreen> createState() => _SeriesDetailScreenState();
}
class _SeriesDetailScreenState extends ConsumerState<SeriesDetailScreen> {
SeriesInfo? _seriesInfo;
bool _isLoading = true;
String? _error;
int _selectedSeason = 1;
@override
void initState() {
super.initState();
_loadSeriesInfo();
}
Future<void> _loadSeriesInfo() async {
try {
setState(() {
_isLoading = true;
_error = null;
});
final service = ref.read(xtreamServiceProvider(widget.playlist));
final info = await service.getSeriesInfo(widget.series.seriesId);
setState(() {
_seriesInfo = info;
_isLoading = false;
// Select first available season
if (info.episodes.isNotEmpty) {
_selectedSeason = info.episodes.keys.first;
}
});
} catch (e) {
setState(() {
_error = e.toString();
_isLoading = false;
});
}
}
String? _formatRating(String? rating) {
if (rating == null || rating.isEmpty) return null;
final value = double.tryParse(rating);
if (value != null) {
return value.toStringAsFixed(1);
}
return rating;
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(widget.series.name),
backgroundColor: Colors.black87,
leading: IconButton(
icon: const Icon(Icons.arrow_back, color: Colors.white),
onPressed: () => Navigator.pop(context),
),
),
backgroundColor: Colors.grey.shade900,
body: _isLoading
? const Center(child: CircularProgressIndicator())
: _error != null
? Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.error_outline, size: 48, color: Colors.red),
const SizedBox(height: 16),
Text('Error: $_error', style: const TextStyle(color: Colors.white70)),
const SizedBox(height: 16),
ElevatedButton(
onPressed: _loadSeriesInfo,
child: const Text('Retry'),
),
],
),
)
: _buildContent(),
);
}
Widget _buildContent() {
if (_seriesInfo == null) return const SizedBox.shrink();
final currentEpisodes = _seriesInfo!.episodes[_selectedSeason] ?? [];
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Left panel: Series info and season selector
SizedBox(
width: 280,
child: SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Cover image
if (_seriesInfo!.cover != null)
ClipRRect(
borderRadius: BorderRadius.circular(8),
child: CachedNetworkImage(
imageUrl: _seriesInfo!.cover!,
width: 248,
height: 350,
fit: BoxFit.cover,
errorWidget: (_, __, ___) => Container(
width: 248,
height: 350,
color: Colors.grey.shade800,
child: const Icon(Icons.tv, size: 64, color: Colors.white54),
),
),
),
const SizedBox(height: 16),
// Title
Text(
_seriesInfo!.name,
style: GoogleFonts.roboto(
fontSize: 18,
fontWeight: FontWeight.bold,
color: Colors.white,
),
),
const SizedBox(height: 8),
// Rating
if (_seriesInfo!.rating != null)
Row(
children: [
Icon(Icons.star, size: 16, color: Colors.amber.shade700),
const SizedBox(width: 4),
Text(
_formatRating(_seriesInfo!.rating!)!,
style: GoogleFonts.roboto(fontSize: 13, color: Colors.white70),
),
],
),
const SizedBox(height: 12),
// Plot
if (_seriesInfo!.plot != null && _seriesInfo!.plot!.isNotEmpty)
Text(
_seriesInfo!.plot!,
style: GoogleFonts.roboto(fontSize: 12, color: Colors.white60),
maxLines: 5,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 16),
// Season selector
Text(
'Seasons',
style: GoogleFonts.roboto(
fontSize: 14,
fontWeight: FontWeight.w600,
color: Colors.white,
),
),
const SizedBox(height: 8),
Wrap(
spacing: 8,
runSpacing: 8,
children: _seriesInfo!.episodes.keys.map((seasonNum) {
final isSelected = seasonNum == _selectedSeason;
return ChoiceChip(
label: Text('S$seasonNum'),
selected: isSelected,
onSelected: (selected) {
if (selected) {
setState(() => _selectedSeason = seasonNum);
}
},
selectedColor: Colors.blue.shade700,
backgroundColor: Colors.grey.shade800,
labelStyle: GoogleFonts.roboto(
color: isSelected ? Colors.white : Colors.white70,
fontSize: 12,
),
);
}).toList(),
),
],
),
),
),
// Divider
Container(width: 1, color: Colors.grey.shade700),
// Right panel: Episodes list
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.all(16),
child: Text(
'Season $_selectedSeason - ${currentEpisodes.length} Episodes',
style: GoogleFonts.roboto(
fontSize: 16,
fontWeight: FontWeight.w600,
color: Colors.white,
),
),
),
Expanded(
child: currentEpisodes.isEmpty
? Center(
child: Text(
'No episodes found',
style: GoogleFonts.roboto(color: Colors.white54),
),
)
: ListView.builder(
padding: const EdgeInsets.symmetric(horizontal: 16),
itemCount: currentEpisodes.length,
itemBuilder: (context, index) {
final episode = currentEpisodes[index];
return _buildEpisodeTile(episode);
},
),
),
],
),
),
],
);
}
Widget _buildEpisodeTile(Episode episode) {
final watchHistory = ref.watch(watchHistoryProvider);
final episodeKey = WatchHistory.episodeKey(
widget.series.seriesId,
_selectedSeason,
episode.episodeNum,
);
final isWatched = watchHistory.isEpisodeWatched(episodeKey);
return Card(
color: Colors.grey.shade800,
margin: const EdgeInsets.only(bottom: 8),
child: InkWell(
onTap: () {
// Mark as watched when playing
ref.read(watchHistoryProvider.notifier).markEpisodeWatched(episodeKey);
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => PlayerScreen(
streamId: episode.id,
title: '${widget.series.name} - ${episode.title}',
playlist: widget.playlist,
streamType: StreamType.series,
containerExtension: episode.containerExtension ?? 'mkv',
),
),
);
},
onLongPress: () {
// Toggle watched on long press
ref.read(watchHistoryProvider.notifier).toggleEpisodeWatched(episodeKey);
},
borderRadius: BorderRadius.circular(8),
child: Padding(
padding: const EdgeInsets.all(12),
child: Row(
children: [
// Episode number badge
Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: isWatched ? Colors.green : Colors.blue.shade700,
borderRadius: BorderRadius.circular(8),
),
child: Center(
child: isWatched
? const Icon(Icons.check, color: Colors.white, size: 20)
: Text(
'${episode.episodeNum}',
style: GoogleFonts.roboto(
fontSize: 16,
fontWeight: FontWeight.bold,
color: Colors.white,
),
),
),
),
const SizedBox(width: 12),
// Episode info
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
episode.title,
style: GoogleFonts.roboto(
fontSize: 14,
fontWeight: FontWeight.w500,
color: isWatched ? Colors.white54 : Colors.white,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
if (episode.durationSecs != null && episode.durationSecs! > 0)
Text(
_formatDuration(episode.durationSecs!),
style: GoogleFonts.roboto(
fontSize: 12,
color: Colors.white54,
),
),
],
),
),
// Play icon
Icon(
isWatched ? Icons.replay : Icons.play_circle_outline,
color: Colors.white70,
size: 28,
),
],
),
),
),
);
}
String _formatDuration(int seconds) {
final hours = seconds ~/ 3600;
final minutes = (seconds % 3600) ~/ 60;
if (hours > 0) {
return '${hours}h ${minutes}m';
}
return '${minutes}m';
}
}
@@ -1,595 +0,0 @@
import 'package:dio/dio.dart';
import 'package:dio_cache_interceptor/dio_cache_interceptor.dart';
import 'package:dio_cache_interceptor_hive_store/dio_cache_interceptor_hive_store.dart';
import '../../../core/models/playlist_config.dart';
import '../../../core/models/iptv_models.dart';
import '../models/xtream_models.dart' as xm;
import '../../../core/utils/platform_utils.dart' as platform;
/// Xtream Codes API Service
///
/// Handles all communication with Xtream API servers
class XtreamService {
late final Dio _dio;
late final CacheOptions _cacheOptions;
PlaylistConfig? _currentPlaylist;
XtreamService() {
_dio = Dio(
BaseOptions(
connectTimeout: const Duration(seconds: 15),
receiveTimeout: const Duration(seconds: 15),
),
);
// Setup caching for API responses
_cacheOptions = CacheOptions(
store: HiveCacheStore('./cache'),
policy: CachePolicy.forceCache,
maxStale: const Duration(hours: 1),
priority: CachePriority.high,
);
_dio.interceptors.add(DioCacheInterceptor(options: _cacheOptions));
}
/// Check if we're running on HTTPS (production)
bool get _isHttps {
return platform.isHttps();
}
/// Wrap URL with proxy if needed (for HTTPS to HTTP bridge)
String _wrapWithProxy(String url) {
// If we're on HTTPS and the target URL is HTTP, use the proxy
if (_isHttps && url.startsWith('http://')) {
final baseUrl = platform.getWindowOrigin();
return '$baseUrl/api/xtream/$url';
}
return url;
}
/// Initialize connection with a playlist
void setPlaylist(PlaylistConfig playlist) {
_currentPlaylist = playlist;
}
/// Generate stream URL for live TV via FFmpeg transcoding
///
/// Returns the start URL for FFmpeg transcoding endpoint.
/// The endpoint will start FFmpeg and return the local HLS URL.
String getLiveStreamUrl(String streamId) {
if (_currentPlaylist == null) throw Exception('No playlist configured');
// Use .ts format for direct MPEG-TS streaming (FFmpeg handles this better)
final iptvUrl =
'${_currentPlaylist!.dns}/live/${_currentPlaylist!.username}/${_currentPlaylist!.password}/$streamId.ts';
// Use FFmpeg transcoding endpoint
final baseUrl = platform.getWindowOrigin();
final encodedUrl = Uri.encodeComponent(iptvUrl);
return '$baseUrl/api/stream/$streamId?url=$encodedUrl';
}
/// Generate stream URL for VOD (movies)
String getVodStreamUrl(String streamId, String containerExtension) {
if (_currentPlaylist == null) throw Exception('No playlist configured');
final url =
'${_currentPlaylist!.dns}/movie/${_currentPlaylist!.username}/${_currentPlaylist!.password}/$streamId.$containerExtension';
return _wrapWithProxy(url);
}
/// Generate stream URL for series episodes
String getSeriesStreamUrl(String streamId, String containerExtension) {
if (_currentPlaylist == null) throw Exception('No playlist configured');
final url =
'${_currentPlaylist!.dns}/series/${_currentPlaylist!.username}/${_currentPlaylist!.password}/$streamId.$containerExtension';
return _wrapWithProxy(url);
}
/// Authenticate and get server info
Future<Map<String, dynamic>> authenticate() async {
if (_currentPlaylist == null) throw Exception('No playlist configured');
try {
final response = await _dio.get(
_wrapWithProxy(_currentPlaylist!.apiBaseUrl),
queryParameters: {
'username': _currentPlaylist!.username,
'password': _currentPlaylist!.password,
},
options: Options(extra: _cacheOptions.toExtra()),
);
return response.data as Map<String, dynamic>;
} catch (e) {
throw Exception('Authentication failed: $e');
}
}
/// Load categories mapping (category_id -> category_name)
Future<Map<String, String>> _getLiveCategories() async {
if (_currentPlaylist == null) throw Exception('No playlist configured');
try {
final response = await _dio.get(
_wrapWithProxy(_currentPlaylist!.apiBaseUrl),
queryParameters: {
'username': _currentPlaylist!.username,
'password': _currentPlaylist!.password,
'action': 'get_live_categories',
},
options: Options(extra: _cacheOptions.toExtra()),
);
final List<dynamic> categories = response.data as List<dynamic>;
final Map<String, String> categoryMap = {};
for (final cat in categories) {
final catData = cat as Map<String, dynamic>;
final id = catData['category_id']?.toString() ?? '';
final name = catData['category_name']?.toString() ?? 'Unknown';
if (id.isNotEmpty) {
categoryMap[id] = name;
}
}
return categoryMap;
} catch (e) {
return {}; // Return empty map on error, channels will be "Uncategorized"
}
}
/// Get all live TV channels grouped by category
///
/// Returns a Map where key is category name and value is list of channels
Future<Map<String, List<Channel>>> getLiveChannels() async {
if (_currentPlaylist == null) throw Exception('No playlist configured');
try {
// First, load categories to get the mapping
final categoryMap = await _getLiveCategories();
final response = await _dio.get(
_wrapWithProxy(_currentPlaylist!.apiBaseUrl),
queryParameters: {
'username': _currentPlaylist!.username,
'password': _currentPlaylist!.password,
'action': 'get_live_streams',
},
options: Options(extra: _cacheOptions.toExtra()),
);
final List<dynamic> streams = response.data as List<dynamic>;
final Map<String, List<Channel>> groupedChannels = {};
for (final streamData in streams) {
final data = streamData as Map<String, dynamic>;
// Get category name from our mapping using category_id
final categoryId = data['category_id']?.toString() ?? '';
final categoryName = categoryMap[categoryId] ?? 'Uncategorized';
// Inject category_name into data before parsing
data['category_name'] = categoryName;
final channel = Channel.fromJson(data);
if (!groupedChannels.containsKey(categoryName)) {
groupedChannels[categoryName] = [];
}
groupedChannels[categoryName]!.add(channel);
}
return groupedChannels;
} catch (e) {
throw Exception('Failed to fetch live channels: $e');
}
}
/// Load VOD categories mapping
Future<Map<String, String>> _getVodCategories() async {
if (_currentPlaylist == null) return {};
try {
final response = await _dio.get(
_wrapWithProxy(_currentPlaylist!.apiBaseUrl),
queryParameters: {
'username': _currentPlaylist!.username,
'password': _currentPlaylist!.password,
'action': 'get_vod_categories',
},
options: Options(extra: _cacheOptions.toExtra()),
);
final List<dynamic> categories = response.data as List<dynamic>;
final Map<String, String> categoryMap = {};
for (final cat in categories) {
final catData = cat as Map<String, dynamic>;
final id = catData['category_id']?.toString() ?? '';
final name = catData['category_name']?.toString() ?? 'Unknown';
if (id.isNotEmpty) categoryMap[id] = name;
}
return categoryMap;
} catch (e) {
return {};
}
}
/// Get all VOD items (movies) grouped by category
Future<Map<String, List<VodItem>>> getVodItems() async {
if (_currentPlaylist == null) throw Exception('No playlist configured');
try {
final categoryMap = await _getVodCategories();
final response = await _dio.get(
_wrapWithProxy(_currentPlaylist!.apiBaseUrl),
queryParameters: {
'username': _currentPlaylist!.username,
'password': _currentPlaylist!.password,
'action': 'get_vod_streams',
},
options: Options(extra: _cacheOptions.toExtra()),
);
final List<dynamic> vods = response.data as List<dynamic>;
final Map<String, List<VodItem>> groupedVods = {};
for (final vodData in vods) {
final data = vodData as Map<String, dynamic>;
final categoryId = data['category_id']?.toString() ?? '';
final categoryName = categoryMap[categoryId] ?? 'Uncategorized';
data['category_name'] = categoryName;
final vod = VodItem.fromJson(data);
if (!groupedVods.containsKey(categoryName)) {
groupedVods[categoryName] = [];
}
groupedVods[categoryName]!.add(vod);
}
return groupedVods;
} catch (e) {
throw Exception('Failed to fetch VOD items: $e');
}
}
/// Load Series categories mapping
Future<Map<String, String>> _getSeriesCategories() async {
if (_currentPlaylist == null) return {};
try {
final response = await _dio.get(
_wrapWithProxy(_currentPlaylist!.apiBaseUrl),
queryParameters: {
'username': _currentPlaylist!.username,
'password': _currentPlaylist!.password,
'action': 'get_series_categories',
},
options: Options(extra: _cacheOptions.toExtra()),
);
final List<dynamic> categories = response.data as List<dynamic>;
final Map<String, String> categoryMap = {};
for (final cat in categories) {
final catData = cat as Map<String, dynamic>;
final id = catData['category_id']?.toString() ?? '';
final name = catData['category_name']?.toString() ?? 'Unknown';
if (id.isNotEmpty) categoryMap[id] = name;
}
return categoryMap;
} catch (e) {
return {};
}
}
/// Get all series grouped by category
Future<Map<String, List<Series>>> getSeries() async {
if (_currentPlaylist == null) throw Exception('No playlist configured');
try {
final categoryMap = await _getSeriesCategories();
final response = await _dio.get(
_wrapWithProxy(_currentPlaylist!.apiBaseUrl),
queryParameters: {
'username': _currentPlaylist!.username,
'password': _currentPlaylist!.password,
'action': 'get_series',
},
options: Options(extra: _cacheOptions.toExtra()),
);
final List<dynamic> seriesList = response.data as List<dynamic>;
final Map<String, List<Series>> groupedSeries = {};
for (final seriesData in seriesList) {
final data = seriesData as Map<String, dynamic>;
final categoryId = data['category_id']?.toString() ?? '';
final categoryName = categoryMap[categoryId] ?? 'Uncategorized';
data['category_name'] = categoryName;
final series = Series.fromJson(data);
if (!groupedSeries.containsKey(categoryName)) {
groupedSeries[categoryName] = [];
}
groupedSeries[categoryName]!.add(series);
}
return groupedSeries;
} catch (e) {
throw Exception('Failed to fetch series: $e');
}
}
/// Get movies with pagination support
Future<List<xm.Movie>> getMoviesPaginated({
int offset = 0,
int limit = 100,
}) async {
if (_currentPlaylist == null) throw Exception('No playlist configured');
try {
// Load categories for mapping
final categoryMap = await _getVodCategories();
final response = await _dio.get(
_wrapWithProxy(_currentPlaylist!.apiBaseUrl),
queryParameters: {
'username': _currentPlaylist!.username,
'password': _currentPlaylist!.password,
'action': 'get_vod_streams',
},
options: Options(extra: _cacheOptions.toExtra()),
);
final List<dynamic> allMovies = response.data as List<dynamic>;
// Apply pagination
final endIndex = (offset + limit) > allMovies.length
? allMovies.length
: offset + limit;
if (offset >= allMovies.length) return [];
final paginatedMovies = allMovies.sublist(offset, endIndex);
return paginatedMovies.map((movieData) {
final data = movieData as Map<String, dynamic>;
final categoryId = data['category_id']?.toString() ?? '';
data['category_name'] = categoryMap[categoryId] ?? 'Uncategorized';
return xm.Movie.fromJson(data);
}).toList();
} catch (e) {
throw Exception('Failed to fetch movies: $e');
}
}
/// Search movies in the entire catalogue
Future<List<xm.Movie>> searchMovies(String query) async {
if (_currentPlaylist == null) throw Exception('No playlist configured');
if (query.isEmpty) return [];
try {
final categoryMap = await _getVodCategories();
final response = await _dio.get(
_wrapWithProxy(_currentPlaylist!.apiBaseUrl),
queryParameters: {
'username': _currentPlaylist!.username,
'password': _currentPlaylist!.password,
'action': 'get_vod_streams',
},
options: Options(extra: _cacheOptions.toExtra()),
);
final List<dynamic> allMovies = response.data as List<dynamic>;
final queryLower = query.toLowerCase();
// Filter by search query
return allMovies
.where((m) {
final name = (m['name']?.toString() ?? '').toLowerCase();
return name.contains(queryLower);
})
.take(100) // Limit results
.map((movieData) {
final data = movieData as Map<String, dynamic>;
final categoryId = data['category_id']?.toString() ?? '';
data['category_name'] = categoryMap[categoryId] ?? 'Uncategorized';
return xm.Movie.fromJson(data);
})
.toList();
} catch (e) {
return [];
}
}
/// Get series with pagination support (returns flat list)
Future<List<xm.Series>> getSeriesPaginated({
int offset = 0,
int limit = 100,
}) async {
if (_currentPlaylist == null) throw Exception('No playlist configured');
try {
// Load categories for mapping
final categoryMap = await _getSeriesCategories();
final response = await _dio.get(
_wrapWithProxy(_currentPlaylist!.apiBaseUrl),
queryParameters: {
'username': _currentPlaylist!.username,
'password': _currentPlaylist!.password,
'action': 'get_series',
},
options: Options(extra: _cacheOptions.toExtra()),
);
final List<dynamic> allSeries = response.data as List<dynamic>;
// Apply pagination
final endIndex = (offset + limit) > allSeries.length
? allSeries.length
: offset + limit;
if (offset >= allSeries.length) return [];
final paginatedSeries = allSeries.sublist(offset, endIndex);
return paginatedSeries.map((seriesData) {
final data = seriesData as Map<String, dynamic>;
final categoryId = data['category_id']?.toString() ?? '';
data['category_name'] = categoryMap[categoryId] ?? 'Uncategorized';
return xm.Series.fromJson(data);
}).toList();
} catch (e) {
throw Exception('Failed to fetch series: $e');
}
}
/// Search series in the entire catalogue
Future<List<xm.Series>> searchSeries(String query) async {
if (_currentPlaylist == null) throw Exception('No playlist configured');
if (query.isEmpty) return [];
try {
final categoryMap = await _getSeriesCategories();
final response = await _dio.get(
_wrapWithProxy(_currentPlaylist!.apiBaseUrl),
queryParameters: {
'username': _currentPlaylist!.username,
'password': _currentPlaylist!.password,
'action': 'get_series',
},
options: Options(extra: _cacheOptions.toExtra()),
);
final List<dynamic> allSeries = response.data as List<dynamic>;
final queryLower = query.toLowerCase();
// Filter by search query
return allSeries
.where((s) {
final name = (s['name']?.toString() ?? '').toLowerCase();
return name.contains(queryLower);
})
.take(100) // Limit results
.map((seriesData) {
final data = seriesData as Map<String, dynamic>;
final categoryId = data['category_id']?.toString() ?? '';
data['category_name'] = categoryMap[categoryId] ?? 'Uncategorized';
return xm.Series.fromJson(data);
})
.toList();
} catch (e) {
return [];
}
}
/// Get series info with seasons and episodes
Future<xm.SeriesInfo> getSeriesInfo(String seriesId) async {
if (_currentPlaylist == null) throw Exception('No playlist configured');
try {
final response = await _dio.get(
_wrapWithProxy(_currentPlaylist!.apiBaseUrl),
queryParameters: {
'username': _currentPlaylist!.username,
'password': _currentPlaylist!.password,
'action': 'get_series_info',
'series_id': seriesId,
},
options: Options(extra: _cacheOptions.toExtra()),
);
return xm.SeriesInfo.fromJson(response.data as Map<String, dynamic>);
} catch (e) {
throw Exception('Failed to fetch series info: $e');
}
}
/// Get short EPG for a specific stream
///
/// Returns "Now" and "Next" program info
Future<List<EpgEntry>> getShortEpg(String streamId) async {
if (_currentPlaylist == null) throw Exception('No playlist configured');
try {
final response = await _dio.get(
_wrapWithProxy(_currentPlaylist!.apiBaseUrl),
queryParameters: {
'username': _currentPlaylist!.username,
'password': _currentPlaylist!.password,
'action': 'get_short_epg',
'stream_id': streamId,
},
options: Options(
extra: CacheOptions(
store: _cacheOptions.store,
policy: CachePolicy.request,
maxStale: const Duration(minutes: 5), // EPG changes frequently
).toExtra(),
),
);
if (response.data == null || response.data['epg_listings'] == null) {
return [];
}
final List<dynamic> epgData =
response.data['epg_listings'] as List<dynamic>;
return epgData
.map((entry) => EpgEntry.fromJson(entry as Map<String, dynamic>))
.toList();
} catch (e) {
// EPG is optional, don't throw on failure
return [];
}
}
/// Get short EPG as ShortEPG object (for EPGWidget)
Future<xm.ShortEPG> getShortEPG(String streamId) async {
if (_currentPlaylist == null) throw Exception('No playlist configured');
try {
final response = await _dio.get(
_wrapWithProxy(_currentPlaylist!.apiBaseUrl),
queryParameters: {
'username': _currentPlaylist!.username,
'password': _currentPlaylist!.password,
'action': 'get_short_epg',
'stream_id': streamId,
},
options: Options(
extra: CacheOptions(
store: _cacheOptions.store,
policy: CachePolicy.request,
maxStale: const Duration(minutes: 5),
).toExtra(),
),
);
if (response.data == null) {
return const xm.ShortEPG();
}
return xm.ShortEPG.fromJson(response.data as Map<String, dynamic>);
} catch (e) {
// EPG is optional, don't throw on failure
return const xm.ShortEPG();
}
}
/// Dispose resources
void dispose() {
_dio.close();
}
}
@@ -1,723 +0,0 @@
import 'package:flutter/foundation.dart';
import 'package:dio/dio.dart';
import 'package:dio_cache_interceptor/dio_cache_interceptor.dart';
import 'package:dio_cache_interceptor_hive_store/dio_cache_interceptor_hive_store.dart';
import '../../../core/models/playlist_config.dart';
import '../../../core/models/iptv_models.dart';
import '../../../core/api/dns_resolver.dart';
import '../models/xtream_models.dart' as xm;
/// Xtream Codes API Service for Mobile (no dart:html dependency)
///
/// Handles all communication with Xtream API servers
class XtreamServiceMobile {
late final Dio _dio;
late final CacheOptions _cacheOptions;
PlaylistConfig? _currentPlaylist;
String? _resolvedIp; // Cached resolved IP for the playlist server
String? _originalHost; // Original hostname for Host header
// In-memory cache for pagination performance
List<dynamic>? _cachedMoviesRaw;
List<dynamic>? _cachedSeriesRaw;
Map<String, String>? _cachedVodCategories;
Map<String, String>? _cachedSeriesCategories;
late final HiveCacheStore _cacheStore;
XtreamServiceMobile(String cachePath) {
_cacheStore = HiveCacheStore(cachePath);
_dio = Dio(
BaseOptions(
connectTimeout: const Duration(seconds: 15),
receiveTimeout: const Duration(seconds: 15),
),
);
// Setup caching for API responses - 24h cache for VOD content
_cacheOptions = CacheOptions(
store: _cacheStore,
policy: CachePolicy.forceCache,
maxStale: const Duration(hours: 24),
priority: CachePriority.high,
keyBuilder: (request) {
// Use the Original Host header if available to generate a stable cache key
// regardless of the resolved IP address used for the connection
if (request.headers.containsKey('Host')) {
final host = request.headers['Host'] as String;
final uri = request.uri;
// Reconstruct URI with the original hostname
final stableUri = uri.replace(host: host);
return stableUri.toString();
}
return request.uri.toString();
},
);
_dio.interceptors.add(DioCacheInterceptor(options: _cacheOptions));
}
/// Initialize connection with a playlist - now async for DNS resolution
Future<void> setPlaylistAsync(PlaylistConfig playlist) async {
_currentPlaylist = playlist;
// Extract hostname from DNS URL
final uri = Uri.tryParse(playlist.dns);
if (uri != null && uri.host.isNotEmpty) {
_originalHost = uri.host;
// Proactively resolve DNS
print('XtreamServiceMobile: Pre-resolving ${uri.host}');
_resolvedIp = await DnsResolver.resolve(uri.host);
if (_resolvedIp != null) {
print('XtreamServiceMobile: Will use IP $_resolvedIp for ${uri.host}');
} else {
print(
'XtreamServiceMobile: DNS resolution failed, will use hostname directly',
);
}
}
}
/// Legacy sync method - calls async internally
void setPlaylist(PlaylistConfig playlist) {
_currentPlaylist = playlist;
// Trigger async resolution in background
final uri = Uri.tryParse(playlist.dns);
if (uri != null && uri.host.isNotEmpty) {
_originalHost = uri.host;
DnsResolver.resolve(uri.host).then((ip) {
_resolvedIp = ip;
});
}
}
/// Get the API base URL, using resolved IP if available
String get _effectiveApiBaseUrl {
if (_currentPlaylist == null) throw Exception('No playlist configured');
if (_resolvedIp != null && _originalHost != null) {
// Replace hostname with IP in the URL
final originalUrl = _currentPlaylist!.apiBaseUrl;
return originalUrl.replaceFirst(_originalHost!, _resolvedIp!);
}
return _currentPlaylist!.apiBaseUrl;
}
/// Get effective DNS base, using resolved IP if available
String get _effectiveDnsBase {
if (_currentPlaylist == null) throw Exception('No playlist configured');
if (_resolvedIp != null && _originalHost != null) {
return _currentPlaylist!.dns.replaceFirst(_originalHost!, _resolvedIp!);
}
return _currentPlaylist!.dns;
}
/// Get request options with Host header if using IP
Options _getOptions() {
final opts = Options(extra: _cacheOptions.toExtra());
if (_resolvedIp != null && _originalHost != null) {
opts.headers = {'Host': _originalHost!};
}
return opts;
}
PlaylistConfig? get currentPlaylist => _currentPlaylist;
/// Generate direct stream URL for live TV (MPEG-TS format for maximum compatibility)
String getLiveStreamUrl(String streamId) {
if (_currentPlaylist == null) throw Exception('No playlist configured');
// Use .ts format (MPEG-TS) for better compatibility with more providers
return '$_effectiveDnsBase/live/${_currentPlaylist!.username}/${_currentPlaylist!.password}/$streamId.ts';
}
/// Generate stream URL for VOD (movies)
String getVodStreamUrl(String streamId, String containerExtension) {
if (_currentPlaylist == null) throw Exception('No playlist configured');
return '$_effectiveDnsBase/movie/${_currentPlaylist!.username}/${_currentPlaylist!.password}/$streamId.$containerExtension';
}
/// Generate stream URL for series episodes
String getSeriesStreamUrl(String streamId, String containerExtension) {
if (_currentPlaylist == null) throw Exception('No playlist configured');
return '$_effectiveDnsBase/series/${_currentPlaylist!.username}/${_currentPlaylist!.password}/$streamId.$containerExtension';
}
/// Authenticate and get server info
Future<Map<String, dynamic>> authenticate() async {
if (_currentPlaylist == null) throw Exception('No playlist configured');
try {
final response = await _dio.get(
_effectiveApiBaseUrl,
queryParameters: {
'username': _currentPlaylist!.username,
'password': _currentPlaylist!.password,
},
options: _getOptions(),
);
if (response.data is! Map<String, dynamic>) {
if (response.data is String) {
throw Exception('Auth Error: ${response.data}');
}
throw Exception('Invalid auth response format');
}
return response.data as Map<String, dynamic>;
} catch (e) {
throw Exception('Authentication failed: $e');
}
}
/// Load categories mapping (category_id -> category_name)
Future<Map<String, String>> _getLiveCategories() async {
if (_currentPlaylist == null) throw Exception('No playlist configured');
try {
final response = await _dio.get(
_effectiveApiBaseUrl,
queryParameters: {
'username': _currentPlaylist!.username,
'password': _currentPlaylist!.password,
'action': 'get_live_categories',
},
options: _getOptions(),
);
if (response.data is! List) {
if (response.data is String) {
throw Exception('Category Error: ${response.data}');
}
return {};
}
final List<dynamic> categories = response.data as List<dynamic>;
final Map<String, String> categoryMap = {};
for (final cat in categories) {
final catData = cat as Map<String, dynamic>;
final id = catData['category_id']?.toString() ?? '';
final name = catData['category_name']?.toString() ?? 'Unknown';
if (id.isNotEmpty) {
categoryMap[id] = name;
}
}
return categoryMap;
} catch (e) {
return {}; // Return empty map on error, channels will be "Uncategorized"
}
}
/// Get all live TV channels grouped by category
Future<Map<String, List<Channel>>> getLiveChannels() async {
if (_currentPlaylist == null) throw Exception('No playlist configured');
try {
// First, load categories to get the mapping
final categoryMap = await _getLiveCategories();
final response = await _dio.get(
_effectiveApiBaseUrl,
queryParameters: {
'username': _currentPlaylist!.username,
'password': _currentPlaylist!.password,
'action': 'get_live_streams',
},
options: _getOptions(),
);
if (response.data is! List) {
if (response.data is String) {
throw Exception('Live TV Error: ${response.data}');
}
return {};
}
final List<dynamic> streams = response.data as List<dynamic>;
final Map<String, List<Channel>> groupedChannels = {};
for (final streamData in streams) {
try {
final data = streamData as Map<String, dynamic>;
// Get category name from our mapping using category_id
final categoryId = data['category_id']?.toString() ?? '';
final categoryName = categoryMap[categoryId] ?? 'Uncategorized';
// Inject category_name into data before parsing
data['category_name'] = categoryName;
final channel = Channel.fromJson(data);
if (!groupedChannels.containsKey(categoryName)) {
groupedChannels[categoryName] = [];
}
groupedChannels[categoryName]!.add(channel);
} catch (e) {
// Skip malformed channel
continue;
}
}
return groupedChannels;
} catch (e) {
throw Exception('Failed to fetch live channels: $e');
}
}
/// Load VOD categories mapping
Future<Map<String, String>> getVodCategories() async {
if (_currentPlaylist == null) return {};
try {
final response = await _dio.get(
_effectiveApiBaseUrl,
queryParameters: {
'username': _currentPlaylist!.username,
'password': _currentPlaylist!.password,
'action': 'get_vod_categories',
},
options: _getOptions(),
);
if (response.data is! List) return {};
final List<dynamic> categories = response.data as List<dynamic>;
final Map<String, String> categoryMap = {};
for (final cat in categories) {
final catData = cat as Map<String, dynamic>;
final id = catData['category_id']?.toString() ?? '';
final name = catData['category_name']?.toString() ?? 'Unknown';
if (id.isNotEmpty) categoryMap[id] = name;
}
return categoryMap;
} catch (e) {
return {};
}
}
/// Load Series categories mapping
Future<Map<String, String>> _getSeriesCategories() async {
if (_currentPlaylist == null) return {};
try {
final response = await _dio.get(
_effectiveApiBaseUrl,
queryParameters: {
'username': _currentPlaylist!.username,
'password': _currentPlaylist!.password,
'action': 'get_series_categories',
},
options: _getOptions(),
);
if (response.data is! List) return {};
final List<dynamic> categories = response.data as List<dynamic>;
final Map<String, String> categoryMap = {};
for (final cat in categories) {
final catData = cat as Map<String, dynamic>;
final id = catData['category_id']?.toString() ?? '';
final name = catData['category_name']?.toString() ?? 'Unknown';
if (id.isNotEmpty) categoryMap[id] = name;
}
return categoryMap;
} catch (e) {
return {};
}
}
/// Get movies with pagination support (uses in-memory cache for performance)
Future<List<xm.Movie>> getMoviesPaginated({
int offset = 0,
int limit = 100,
}) async {
if (_currentPlaylist == null) throw Exception('No playlist configured');
try {
// Load from cache or fetch from API
if (_cachedMoviesRaw == null) {
// First load: fetch from API and cache
_cachedVodCategories ??= await getVodCategories();
final response = await _dio.get(
_effectiveApiBaseUrl,
queryParameters: {
'username': _currentPlaylist!.username,
'password': _currentPlaylist!.password,
'action': 'get_vod_streams',
},
options: _getOptions(),
);
if (response.data is! List) {
if (response.data is String) {
throw Exception('VOD Error: ${response.data}');
}
_cachedMoviesRaw = [];
} else {
_cachedMoviesRaw = response.data as List<dynamic>;
}
}
final allMovies = _cachedMoviesRaw!;
final categoryMap = _cachedVodCategories!;
// Apply pagination
final endIndex = (offset + limit) > allMovies.length
? allMovies.length
: offset + limit;
if (offset >= allMovies.length) return [];
final paginatedMovies = allMovies.sublist(offset, endIndex);
final List<xm.Movie> movies = [];
for (final movieData in paginatedMovies) {
try {
final data =
Map<String, dynamic>.from(movieData as Map<String, dynamic>);
final categoryId = data['category_id']?.toString() ?? '';
data['category_name'] = categoryMap[categoryId] ?? 'Uncategorized';
movies.add(xm.Movie.fromJson(data));
} catch (e) {
continue;
}
}
return movies;
} catch (e) {
throw Exception('Failed to fetch movies: $e');
}
}
/// Get movies by category (Fast, optimized for large libraries)
Future<List<xm.Movie>> getMoviesByCategory(String categoryId) async {
if (_currentPlaylist == null) throw Exception('No playlist configured');
try {
final categoryMap = await getVodCategories();
final categoryName = categoryMap[categoryId] ?? 'Unknown';
final response = await _dio.get(
_effectiveApiBaseUrl,
queryParameters: {
'username': _currentPlaylist!.username,
'password': _currentPlaylist!.password,
'action': 'get_vod_streams',
'category_id': categoryId,
},
options: _getOptions(),
);
if (response.data is! List) return [];
final List<dynamic> moviesRaw = response.data as List<dynamic>;
final List<xm.Movie> movies = [];
for (final movieData in moviesRaw) {
try {
final data =
Map<String, dynamic>.from(movieData as Map<String, dynamic>);
data['category_name'] = categoryName;
movies.add(xm.Movie.fromJson(data));
} catch (e) {
continue;
}
}
return movies;
} catch (e) {
// Return empty list instead of throwing to prevent UI crash
return [];
}
}
/// Search movies in the entire catalogue
Future<List<xm.Movie>> searchMovies(String query) async {
if (_currentPlaylist == null) throw Exception('No playlist configured');
if (query.isEmpty) return [];
try {
// Ensure data is loaded in cache
if (_cachedMoviesRaw == null) {
await getMoviesPaginated(limit: 1);
}
final allMovies = _cachedMoviesRaw ?? [];
final categoryMap = _cachedVodCategories ?? await getVodCategories();
final queryLower = query.toLowerCase();
// Filter by search query (Local Cache)
final List<xm.Movie> results = [];
// Efficient filtering: check name matches, limited to 100 results
final matches = allMovies.where((m) {
final name = (m['name']?.toString() ?? '').toLowerCase();
return name.contains(queryLower);
}).take(100);
for (final movieData in matches) {
try {
final data =
Map<String, dynamic>.from(movieData as Map<String, dynamic>);
final categoryId = data['category_id']?.toString() ?? '';
data['category_name'] = categoryMap[categoryId] ?? 'Uncategorized';
results.add(xm.Movie.fromJson(data));
} catch (e) {
continue;
}
}
return results;
} catch (e) {
debugPrint('Search error: $e');
return [];
}
}
/// Get series with pagination support (uses in-memory cache for performance)
Future<List<xm.Series>> getSeriesPaginated({
int offset = 0,
int limit = 100,
}) async {
if (_currentPlaylist == null) throw Exception('No playlist configured');
try {
// Load from cache or fetch from API
if (_cachedSeriesRaw == null) {
// First load: fetch from API and cache
_cachedSeriesCategories ??= await _getSeriesCategories();
final response = await _dio.get(
_effectiveApiBaseUrl,
queryParameters: {
'username': _currentPlaylist!.username,
'password': _currentPlaylist!.password,
'action': 'get_series',
},
options: _getOptions(),
);
if (response.data is! List) {
if (response.data is String) {
throw Exception('Series Error: ${response.data}');
}
_cachedSeriesRaw = [];
} else {
_cachedSeriesRaw = response.data as List<dynamic>;
}
}
final allSeries = _cachedSeriesRaw!;
final categoryMap = _cachedSeriesCategories!;
// Apply pagination
final endIndex = (offset + limit) > allSeries.length
? allSeries.length
: offset + limit;
if (offset >= allSeries.length) return [];
final paginatedSeries = allSeries.sublist(offset, endIndex);
final List<xm.Series> seriesList = [];
for (final seriesData in paginatedSeries) {
try {
final data =
Map<String, dynamic>.from(seriesData as Map<String, dynamic>);
final categoryId = data['category_id']?.toString() ?? '';
data['category_name'] = categoryMap[categoryId] ?? 'Uncategorized';
seriesList.add(xm.Series.fromJson(data));
} catch (e) {
continue;
}
}
return seriesList;
} catch (e) {
throw Exception('Failed to fetch series: $e');
}
}
/// Search series in the entire catalogue
Future<List<xm.Series>> searchSeries(String query) async {
if (_currentPlaylist == null) throw Exception('No playlist configured');
if (query.isEmpty) return [];
try {
// Ensure data is loaded in cache
if (_cachedSeriesRaw == null) {
await getSeriesPaginated(limit: 1);
}
final allSeries = _cachedSeriesRaw ?? [];
final categoryMap =
_cachedSeriesCategories ?? await _getSeriesCategories();
final queryLower = query.toLowerCase();
// Filter by search query (Local Cache)
final List<xm.Series> results = [];
final matches = allSeries.where((s) {
final name = (s['name']?.toString() ?? '').toLowerCase();
return name.contains(queryLower);
}).take(100);
for (final seriesData in matches) {
try {
final data =
Map<String, dynamic>.from(seriesData as Map<String, dynamic>);
final categoryId = data['category_id']?.toString() ?? '';
data['category_name'] = categoryMap[categoryId] ?? 'Uncategorized';
results.add(xm.Series.fromJson(data));
} catch (e) {
continue;
}
}
return results;
} catch (e) {
debugPrint('Search series error: $e');
return [];
}
}
/// Get series info with seasons and episodes
Future<xm.SeriesInfo> getSeriesInfo(String seriesId) async {
if (_currentPlaylist == null) throw Exception('No playlist configured');
try {
final response = await _dio.get(
_effectiveApiBaseUrl,
queryParameters: {
'username': _currentPlaylist!.username,
'password': _currentPlaylist!.password,
'action': 'get_series_info',
'series_id': seriesId,
},
options: _getOptions(),
);
if (response.data is! Map<String, dynamic>) {
throw Exception('Invalid response format for Series Info');
}
return xm.SeriesInfo.fromJson(response.data as Map<String, dynamic>);
} catch (e) {
throw Exception('Failed to fetch series info: $e');
}
}
/// Get short EPG for a specific stream
Future<List<EpgEntry>> getShortEpg(String streamId) async {
if (_currentPlaylist == null) throw Exception('No playlist configured');
try {
final response = await _dio.get(
_effectiveApiBaseUrl,
queryParameters: {
'username': _currentPlaylist!.username,
'password': _currentPlaylist!.password,
'action': 'get_short_epg',
'stream_id': streamId,
},
options: Options(
headers: _resolvedIp != null && _originalHost != null
? {'Host': _originalHost!}
: null,
extra: CacheOptions(
store: _cacheOptions.store,
policy: CachePolicy.request,
maxStale: const Duration(minutes: 5), // EPG changes frequently
).toExtra(),
),
);
if (response.data == null || response.data['epg_listings'] == null) {
return [];
}
if (response.data['epg_listings'] is! List) return [];
final List<dynamic> epgData =
response.data['epg_listings'] as List<dynamic>;
return epgData
.map((entry) => EpgEntry.fromJson(entry as Map<String, dynamic>))
.toList();
} catch (e) {
// EPG is optional, don't throw on failure
return [];
}
}
/// Get short EPG as ShortEPG object (for EPGWidget)
/// Caches results for 12 hours by default
Future<xm.ShortEPG> getShortEPG(
String streamId, {
bool forceRefresh = false,
}) async {
if (_currentPlaylist == null) throw Exception('No playlist configured');
try {
final response = await _dio.get(
_effectiveApiBaseUrl,
queryParameters: {
'username': _currentPlaylist!.username,
'password': _currentPlaylist!.password,
'action': 'get_short_epg',
'stream_id': streamId,
},
options: Options(
headers: _resolvedIp != null && _originalHost != null
? {'Host': _originalHost!}
: null,
extra: CacheOptions(
store: _cacheOptions.store,
policy: forceRefresh ? CachePolicy.refresh : CachePolicy.request,
maxStale: const Duration(hours: 12), // Cache for 12h as requested
).toExtra(),
),
);
if (response.data == null) {
return const xm.ShortEPG();
}
if (response.data is! Map<String, dynamic>) {
return const xm.ShortEPG();
}
return xm.ShortEPG.fromJson(response.data as Map<String, dynamic>);
} catch (e) {
// EPG is optional, don't throw on failure
return const xm.ShortEPG();
}
}
/// Clear in-memory cache (call on refresh)
void clearCache() {
_cachedMoviesRaw = null;
_cachedSeriesRaw = null;
_cachedVodCategories = null;
_cachedSeriesCategories = null;
}
/// Force refresh all cached data (clears both memory and Hive HTTP cache)
/// This will cause the next API calls to fetch fresh data from the server
Future<void> forceRefreshCache() async {
// Clear in-memory cache
clearCache();
// Clear HTTP cache (Hive store)
try {
await _cacheStore.clean();
print('XtreamServiceMobile: Cache cleared successfully');
} catch (e) {
print('XtreamServiceMobile: Error clearing cache: $e');
}
}
/// Dispose resources
void dispose() {
clearCache();
_dio.close();
}
}
@@ -1,217 +0,0 @@
import 'package:flutter/material.dart';
import '../../../core/theme/app_colors.dart';
/// Horizontal carousel for displaying channels by category
///
/// Netflix-style horizontal scrolling list with:
/// - Category title with count
/// - Smooth horizontal scrolling
/// - Lazy loading support
/// - Fade edges for visual polish
class CategoryCarousel<T> extends StatelessWidget {
final String title;
final int? itemCount;
final List<T> items;
final Widget Function(BuildContext context, T item, int index) itemBuilder;
final double itemWidth;
final double itemHeight;
final double spacing;
final EdgeInsets? padding;
final VoidCallback? onSeeAllTap;
const CategoryCarousel({
super.key,
required this.title,
required this.items,
required this.itemBuilder,
this.itemCount,
this.itemWidth = 180,
this.itemHeight = 120,
this.spacing = 12,
this.padding,
this.onSeeAllTap,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
if (items.isEmpty) {
return const SizedBox.shrink();
}
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Category header
Padding(
padding: padding ?? const EdgeInsets.symmetric(horizontal: 16),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
children: [
Text(
title,
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w700,
color: colorScheme.onSurface,
),
),
if (itemCount != null) ...[
const SizedBox(width: 8),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 2,
),
decoration: BoxDecoration(
color: colorScheme.primary.withOpacity(0.15),
borderRadius: BorderRadius.circular(12),
),
child: Text(
'$itemCount',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: colorScheme.primary,
),
),
),
],
],
),
if (onSeeAllTap != null)
TextButton(
onPressed: onSeeAllTap,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
'Voir tout',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: colorScheme.primary,
),
),
const SizedBox(width: 4),
Icon(
Icons.arrow_forward_ios_rounded,
size: 12,
color: colorScheme.primary,
),
],
),
),
],
),
),
const SizedBox(height: 12),
// Horizontal scrolling list with fade edges
SizedBox(
height: itemHeight,
child: ShaderMask(
shaderCallback: (Rect bounds) {
return const LinearGradient(
begin: Alignment.centerLeft,
end: Alignment.centerRight,
colors: [
Colors.transparent,
Colors.white,
Colors.white,
Colors.transparent,
],
stops: [0.0, 0.02, 0.98, 1.0],
).createShader(bounds);
},
blendMode: BlendMode.dstIn,
child: ListView.separated(
scrollDirection: Axis.horizontal,
padding: EdgeInsets.symmetric(
horizontal: (padding?.left ?? 16),
),
physics: const BouncingScrollPhysics(),
itemCount: items.length,
separatorBuilder: (_, __) => SizedBox(width: spacing),
itemBuilder: (context, index) {
return SizedBox(
width: itemWidth,
height: itemHeight,
child: itemBuilder(context, items[index], index),
);
},
),
),
),
],
);
}
}
/// Simple category chip for filtering
class CategoryChipButton extends StatelessWidget {
final String label;
final bool isSelected;
final VoidCallback? onTap;
final IconData? icon;
const CategoryChipButton({
super.key,
required this.label,
this.isSelected = false,
this.onTap,
this.icon,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
return GestureDetector(
onTap: onTap,
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
decoration: BoxDecoration(
gradient: isSelected ? AppColors.primaryGradient : null,
color: isSelected ? null : colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(20),
border: Border.all(
color: isSelected ? Colors.transparent : colorScheme.outlineVariant,
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (icon != null) ...[
Icon(
icon,
size: 16,
color: isSelected
? colorScheme.onPrimary
: colorScheme.onSurfaceVariant,
),
const SizedBox(width: 6),
],
Text(
label,
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color:
isSelected ? colorScheme.onPrimary : colorScheme.onSurface,
),
),
],
),
),
);
}
}
-282
View File
@@ -1,282 +0,0 @@
import 'package:flutter/material.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/theme/app_colors.dart';
import '../../../core/theme/app_theme.dart';
import '../providers/favorites_provider.dart';
import '../../../core/models/playlist_config.dart';
import '../../iptv/providers/xtream_provider.dart';
class ChannelCard extends ConsumerStatefulWidget {
final String streamId;
final String name;
final String? iconUrl;
final String? currentProgram;
final bool isLive;
final VoidCallback? onTap;
final double? width;
final double? height;
final PlaylistConfig playlist;
const ChannelCard({
super.key,
required this.streamId,
required this.name,
required this.playlist,
this.iconUrl,
this.currentProgram,
this.isLive = true,
this.onTap,
this.width,
this.height,
});
@override
ConsumerState<ChannelCard> createState() => _ChannelCardState();
}
class _ChannelCardState extends ConsumerState<ChannelCard>
with SingleTickerProviderStateMixin {
bool _isHovered = false;
late AnimationController _pulseController;
// EPG State
String? _epgNow;
bool _epgLoaded = false;
@override
void initState() {
super.initState();
_pulseController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 1000),
)..repeat(reverse: true);
// Fetch EPG immediately when card is built
WidgetsBinding.instance.addPostFrameCallback((_) {
_fetchEpg();
});
}
@override
void dispose() {
_pulseController.dispose();
super.dispose();
}
Future<void> _fetchEpg() async {
if (_epgLoaded) return;
try {
// Don't modify state if unmounted
if (!mounted) return;
final service = ref.read(xtreamServiceProvider(widget.playlist));
// We use the short EPG endpoint for quick access
final entries = await service.getShortEpg(widget.streamId);
if (mounted && entries.isNotEmpty) {
final now = DateTime.now();
// Find current program
final current = entries.firstWhere(
(e) {
try {
final start = DateTime.parse(e.start);
final end = DateTime.parse(e.end);
return now.isAfter(start) && now.isBefore(end);
} catch (_) {
return false;
}
},
orElse: () => entries.first,
);
setState(() {
_epgNow = current.title;
_epgLoaded = true;
});
}
} catch (_) {
// Fail silently
if (mounted) setState(() => _epgLoaded = true);
}
}
@override
Widget build(BuildContext context) {
final favorites = ref.watch(favoritesProvider);
final isFavorite = favorites.contains(widget.streamId);
// Dynamic sizing based on hover
return MouseRegion(
onEnter: (_) {
setState(() => _isHovered = true);
},
onExit: (_) => setState(() => _isHovered = false),
cursor: SystemMouseCursors.click,
child: GestureDetector(
onTap: widget.onTap,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// The Image Card (Scales)
AnimatedScale(
scale: _isHovered ? 1.05 : 1.0,
duration: AppTheme.durationFast,
curve: AppTheme.curveDefault,
child: AnimatedContainer(
duration: AppTheme.durationFast,
width: widget.width ?? 180,
height: widget.height ?? 110,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(AppTheme.radiusMd),
color: AppColors.surface, // Fallback color
// Thick white border on focus (tvOS style)
border: Border.all(
color: _isHovered ? AppColors.focusColor : Colors.transparent,
width: _isHovered ? 3 : 0,
),
// Deep shadow on focus
boxShadow: _isHovered
? [
BoxShadow(
color: Colors.black.withOpacity(0.5),
blurRadius: 30,
offset: const Offset(0, 15),
spreadRadius: 2,
),
]
: [
BoxShadow(
color: Colors.black.withOpacity(0.2),
blurRadius: 4,
offset: const Offset(0, 2),
),
],
),
child: ClipRRect(
borderRadius: BorderRadius.circular(AppTheme.radiusMd - (_isHovered ? 2 : 0)), // Adjust inner radius
child: Stack(
fit: StackFit.expand,
children: [
// 1. Background Image / Logo - EDGE TO EDGE
_buildChannelImage(),
// 2. Gradient Overlay (Subtle)
if (_isHovered)
Positioned.fill(
child: Container(
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [Colors.transparent, Colors.black.withOpacity(0.4)],
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
),
),
),
),
// 3. Live Badge (Top Right)
if (widget.isLive)
Positioned(
top: 8,
right: 8,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: AppColors.live,
borderRadius: BorderRadius.circular(4),
),
child: const Text('LIVE', style: TextStyle(color: Colors.white, fontSize: 9, fontWeight: FontWeight.bold)),
),
),
// 4. Favorite Icon (Top Left)
if (_isHovered || isFavorite)
Positioned(
top: 5,
left: 5,
child: GestureDetector(
onTap: () => ref.read(favoritesProvider.notifier).toggleFavorite(widget.streamId),
child: Icon(
isFavorite ? Icons.favorite : Icons.favorite_border,
color: isFavorite ? AppColors.live : Colors.white.withOpacity(0.7),
size: 20,
),
),
),
],
),
),
),
),
const SizedBox(height: 8),
// Text separates from card (Classic tvOS look)
SizedBox(
width: widget.width ?? 180,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Text(
widget.name,
textAlign: TextAlign.center,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: _isHovered ? AppColors.focusColor : AppColors.textSecondary,
fontWeight: _isHovered ? FontWeight.bold : FontWeight.w500,
fontSize: 13,
),
),
if ((widget.currentProgram != null || _epgNow != null)) ...[
const SizedBox(height: 2),
Text(
_epgNow ?? widget.currentProgram ?? '',
textAlign: TextAlign.center,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: AppColors.textTertiary,
fontSize: 11,
),
),
],
],
),
),
],
),
),
);
}
Widget _buildChannelImage() {
if (widget.iconUrl != null && widget.iconUrl!.isNotEmpty) {
return Container(
color: Colors.white, // Logos usually look best on white/light grey
child: CachedNetworkImage(
imageUrl: widget.iconUrl!,
fit: BoxFit.contain,
width: double.infinity,
height: double.infinity,
placeholder: (context, url) => _buildPlaceholder(),
errorWidget: (context, url, error) => _buildPlaceholder(),
),
);
}
return _buildPlaceholder();
}
Widget _buildPlaceholder() {
return Container(
color: AppColors.surface,
child: const Center(
child: Icon(Icons.tv, color: AppColors.textTertiary),
),
);
}
}
@@ -1,55 +0,0 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
class ClockWidget extends StatefulWidget {
final TextStyle? style;
const ClockWidget({super.key, this.style});
@override
State<ClockWidget> createState() => _ClockWidgetState();
}
class _ClockWidgetState extends State<ClockWidget> {
late DateTime _now;
Timer? _timer;
@override
void initState() {
super.initState();
_now = DateTime.now();
_startTimer();
}
void _startTimer() {
_timer = Timer.periodic(const Duration(seconds: 1), (timer) {
if (mounted) {
setState(() {
_now = DateTime.now();
});
}
});
}
@override
void dispose() {
_timer?.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
// Format: HH:mm
final timeString = DateFormat('HH:mm').format(_now);
return Text(
timeString,
style: widget.style ?? const TextStyle(
color: Colors.white,
fontSize: 16,
fontWeight: FontWeight.bold,
shadows: [Shadow(color: Colors.black, blurRadius: 4)],
),
);
}
}
-113
View File
@@ -1,113 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/models/playlist_config.dart';
import '../providers/xtream_provider.dart';
/// Minimal EPG overlay - shows program title and info
class EpgOverlay extends ConsumerWidget {
final String streamId;
final PlaylistConfig playlist;
const EpgOverlay({
super.key,
required this.streamId,
required this.playlist,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final epgAsync = ref.watch(
epgByPlaylistProvider(
EpgRequestKey(playlist: playlist, streamId: streamId),
),
);
return epgAsync.when(
data: (epgEntries) {
if (epgEntries.isEmpty) {
return const SizedBox.shrink();
}
// Get "Now" program
final now = DateTime.now();
final currentProgram = epgEntries.firstWhere(
(entry) {
final start = DateTime.parse(entry.start);
final end = DateTime.parse(entry.end);
return now.isAfter(start) && now.isBefore(end);
},
orElse: () => epgEntries.first,
);
// Enhanced info display
return Align(
alignment: Alignment.bottomLeft, // Align to bottom left near controls
child: Container(
margin: const EdgeInsets.fromLTRB(24, 0, 24, 24),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: BoxDecoration(
color: Colors.black
.withOpacity(0.5), // Semi-transparent backing for readability
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.white.withOpacity(0.1)),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
padding:
const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: Colors.red,
borderRadius: BorderRadius.circular(4),
),
child: const Text(
'LIVE',
style: TextStyle(
color: Colors.white,
fontSize: 12, // Larger
fontWeight: FontWeight.bold,
),
),
),
const SizedBox(width: 12),
ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 500), // Wider
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
currentProgram.title,
style: const TextStyle(
color: Colors.white,
fontSize: 18, // Much Larger
fontWeight: FontWeight.bold,
shadows: [Shadow(color: Colors.black, blurRadius: 4)],
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
if (currentProgram.description.isNotEmpty)
Text(
currentProgram.description,
style: const TextStyle(
color: Colors.white70,
fontSize: 13, // Larger desc
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
),
],
),
),
);
},
loading: () => const SizedBox.shrink(),
error: (_, __) => const SizedBox.shrink(),
);
}
}
-168
View File
@@ -1,168 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:google_fonts/google_fonts.dart';
import '../../../core/models/playlist_config.dart';
import '../providers/xtream_provider.dart';
import '../models/xtream_models.dart';
class EPGWidget extends ConsumerStatefulWidget {
final String channelId;
final PlaylistConfig playlist;
const EPGWidget({
super.key,
required this.channelId,
required this.playlist,
});
@override
ConsumerState<EPGWidget> createState() => _EPGWidgetState();
}
class _EPGWidgetState extends ConsumerState<EPGWidget> {
ShortEPG? _epg;
bool _isLoading = true;
@override
void initState() {
super.initState();
_loadEPG();
}
Future<void> _loadEPG() async {
try {
final service = ref.read(xtreamServiceProvider(widget.playlist));
final epg = await service.getShortEPG(widget.channelId);
if (mounted) {
setState(() {
_epg = epg;
_isLoading = false;
});
}
} catch (e) {
if (mounted) {
setState(() {
_isLoading = false;
});
}
}
}
@override
Widget build(BuildContext context) {
if (_isLoading) {
return Container(
padding: const EdgeInsets.all(8),
child: const Row(
children: [
SizedBox(
width: 12,
height: 12,
child: CircularProgressIndicator(strokeWidth: 1.5),
),
SizedBox(width: 8),
Text('Loading EPG...', style: TextStyle(fontSize: 10)),
],
),
);
}
if (_epg == null ||
(_epg!.nowPlaying == null && _epg!.nextPlaying == null)) {
return const SizedBox.shrink();
}
return Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Colors.black.withOpacity(0.6),
borderRadius: BorderRadius.circular(4),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
// Now Playing
if (_epg!.nowPlaying != null) ...[
Row(
children: [
Container(
padding: const EdgeInsets.symmetric(
horizontal: 6,
vertical: 2,
),
decoration: BoxDecoration(
color: Colors.red,
borderRadius: BorderRadius.circular(3),
),
child: Text(
'LIVE',
style: GoogleFonts.roboto(
fontSize: 9,
fontWeight: FontWeight.bold,
color: Colors.white,
),
),
),
const SizedBox(width: 6),
Expanded(
child: Text(
_epg!.nowPlaying!,
style: GoogleFonts.roboto(
fontSize: 11,
fontWeight: FontWeight.w600,
color: Colors.white,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
),
// Progress bar
if (_epg!.progress != null) ...[
const SizedBox(height: 4),
ClipRRect(
borderRadius: BorderRadius.circular(2),
child: LinearProgressIndicator(
value: _epg!.progress!.clamp(0.0, 1.0),
backgroundColor: Colors.white24,
valueColor: const AlwaysStoppedAnimation<Color>(Colors.red),
minHeight: 3,
),
),
],
],
// Next Playing
if (_epg!.nextPlaying != null) ...[
const SizedBox(height: 6),
Row(
children: [
Icon(
Icons.upcoming,
size: 12,
color: Colors.grey.shade400,
),
const SizedBox(width: 6),
Expanded(
child: Text(
'Next: ${_epg!.nextPlaying}',
style: GoogleFonts.roboto(
fontSize: 10,
color: Colors.grey.shade400,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
),
],
],
),
);
}
}
-436
View File
@@ -1,436 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../providers/xtream_provider.dart';
import '../providers/settings_provider.dart';
import '../providers/favorites_provider.dart';
import '../screens/player_screen.dart';
import '../../../core/models/iptv_models.dart';
import '../../../core/models/playlist_config.dart';
import '../../../core/theme/app_colors.dart';
import '../../../core/theme/app_theme.dart';
import '../../../core/utils/responsive_layout.dart';
import '../../../core/widgets/themed_loading_screen.dart';
import 'channel_card.dart';
/// Live TV tab - Apple TV Style with Horizontal Category Filter
class LiveTVTab extends ConsumerStatefulWidget {
final PlaylistConfig playlist;
const LiveTVTab({super.key, required this.playlist});
@override
ConsumerState<LiveTVTab> createState() => _LiveTVTabState();
}
class _LiveTVTabState extends ConsumerState<LiveTVTab>
with AutomaticKeepAliveClientMixin {
// State
String? _selectedCategory;
// UI State
bool _isGridView = true;
final TextEditingController _searchController = TextEditingController();
bool _showFavoritesOnly = false;
String _searchQuery = '';
// Scroll Controllers
final ScrollController _mainScrollController = ScrollController();
@override
void initState() {
super.initState();
_searchController.addListener(() {
setState(() {
_searchQuery = _searchController.text.toLowerCase();
});
});
}
@override
void dispose() {
_searchController.dispose();
_mainScrollController.dispose();
super.dispose();
}
@override
bool get wantKeepAlive => true;
@override
Widget build(BuildContext context) {
super.build(context);
final channelsAsync =
ref.watch(liveChannelsByPlaylistProvider(widget.playlist));
final favorites = ref.watch(favoritesProvider);
final settings = ref.watch(iptvSettingsProvider);
return Scaffold(
backgroundColor: Colors.transparent,
body: channelsAsync.when(
loading: () => const ThemedLoading(),
error: (e, s) => Center(child: Text('Error: $e')),
data: (groupedChannels) {
// Groups
var categories = groupedChannels.keys.toList();
if (settings.liveTvKeywords.isNotEmpty) {
categories = categories
.where((cat) => settings.matchesLiveTvFilter(cat))
.toList();
}
categories.sort();
// Filter Logic
List<Channel> displayedChannels = [];
bool showingCategoryGrid = false;
if (_searchQuery.isNotEmpty) {
// Global Search
displayedChannels = groupedChannels.values
.expand((l) => l)
.where((c) => c.name.toLowerCase().contains(_searchQuery))
.toList();
} else if (_showFavoritesOnly) {
// Favorites
displayedChannels = groupedChannels.values
.expand((l) => l)
.where((c) => favorites.contains(c.streamId))
.toList();
} else if (_selectedCategory != null) {
// Selected Category View
displayedChannels = groupedChannels[_selectedCategory] ?? [];
} else {
// Category Grid View (Default)
showingCategoryGrid = true;
}
return Column(
children: [
// Header
Container(
padding: const EdgeInsets.fromLTRB(24, 16, 24, 16),
child: Row(
children: [
// Back Button (if in category view and not searching)
if (_selectedCategory != null &&
_searchQuery.isEmpty &&
!_showFavoritesOnly) ...[
IconButton(
icon: const Icon(
Icons.arrow_back,
color: AppColors.textPrimary,
),
onPressed: () {
setState(() {
_selectedCategory = null;
_mainScrollController.jumpTo(0);
});
},
tooltip: 'Back to Categories',
),
const SizedBox(width: 8),
],
Text(
_searchQuery.isNotEmpty
? 'Search Results'
: _showFavoritesOnly
? 'Favorites'
: _selectedCategory ?? 'Live TV',
style:
Theme.of(context).textTheme.headlineMedium?.copyWith(
color: Colors.white,
fontWeight: FontWeight.bold,
),
),
const Spacer(),
// Search Pill
Container(
width: 300,
height: 40,
decoration: BoxDecoration(
color: AppColors.surface,
borderRadius:
BorderRadius.circular(AppTheme.radiusFull),
border: Border.all(color: AppColors.border),
),
padding: const EdgeInsets.symmetric(horizontal: 12),
child: Row(
children: [
const Icon(
Icons.search,
size: 20,
color: AppColors.textSecondary,
),
const SizedBox(width: 8),
Expanded(
child: TextField(
controller: _searchController,
style: const TextStyle(
fontSize: 14,
color: Colors.white,
),
decoration: const InputDecoration(
hintText: 'Rechercher...',
hintStyle: TextStyle(color: Colors.white54),
border: InputBorder.none,
isDense: true,
contentPadding: EdgeInsets.only(bottom: 11),
),
),
),
if (_searchQuery.isNotEmpty)
GestureDetector(
onTap: () => _searchController.clear(),
child: const Icon(
Icons.close,
size: 16,
color: AppColors.textSecondary,
),
),
],
),
),
const SizedBox(width: 16),
// Favorites Toggle
IconButton(
icon: Icon(
_showFavoritesOnly
? Icons.favorite
: Icons.favorite_border,
color: _showFavoritesOnly
? AppColors.error
: AppColors.textSecondary,
),
onPressed: () {
setState(() {
_showFavoritesOnly = !_showFavoritesOnly;
if (_showFavoritesOnly) _searchController.clear();
_selectedCategory =
null; // Reset selection when toggling favorites
});
},
tooltip: 'Favorites Only',
),
// View Toggle (Only show when not in category grid)
if (!showingCategoryGrid)
IconButton(
icon: Icon(
_isGridView ? Icons.view_list : Icons.grid_view,
color: AppColors.textSecondary,
),
onPressed: () =>
setState(() => _isGridView = !_isGridView),
tooltip: 'Toggle View',
),
],
),
),
// Content
Expanded(
child: showingCategoryGrid
? _buildCategoryGrid(categories, groupedChannels)
: displayedChannels.isEmpty
? Center(
child: Text(
_searchQuery.isNotEmpty
? 'No channels found'
: _showFavoritesOnly
? 'No favorites'
: 'No channels in this group',
style: Theme.of(context)
.textTheme
.bodyLarge
?.copyWith(color: AppColors.textSecondary),
),
)
: _isGridView
? _buildChannelGrid(displayedChannels)
: _buildChannelList(displayedChannels),
),
],
);
},
),
);
}
Widget _buildCategoryGrid(
List<String> categories,
Map<String, List<Channel>> groupedChannels,
) {
final columns = ResponsiveLayout.value(
context,
mobile: 2,
tablet: 3,
desktop: 5,
);
return GridView.builder(
controller: _mainScrollController,
padding: const EdgeInsets.all(24),
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: columns,
mainAxisSpacing: 16,
crossAxisSpacing: 16,
childAspectRatio: 1.5,
),
itemCount: categories.length,
itemBuilder: (context, index) {
final category = categories[index];
final count = groupedChannels[category]?.length ?? 0;
return Card(
clipBehavior: Clip.antiAlias,
elevation: 2,
shape:
RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
child: InkWell(
onTap: () {
setState(() {
_selectedCategory = category;
_mainScrollController.jumpTo(0);
});
},
child: Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
AppColors.surfaceVariant,
AppColors.surfaceVariant.withOpacity(0.8),
],
),
border: Border.all(color: Colors.white.withOpacity(0.2)),
borderRadius: BorderRadius.circular(12),
),
padding: const EdgeInsets.all(16),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: AppColors.primary.withOpacity(0.1),
shape: BoxShape.circle,
),
child: const Icon(
Icons.folder_open,
color: AppColors.primary,
size: 32,
),
),
const SizedBox(height: 12),
Text(
category,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: AppColors.textPrimary,
),
textAlign: TextAlign.center,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 4),
Text(
'$count Channels',
style: const TextStyle(
fontSize: 12,
color: AppColors.textSecondary,
),
),
],
),
),
),
);
},
);
}
Widget _buildChannelGrid(List<Channel> channels) {
final columns = ResponsiveLayout.value(
context,
mobile: 2,
tablet: 4,
desktop: 6,
);
return GridView.builder(
controller: _mainScrollController,
padding: const EdgeInsets.fromLTRB(24, 0, 24, 24),
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: columns,
mainAxisSpacing: 16,
crossAxisSpacing: 16,
childAspectRatio: 1.3,
),
itemCount: channels.length,
itemBuilder: (context, index) {
final channel = channels[index];
return ChannelCard(
streamId: channel.streamId,
name: channel.name,
iconUrl: _getProxiedIconUrl(channel.streamIcon),
currentProgram: null,
playlist: widget.playlist,
onTap: () => _playChannel(channel, channels),
);
},
);
}
Widget _buildChannelList(List<Channel> channels) {
return ListView.separated(
controller: _mainScrollController,
padding: const EdgeInsets.fromLTRB(24, 0, 24, 24),
itemCount: channels.length,
separatorBuilder: (_, __) => const SizedBox(height: 8),
itemBuilder: (context, index) {
final channel = channels[index];
return SizedBox(
height: 70,
child: ChannelCard(
streamId: channel.streamId,
name: channel.name,
iconUrl: _getProxiedIconUrl(channel.streamIcon),
height: 70,
width: double.infinity,
playlist: widget.playlist,
onTap: () => _playChannel(channel, channels),
),
);
},
);
}
String? _getProxiedIconUrl(String originalUrl) {
if (originalUrl.isEmpty) return null;
if (originalUrl.startsWith('http://')) {
return '/api/xtream/$originalUrl';
}
return originalUrl;
}
void _playChannel(Channel channel, List<Channel> contextList) {
final index = contextList.indexOf(channel);
Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => PlayerScreen(
streamId: channel.streamId,
title: channel.name,
playlist: widget.playlist,
streamType: StreamType.live,
channels: contextList,
initialIndex: index,
),
),
);
}
}
-355
View File
@@ -1,355 +0,0 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/models/playlist_config.dart';
import '../../../core/theme/app_colors.dart';
import '../../../core/theme/app_theme.dart';
import '../../../core/utils/responsive_layout.dart';
import '../../../core/widgets/components/hero_carousel.dart';
import '../../../core/widgets/components/ui_components.dart';
import '../../../core/widgets/themed_loading_screen.dart';
import '../providers/watch_history_provider.dart';
import '../models/xtream_models.dart';
import '../providers/xtream_provider.dart';
import '../providers/settings_provider.dart';
import '../screens/player_screen.dart';
class MoviesTab extends ConsumerStatefulWidget {
final PlaylistConfig playlist;
const MoviesTab({super.key, required this.playlist});
@override
ConsumerState<MoviesTab> createState() => _MoviesTabState();
}
class _MoviesTabState extends ConsumerState<MoviesTab> {
final ScrollController _scrollController = ScrollController();
final TextEditingController _searchController = TextEditingController();
final List<Movie> _movies = [];
List<Movie>? _searchResults;
String _searchQuery = '';
bool _isLoading = false;
bool _isSearching = false;
bool _hasMore = true;
int _currentOffset = 0;
static const int _pageSize = 100;
Timer? _searchDebounce;
@override
void initState() {
super.initState();
_loadMoreMovies();
_scrollController.addListener(_onScroll);
}
@override
void dispose() {
_scrollController.dispose();
_searchController.dispose();
_searchDebounce?.cancel();
super.dispose();
}
void _onScroll() {
if (_scrollController.position.pixels >=
_scrollController.position.maxScrollExtent * 0.8) {
_loadMoreMovies();
}
}
void _onSearchChanged(String query) {
_searchDebounce?.cancel();
setState(() {
_searchQuery = query;
if (query.isEmpty) {
_searchResults = null;
_isSearching = false;
}
});
if (query.length >= 2) {
_searchDebounce = Timer(const Duration(milliseconds: 500), () {
_performSearch(query);
});
}
}
Future<void> _performSearch(String query) async {
if (query.isEmpty) return;
setState(() => _isSearching = true);
try {
final service = ref.read(xtreamServiceProvider(widget.playlist));
final results = await service.searchMovies(query);
if (mounted && _searchQuery == query) {
setState(() {
_searchResults = results;
_isSearching = false;
});
}
} catch (e) {
if (mounted) {
setState(() => _isSearching = false);
}
}
}
Future<void> _loadMoreMovies() async {
if (_isLoading || !_hasMore) return;
setState(() {
_isLoading = true;
});
try {
final service = ref.read(xtreamServiceProvider(widget.playlist));
final newMovies = await service.getMoviesPaginated(
offset: _currentOffset,
limit: _pageSize,
);
setState(() {
_movies.addAll(newMovies);
_currentOffset += _pageSize;
_hasMore = newMovies.length == _pageSize;
_isLoading = false;
});
} catch (e) {
setState(() {
_isLoading = false;
});
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Failed to load movies: $e')),
);
}
}
}
String? _formatRating(String? rating) {
if (rating == null || rating.isEmpty) return null;
final value = double.tryParse(rating);
if (value != null) {
return value.toStringAsFixed(1);
}
return rating;
}
/// Proxy HTTP images through backend to avoid CORS/mixed-content issues
String _getProxiedImageUrl(String? originalUrl) {
if (originalUrl == null || originalUrl.isEmpty) return '';
if (originalUrl.startsWith('http://')) {
return '/api/xtream/$originalUrl';
}
return originalUrl;
}
void _playMovie(Movie movie) {
ref.read(watchHistoryProvider.notifier).markMovieWatched(movie.streamId);
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => PlayerScreen(
streamId: movie.streamId,
title: movie.name,
playlist: widget.playlist,
streamType: StreamType.vod,
containerExtension: movie.containerExtension ?? 'mp4',
),
),
);
}
@override
Widget build(BuildContext context) {
final settings = ref.watch(iptvSettingsProvider);
final watchHistory = ref.watch(watchHistoryProvider);
List<Movie> displayMovies;
if (_searchQuery.isNotEmpty && _searchResults != null) {
displayMovies = _searchResults!;
} else {
displayMovies = settings.moviesKeywords.isEmpty
? _movies
: _movies
.where((m) => settings.matchesMoviesFilter(m.categoryName))
.toList();
}
// Hero Items (Take 5 random or first 5 from filtered list)
final heroItems = displayMovies
.take(5)
.map(
(m) => HeroItem(
id: m.streamId,
title: m.name,
imageUrl: _getProxiedImageUrl(m.streamIcon),
subtitle: m.rating != null ? '${_formatRating(m.rating)} ★' : null,
onMoreInfo: () {
_playMovie(m);
},
),
)
.toList();
if (_movies.isEmpty && !_isLoading) {
return const Center(child: Text('No movies available'));
}
const double gridItemRatio = 0.65;
final int crossAxisCount = ResponsiveLayout.value(
context,
mobile: 3,
tablet: 5,
desktop: 7,
);
return CustomScrollView(
controller: _scrollController,
slivers: [
// Header
SliverToBoxAdapter(
child: Container(
padding: const EdgeInsets.fromLTRB(24, 16, 24, 8),
child: Row(
children: [
Text(
'Movies',
style: Theme.of(context).textTheme.headlineMedium?.copyWith(
color: Colors.white,
fontWeight: FontWeight.bold,
),
),
const Spacer(),
Container(
width: 300,
height: 40,
decoration: BoxDecoration(
color: AppColors.surface,
borderRadius: BorderRadius.circular(AppTheme.radiusFull),
border: Border.all(color: AppColors.border),
),
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Row(
children: [
const Icon(
Icons.search,
size: 20,
color: AppColors.textSecondary,
),
const SizedBox(width: 8),
Expanded(
child: TextField(
controller: _searchController,
style: const TextStyle(
fontSize: 14,
color: Colors.white,
),
decoration: const InputDecoration(
hintText: 'Rechercher...',
hintStyle: TextStyle(color: Colors.white54),
border: InputBorder.none,
enabledBorder: InputBorder.none,
focusedBorder: InputBorder.none,
contentPadding: EdgeInsets.only(bottom: 12),
isDense: true,
),
onChanged: _onSearchChanged,
),
),
if (_isSearching)
const SizedBox(
width: 12,
height: 12,
child: CircularProgressIndicator(strokeWidth: 2),
),
if (_searchQuery.isNotEmpty)
GestureDetector(
onTap: () {
_searchController.clear();
_onSearchChanged('');
},
child: const Icon(
Icons.close,
size: 16,
color: AppColors.textSecondary,
),
),
],
),
),
],
),
),
),
// Hero Carousel
if (_searchQuery.isEmpty && heroItems.isNotEmpty)
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.only(bottom: 24),
child: HeroCarousel(
items: heroItems,
onTap: (item) {
final movie = _movies
.firstWhere((element) => element.streamId == item.id);
_playMovie(movie);
},
),
),
),
// Grid
SliverPadding(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 8),
sliver: SliverGrid(
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: crossAxisCount,
crossAxisSpacing: 24,
mainAxisSpacing: 32,
childAspectRatio: gridItemRatio,
),
delegate: SliverChildBuilderDelegate(
(context, index) {
if (index >= displayMovies.length) return null;
final movie = displayMovies[index];
final isWatched = watchHistory.isMovieWatched(movie.streamId);
return MediaCard(
title: movie.name,
imageUrl: _getProxiedImageUrl(movie.streamIcon),
subtitle: movie.rating != null
? '${_formatRating(movie.rating)} ★'
: null,
rating: _formatRating(movie.rating),
isWatched: isWatched,
onTap: () => _playMovie(movie),
onLongPress: () {
ref
.read(watchHistoryProvider.notifier)
.toggleMovieWatched(movie.streamId);
},
);
},
childCount: displayMovies.length,
),
),
),
// Loader
if (_isLoading)
const SliverToBoxAdapter(
child: Padding(
padding: EdgeInsets.all(24),
child: ThemedLoading(),
),
),
],
);
}
}
-341
View File
@@ -1,341 +0,0 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/models/playlist_config.dart';
import '../../../core/theme/app_colors.dart';
import '../../../core/theme/app_theme.dart';
import '../../../core/utils/responsive_layout.dart';
import '../../../core/widgets/components/hero_carousel.dart';
import '../../../core/widgets/components/ui_components.dart';
import '../../../core/widgets/themed_loading_screen.dart';
import '../models/xtream_models.dart';
import '../providers/xtream_provider.dart';
import '../providers/settings_provider.dart';
import '../screens/series_detail_screen.dart';
class SeriesTab extends ConsumerStatefulWidget {
final PlaylistConfig playlist;
const SeriesTab({super.key, required this.playlist});
@override
ConsumerState<SeriesTab> createState() => _SeriesTabState();
}
class _SeriesTabState extends ConsumerState<SeriesTab> {
final ScrollController _scrollController = ScrollController();
final TextEditingController _searchController = TextEditingController();
final List<Series> _series = [];
List<Series>? _searchResults;
String _searchQuery = '';
bool _isLoading = false;
bool _isSearching = false;
bool _hasMore = true;
int _currentOffset = 0;
static const int _pageSize = 100;
Timer? _searchDebounce;
@override
void initState() {
super.initState();
_loadMoreSeries();
_scrollController.addListener(_onScroll);
}
@override
void dispose() {
_scrollController.dispose();
_searchController.dispose();
_searchDebounce?.cancel();
super.dispose();
}
void _onScroll() {
if (_scrollController.position.pixels >=
_scrollController.position.maxScrollExtent * 0.8) {
_loadMoreSeries();
}
}
void _onSearchChanged(String query) {
_searchDebounce?.cancel();
setState(() {
_searchQuery = query;
if (query.isEmpty) {
_searchResults = null;
_isSearching = false;
}
});
if (query.length >= 2) {
_searchDebounce = Timer(const Duration(milliseconds: 500), () {
_performSearch(query);
});
}
}
Future<void> _performSearch(String query) async {
if (query.isEmpty) return;
setState(() => _isSearching = true);
try {
final service = ref.read(xtreamServiceProvider(widget.playlist));
final results = await service.searchSeries(query);
if (mounted && _searchQuery == query) {
setState(() {
_searchResults = results;
_isSearching = false;
});
}
} catch (e) {
if (mounted) {
setState(() => _isSearching = false);
}
}
}
Future<void> _loadMoreSeries() async {
if (_isLoading || !_hasMore) return;
setState(() {
_isLoading = true;
});
try {
final service = ref.read(xtreamServiceProvider(widget.playlist));
final newSeries = await service.getSeriesPaginated(
offset: _currentOffset,
limit: _pageSize,
);
setState(() {
_series.addAll(newSeries);
_currentOffset += _pageSize;
_hasMore = newSeries.length == _pageSize;
_isLoading = false;
});
} catch (e) {
setState(() {
_isLoading = false;
});
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Failed to load series: $e')),
);
}
}
}
String? _formatRating(String? rating) {
if (rating == null || rating.isEmpty) return null;
final value = double.tryParse(rating);
if (value != null) {
return value.toStringAsFixed(1);
}
return rating;
}
/// Proxy HTTP images through backend to avoid CORS/mixed-content issues
String _getProxiedImageUrl(String? originalUrl) {
if (originalUrl == null || originalUrl.isEmpty) return '';
if (originalUrl.startsWith('http://')) {
return '/api/xtream/$originalUrl';
}
return originalUrl;
}
void _openSeries(Series series) {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => SeriesDetailScreen(
series: series,
playlist: widget.playlist,
),
),
);
}
@override
Widget build(BuildContext context) {
final settings = ref.watch(iptvSettingsProvider);
List<Series> displaySeries;
if (_searchQuery.isNotEmpty && _searchResults != null) {
displaySeries = _searchResults!;
} else {
displaySeries = settings.seriesKeywords.isEmpty
? _series
: _series
.where((s) => settings.matchesSeriesFilter(s.categoryName))
.toList();
}
if (_series.isEmpty && !_isLoading) {
return const Center(child: Text('No series available'));
}
// Hero Items
final heroItems = displaySeries
.take(5)
.map(
(s) => HeroItem(
id: s.seriesId.toString(), // ID logic might differ for Series
title: s.name,
imageUrl: _getProxiedImageUrl(s.cover),
subtitle: s.rating != null ? '${_formatRating(s.rating)} ★' : null,
onMoreInfo: () => _openSeries(s),
),
)
.toList();
const double gridItemRatio = 0.65;
final int crossAxisCount = ResponsiveLayout.value(
context,
mobile: 3,
tablet: 5,
desktop: 7,
);
return CustomScrollView(
controller: _scrollController,
slivers: [
// Header
SliverToBoxAdapter(
child: Container(
padding: const EdgeInsets.fromLTRB(24, 16, 24, 8),
child: Row(
children: [
Text(
'TV Series',
style: Theme.of(context).textTheme.headlineMedium?.copyWith(
color: Colors.white,
fontWeight: FontWeight.bold,
),
),
const Spacer(),
Container(
width: 300,
height: 40,
decoration: BoxDecoration(
color: AppColors.surface,
borderRadius: BorderRadius.circular(AppTheme.radiusFull),
border: Border.all(color: AppColors.border),
),
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Row(
children: [
const Icon(
Icons.search,
size: 20,
color: AppColors.textSecondary,
),
const SizedBox(width: 8),
Expanded(
child: TextField(
controller: _searchController,
style: const TextStyle(
fontSize: 14,
color: Colors.white,
),
decoration: const InputDecoration(
hintText: 'Rechercher...',
hintStyle: TextStyle(color: Colors.white54),
border: InputBorder.none,
enabledBorder: InputBorder.none,
focusedBorder: InputBorder.none,
contentPadding: EdgeInsets.only(bottom: 12),
isDense: true,
),
onChanged: _onSearchChanged,
),
),
if (_isSearching)
const SizedBox(
width: 12,
height: 12,
child: CircularProgressIndicator(strokeWidth: 2),
),
if (_searchQuery.isNotEmpty)
GestureDetector(
onTap: () {
_searchController.clear();
_onSearchChanged('');
},
child: const Icon(
Icons.close,
size: 16,
color: AppColors.textSecondary,
),
),
],
),
),
],
),
),
),
// Hero Carousel
if (_searchQuery.isEmpty && heroItems.isNotEmpty)
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.only(bottom: 24),
child: HeroCarousel(
items: heroItems,
onTap: (item) {
final series = _series.firstWhere(
(s) => s.name == item.title,
); // Fallback by title if ID mismatch
_openSeries(series);
},
),
),
),
// Grid
SliverPadding(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 8),
sliver: SliverGrid(
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: crossAxisCount,
crossAxisSpacing: 24,
mainAxisSpacing: 32,
childAspectRatio: gridItemRatio,
),
delegate: SliverChildBuilderDelegate(
(context, index) {
if (index >= displaySeries.length) return null;
final serie = displaySeries[index];
return MediaCard(
title: serie.name,
imageUrl: _getProxiedImageUrl(serie.cover),
subtitle: serie.rating != null
? '${_formatRating(serie.rating)} ★'
: null,
rating: _formatRating(serie.rating),
placeholderIcon: Icons.tv,
onTap: () => _openSeries(serie),
);
},
childCount: displaySeries.length,
),
),
),
// Loader
if (_isLoading)
const SliverToBoxAdapter(
child: Padding(
padding: EdgeInsets.all(24),
child: ThemedLoading(),
),
),
],
);
}
}
-646
View File
@@ -1,646 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:universal_html/html.dart' as html; // For reloading
import '../../auth/providers/auth_provider.dart';
import '../providers/settings_provider.dart';
import 'streaming_settings_tab.dart';
import '../../../core/providers/theme_provider.dart';
import '../../../core/theme/app_colors.dart';
import '../../admin/screens/admin_panel.dart';
/// Main Settings tab with sub-tabs for Filters, Streaming, and Appearance
class SettingsTab extends ConsumerStatefulWidget {
const SettingsTab({super.key});
@override
ConsumerState<SettingsTab> createState() => _SettingsTabState();
}
class _SettingsTabState extends ConsumerState<SettingsTab>
with SingleTickerProviderStateMixin {
late TabController _tabController;
late TextEditingController _liveTvController;
late TextEditingController _moviesController;
late TextEditingController _seriesController;
bool _initialized = false;
@override
void initState() {
super.initState();
final isAdmin = ref.read(authProvider).currentUser?.isAdmin ?? false;
_tabController = TabController(length: isAdmin ? 4 : 3, vsync: this);
_liveTvController = TextEditingController();
_moviesController = TextEditingController();
_seriesController = TextEditingController();
}
@override
void dispose() {
_tabController.dispose();
_liveTvController.dispose();
_moviesController.dispose();
_seriesController.dispose();
super.dispose();
}
void _syncControllers(IptvSettings settings) {
if (!_initialized ||
_liveTvController.text != settings.liveTvCategoryFilter) {
_liveTvController.text = settings.liveTvCategoryFilter;
}
if (!_initialized ||
_moviesController.text != settings.moviesCategoryFilter) {
_moviesController.text = settings.moviesCategoryFilter;
}
if (!_initialized ||
_seriesController.text != settings.seriesCategoryFilter) {
_seriesController.text = settings.seriesCategoryFilter;
}
_initialized = true;
}
@override
Widget build(BuildContext context) {
final currentUser = ref.watch(authProvider).currentUser;
final settings = ref.watch(iptvSettingsProvider);
// Sync controllers with persisted state (only on first load)
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!_initialized) _syncControllers(settings);
});
return Column(
children: [
// TabBar
Container(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.05),
borderRadius: BorderRadius.circular(50),
),
child: TabBar(
controller: _tabController,
indicator: BoxDecoration(
color: Colors.white.withOpacity(0.1),
borderRadius: BorderRadius.circular(50),
),
labelColor: Colors.white,
unselectedLabelColor: Colors.white54,
dividerColor: Colors.transparent,
overlayColor: WidgetStateProperty.all(Colors.transparent),
tabs: [
const Tab(icon: Icon(Icons.filter_list), text: 'Filtres'),
const Tab(icon: Icon(Icons.stream), text: 'Streaming'),
const Tab(icon: Icon(Icons.palette), text: 'Apparence'),
if (currentUser?.isAdmin ?? false)
const Tab(
icon: Icon(Icons.admin_panel_settings),
text: 'Administration',
),
],
),
),
// TabBarView
Expanded(
child: TabBarView(
controller: _tabController,
children: [
// Filters Tab
_buildFiltersTab(context, currentUser, settings),
// Streaming Tab
const StreamingSettingsTab(),
// Appearance Tab
_buildAppearanceTab(context),
// Admin Tab
if (currentUser?.isAdmin ?? false) const AdminContent(),
],
),
),
],
);
}
/// Build Appearance tab with theme toggle
Widget _buildAppearanceTab(BuildContext context) {
final themeState = ref.watch(themeProvider);
final themeNotifier = ref.read(themeProvider.notifier);
final isDark = Theme.of(context).brightness == Brightness.dark;
return ListView(
padding: const EdgeInsets.all(16),
children: [
// Theme selection card
Container(
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.05),
borderRadius: BorderRadius.circular(16),
border: Border.all(color: Colors.white.withOpacity(0.1)),
),
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Icon(
Icons.dark_mode,
color: Colors.white,
),
const SizedBox(width: 12),
Text(
'Thème',
style: GoogleFonts.inter(
fontSize: 18,
fontWeight: FontWeight.w600,
color: Colors.white,
),
),
],
),
const SizedBox(height: 16),
// Theme options
_buildThemeOption(
context: context,
title: 'Système',
subtitle: 'Suivre les paramètres du système',
icon: Icons.settings_suggest,
isSelected: themeState.appThemeMode == AppThemeMode.system,
onTap: () => themeNotifier.setThemeMode(AppThemeMode.system),
),
const SizedBox(height: 8),
_buildThemeOption(
context: context,
title: 'Sombre',
subtitle: 'Interface sombre premium',
icon: Icons.dark_mode,
isSelected: themeState.appThemeMode == AppThemeMode.dark,
onTap: () => themeNotifier.setThemeMode(AppThemeMode.dark),
),
const SizedBox(height: 8),
_buildThemeOption(
context: context,
title: 'Clair',
subtitle: 'Interface claire et lumineuse',
icon: Icons.light_mode,
isSelected: themeState.appThemeMode == AppThemeMode.light,
onTap: () => themeNotifier.setThemeMode(AppThemeMode.light),
),
],
),
),
const SizedBox(height: 16),
// Quick toggle card
Container(
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.05),
borderRadius: BorderRadius.circular(16),
border: Border.all(color: Colors.white.withOpacity(0.1)),
),
child: ListTile(
leading: Icon(
isDark ? Icons.dark_mode : Icons.light_mode,
color: Colors.white,
),
title: Text(
'Mode actuel',
style: GoogleFonts.inter(
fontWeight: FontWeight.w500,
color: Colors.white,
),
),
subtitle: Text(
isDark ? 'Thème sombre actif' : 'Thème clair actif',
style: const TextStyle(color: Colors.white70),
),
trailing: Switch(
value: isDark,
onChanged: (_) => themeNotifier.toggleTheme(),
activeThumbColor: AppColors.primary,
trackColor: WidgetStateProperty.resolveWith(
(states) => states.contains(WidgetState.selected)
? AppColors.primary.withOpacity(0.5)
: Colors.grey.shade800,
),
),
),
),
],
);
}
Widget _buildThemeOption({
required BuildContext context,
required String title,
required String subtitle,
required IconData icon,
required bool isSelected,
required VoidCallback onTap,
}) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
return InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(12),
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: isSelected
? colorScheme.primary.withOpacity(0.1)
: colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: isSelected ? colorScheme.primary : Colors.transparent,
width: 2,
),
),
child: Row(
children: [
Icon(
icon,
color: isSelected
? colorScheme.primary
: colorScheme.onSurfaceVariant,
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: GoogleFonts.inter(
fontWeight: FontWeight.w600,
color: isSelected
? colorScheme.primary
: colorScheme.onSurface,
),
),
Text(
subtitle,
style: GoogleFonts.inter(
fontSize: 12,
color: colorScheme.onSurfaceVariant,
),
),
],
),
),
if (isSelected)
Icon(
Icons.check_circle,
color: colorScheme.primary,
),
],
),
),
);
}
Widget _buildFiltersTab(
BuildContext context,
dynamic currentUser,
IptvSettings settings,
) {
return ListView(
padding: const EdgeInsets.all(16),
children: [
// User info card
Container(
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.05),
borderRadius: BorderRadius.circular(16),
border: Border.all(color: Colors.white.withOpacity(0.1)),
),
padding: const EdgeInsets.all(4),
child: ListTile(
leading: const Icon(Icons.person, color: Colors.white),
title: Text(
'Connecté en tant que',
style: GoogleFonts.roboto(fontSize: 12, color: Colors.white70),
),
subtitle: Text(
currentUser?.username ?? 'Unknown',
style: GoogleFonts.roboto(
fontWeight: FontWeight.w600,
color: Colors.white,
),
),
trailing: currentUser?.isAdmin ?? false
? Chip(
label: Text(
'Admin',
style: GoogleFonts.roboto(
fontSize: 11,
color: Colors.black,
),
),
backgroundColor: Colors.white,
)
: null,
),
),
const SizedBox(height: 16),
// Category filters section
Text(
'Filtres de Catégories',
style: GoogleFonts.roboto(
fontWeight: FontWeight.bold,
fontSize: 16,
color: Colors.white,
),
),
const SizedBox(height: 4),
Text(
'Seules les catégories contenant un de ces mots-clés seront affichées. Séparez par des virgules.',
style: GoogleFonts.roboto(
fontSize: 12,
color: Colors.white54,
),
),
const SizedBox(height: 12),
// Live TV Filter
_buildFilterCard(
title: 'Filtre TV Live',
icon: Icons.live_tv,
controller: _liveTvController,
keywords: settings.liveTvKeywords,
onChanged: (value) {
ref.read(iptvSettingsProvider.notifier).setLiveTvFilter(value);
},
onClear: () {
_liveTvController.clear();
ref.read(iptvSettingsProvider.notifier).clearLiveTvFilter();
},
),
const SizedBox(height: 8),
// Movies Filter
_buildFilterCard(
title: 'Filtre Films',
icon: Icons.movie,
controller: _moviesController,
keywords: settings.moviesKeywords,
onChanged: (value) {
ref.read(iptvSettingsProvider.notifier).setMoviesFilter(value);
},
onClear: () {
_moviesController.clear();
ref.read(iptvSettingsProvider.notifier).clearMoviesFilter();
},
),
const SizedBox(height: 8),
// Series Filter
_buildFilterCard(
title: 'Filtre Séries',
icon: Icons.tv,
controller: _seriesController,
keywords: settings.seriesKeywords,
onChanged: (value) {
ref.read(iptvSettingsProvider.notifier).setSeriesFilter(value);
},
onClear: () {
_seriesController.clear();
ref.read(iptvSettingsProvider.notifier).clearSeriesFilter();
},
),
const SizedBox(height: 16),
// Change playlist
Container(
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.05),
borderRadius: BorderRadius.circular(16),
border: Border.all(color: Colors.white.withOpacity(0.1)),
),
child: ListTile(
leading: const Icon(Icons.playlist_play, color: Colors.white),
title: Text(
'Changer de Playlist',
style: GoogleFonts.roboto(color: Colors.white),
),
trailing: const Icon(Icons.chevron_right, color: Colors.white54),
onTap: () => context.go('/playlists'),
),
),
const SizedBox(height: 8),
// About
Container(
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.05),
borderRadius: BorderRadius.circular(16),
border: Border.all(color: Colors.white.withOpacity(0.1)),
),
child: ListTile(
leading: const Icon(Icons.info_outline, color: Colors.white),
title: Text(
'À propos',
style: GoogleFonts.roboto(color: Colors.white),
),
subtitle: Text(
'XtremFlow IPTV v1.0.0',
style: GoogleFonts.roboto(fontSize: 12, color: Colors.white54),
),
),
),
const SizedBox(height: 16),
// Maintenance Section
Container(
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.05),
borderRadius: BorderRadius.circular(16),
border: Border.all(color: Colors.orange.withOpacity(0.3)),
),
child: ListTile(
leading: const Icon(Icons.cleaning_services, color: Colors.orange),
title: Text(
'Maintenance',
style: GoogleFonts.roboto(color: Colors.white),
),
subtitle: Text(
'En cas de problèmes d\'affichage ou de mise à jour',
style: GoogleFonts.roboto(fontSize: 12, color: Colors.white54),
),
trailing: FilledButton.icon(
onPressed: () {
// Show confirmation dialog
showDialog(
context: context,
builder: (context) => AlertDialog(
backgroundColor: const Color(0xFF1E1E1E),
title: const Text(
'Vider le cache ?',
style: TextStyle(color: Colors.white),
),
content: const Text(
'Cette action va recharger l\'application et effacer les données temporaires.\n'
'Vos réglages et favoris sont sauvegardés sur le serveur.',
style: TextStyle(color: Colors.white70),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Annuler'),
),
FilledButton(
style: FilledButton.styleFrom(
backgroundColor: Colors.orange,
),
onPressed: () {
Navigator.pop(context);
// Clear Flutter Image Cache
PaintingBinding.instance.imageCache.clear();
PaintingBinding.instance.imageCache.clearLiveImages();
// Clear Browser Local Storage (SharedPreferences backend for Web)
html.window.localStorage.clear();
html.window.sessionStorage.clear();
// Reload application
html.window.location.reload();
},
child: const Text('Vider et Recharger'),
),
],
),
);
},
icon: const Icon(Icons.refresh, size: 16),
label: const Text('Vider le Cache'),
style: FilledButton.styleFrom(
backgroundColor: Colors.orange.withOpacity(0.2),
foregroundColor: Colors.orange,
),
),
),
),
const SizedBox(height: 24),
// Logout button
FilledButton.icon(
onPressed: () {
ref.read(authProvider.notifier).logout();
context.go('/login');
},
icon: const Icon(Icons.logout),
label: const Text('Déconnexion'),
style: FilledButton.styleFrom(
backgroundColor: Colors.red.shade700,
padding: const EdgeInsets.symmetric(vertical: 16),
),
),
],
);
}
Widget _buildFilterCard({
required String title,
required IconData icon,
required TextEditingController controller,
required List<String> keywords,
required ValueChanged<String> onChanged,
required VoidCallback onClear,
}) {
return Container(
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.05),
borderRadius: BorderRadius.circular(16),
border: Border.all(color: Colors.white.withOpacity(0.1)),
),
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(icon, size: 18, color: Colors.blueAccent),
const SizedBox(width: 8),
Text(
title,
style: GoogleFonts.roboto(
fontWeight: FontWeight.w600,
fontSize: 13,
color: Colors.white,
),
),
],
),
const SizedBox(height: 12),
TextField(
controller: controller,
decoration: InputDecoration(
hintText: 'Ex: FR,FRANCE,HD,SPORT',
hintStyle:
GoogleFonts.roboto(fontSize: 12, color: Colors.white30),
filled: true,
fillColor: Colors.black26,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide.none,
),
isDense: true,
contentPadding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 12,
),
suffixIcon: controller.text.isNotEmpty
? IconButton(
icon: const Icon(
Icons.clear,
size: 16,
color: Colors.white54,
),
onPressed: onClear,
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
)
: null,
),
style: GoogleFonts.roboto(fontSize: 12, color: Colors.white),
onChanged: onChanged,
),
if (keywords.isNotEmpty) ...[
const SizedBox(height: 12),
Wrap(
spacing: 6,
runSpacing: 6,
children: keywords.map((keyword) {
return Container(
padding:
const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration(
color: Colors.blueAccent.withOpacity(0.2),
borderRadius: BorderRadius.circular(50),
border:
Border.all(color: Colors.blueAccent.withOpacity(0.5)),
),
child: Text(
keyword,
style:
GoogleFonts.roboto(fontSize: 11, color: Colors.white),
),
);
}).toList(),
),
],
],
),
);
}
}
@@ -1,278 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:google_fonts/google_fonts.dart';
import '../providers/settings_provider.dart';
/// Streaming settings tab for Live TV configuration
class StreamingSettingsTab extends ConsumerWidget {
const StreamingSettingsTab({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final settings = ref.watch(iptvSettingsProvider);
final notifier = ref.read(iptvSettingsProvider.notifier);
return ListView(
padding: const EdgeInsets.all(16),
children: [
// Header
Text(
'Paramètres Streaming Live TV',
style: GoogleFonts.roboto(
fontSize: 18,
fontWeight: FontWeight.bold,
color: Colors.white,
),
),
const SizedBox(height: 8),
Text(
'Ces paramètres s\'appliquent uniquement aux flux TV en direct.',
style: GoogleFonts.roboto(
fontSize: 12,
color: Colors.white54,
),
),
const SizedBox(height: 24),
// Quality Section
_buildSectionCard(
context: context,
title: 'Qualité Vidéo',
icon: Icons.high_quality,
children: [
_buildDropdownTile<StreamQuality>(
title: 'Qualité du flux',
subtitle: 'Bitrate: ${settings.bitrateKbps} kbps',
value: settings.streamQuality,
items: StreamQuality.values,
onChanged: (v) => notifier.setStreamQuality(v!),
labelBuilder: (v) => switch (v) {
StreamQuality.low => 'Faible (1.5 Mbps)',
StreamQuality.medium => 'Moyen (3 Mbps)',
StreamQuality.high => 'Élevé (5 Mbps)',
},
),
],
),
const SizedBox(height: 16),
// Buffer Section
_buildSectionCard(
context: context,
title: 'Buffer & Latence',
icon: Icons.speed,
children: [
_buildDropdownTile<BufferSize>(
title: 'Taille du buffer',
subtitle: 'Segments HLS: ${settings.hlsSegmentDuration}s',
value: settings.bufferSize,
items: BufferSize.values,
onChanged: (v) => notifier.setBufferSize(v!),
labelBuilder: (v) => switch (v) {
BufferSize.low => 'Faible (latence min)',
BufferSize.medium => 'Moyen (équilibré)',
BufferSize.high => 'Élevé (stabilité max)',
},
),
],
),
const SizedBox(height: 16),
// Connection Section
_buildSectionCard(
context: context,
title: 'Connexion',
icon: Icons.wifi,
children: [
_buildDropdownTile<ConnectionTimeout>(
title: 'Timeout de connexion',
subtitle: '${settings.timeoutSeconds} secondes',
value: settings.connectionTimeout,
items: ConnectionTimeout.values,
onChanged: (v) => notifier.setConnectionTimeout(v!),
labelBuilder: (v) => switch (v) {
ConnectionTimeout.short => 'Court (15s)',
ConnectionTimeout.medium => 'Moyen (30s)',
ConnectionTimeout.long => 'Long (60s)',
},
),
Divider(height: 1, color: Colors.white.withOpacity(0.1)),
SwitchListTile(
title: Text(
'Reconnexion automatique',
style: GoogleFonts.roboto(fontSize: 14, color: Colors.white),
),
subtitle: Text(
'Se reconnecter en cas de coupure',
style: GoogleFonts.roboto(fontSize: 12, color: Colors.white54),
),
value: settings.autoReconnect,
activeThumbColor: Colors.blueAccent,
onChanged: (v) => notifier.setAutoReconnect(v),
),
],
),
const SizedBox(height: 16),
// Transcoding Section
_buildSectionCard(
context: context,
title: 'Transcodage',
icon: Icons.transform,
children: [
_buildDropdownTile<TranscodingMode>(
title: 'Mode de transcodage',
subtitle: 'Comment le flux est traité',
value: settings.transcodingMode,
items: TranscodingMode.values,
onChanged: (v) => notifier.setTranscodingMode(v!),
labelBuilder: (v) => switch (v) {
TranscodingMode.auto => 'Auto (recommandé)',
TranscodingMode.forced => 'Forcé (toujours transcoder)',
TranscodingMode.disabled => 'Désactivé (direct)',
},
),
Divider(height: 1, color: Colors.white.withOpacity(0.1)),
SwitchListTile(
title: Text(
'Préférer lecture directe',
style: GoogleFonts.roboto(fontSize: 14, color: Colors.white),
),
subtitle: Text(
'Utiliser le flux original si compatible',
style: GoogleFonts.roboto(fontSize: 12, color: Colors.white54),
),
value: settings.preferDirectPlay,
activeThumbColor: Colors.blueAccent,
onChanged: (v) => notifier.setPreferDirectPlay(v),
),
],
),
const SizedBox(height: 16),
// Cache Section
_buildSectionCard(
context: context,
title: 'Cache EPG',
icon: Icons.schedule,
children: [
_buildDropdownTile<EpgCacheDuration>(
title: 'Durée du cache EPG',
subtitle: '${settings.epgCacheMinutes} minutes',
value: settings.epgCacheDuration,
items: EpgCacheDuration.values,
onChanged: (v) => notifier.setEpgCacheDuration(v!),
labelBuilder: (v) => switch (v) {
EpgCacheDuration.short => 'Court (5 min)',
EpgCacheDuration.medium => 'Moyen (15 min)',
EpgCacheDuration.long => 'Long (1 heure)',
},
),
],
),
const SizedBox(height: 32),
// Info card
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.blue.withOpacity(0.1),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.blue.withOpacity(0.3)),
),
child: Row(
children: [
const Icon(Icons.info_outline, color: Colors.blue),
const SizedBox(width: 12),
Expanded(
child: Text(
'Les changements prennent effet immédiatement pour les nouveaux flux.',
style: GoogleFonts.roboto(
fontSize: 12,
color: Colors.blue.shade700,
),
),
),
],
),
),
],
);
}
Widget _buildSectionCard({
required BuildContext context,
required String title,
required IconData icon,
required List<Widget> children,
}) {
return Container(
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.05),
borderRadius: BorderRadius.circular(16),
border: Border.all(color: Colors.white.withOpacity(0.1)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
Icon(icon, size: 20, color: Colors.blueAccent),
const SizedBox(width: 8),
Text(
title,
style: GoogleFonts.roboto(
fontSize: 16,
fontWeight: FontWeight.w600,
color: Colors.white,
),
),
],
),
),
Divider(height: 1, color: Colors.white.withOpacity(0.1)),
...children,
],
),
);
}
Widget _buildDropdownTile<T>({
required String title,
required String subtitle,
required T value,
required List<T> items,
required ValueChanged<T?> onChanged,
required String Function(T) labelBuilder,
}) {
return ListTile(
title: Text(
title,
style: GoogleFonts.roboto(fontSize: 14, color: Colors.white),
),
subtitle: Text(
subtitle,
style: GoogleFonts.roboto(fontSize: 12, color: Colors.white54),
),
trailing: DropdownButton<T>(
value: value,
underline: const SizedBox(),
dropdownColor: const Color(0xFF1C1C1E), // Dark background for dropdown
items: items.map((item) {
return DropdownMenuItem<T>(
value: item,
child: Text(
labelBuilder(item),
style: GoogleFonts.roboto(fontSize: 13, color: Colors.white),
),
);
}).toList(),
onChanged: onChanged,
style: GoogleFonts.roboto(color: Colors.white),
iconEnabledColor: Colors.white70,
),
);
}
}
-64
View File
@@ -1,64 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:hive_flutter/hive_flutter.dart';
import 'package:media_kit/media_kit.dart';
import 'core/theme/app_theme.dart';
import 'core/providers/theme_provider.dart';
import 'mobile/features/iptv/screens/mobile_playlist_screen.dart';
import 'mobile/features/iptv/screens/mobile_dashboard_screen.dart';
import 'core/models/playlist_config.dart';
import 'core/database/hive_service.dart';
/// Mobile-optimized entry point for XtremFlow IPTV
/// No authentication - direct access to playlist selection
void main() async {
WidgetsFlutterBinding.ensureInitialized();
// Initialize MediaKit (required for video playback)
MediaKit.ensureInitialized();
// Initialize Hive (Centralized)
await HiveService.init();
// CLEAR CACHE ON STARTUP (Requested by User)
try {
// Clear API/EPG Cache (using default box name 'dio_cache')
await Hive.deleteBoxFromDisk('dio_cache');
debugPrint('XtremFlow: API Cache cleared');
} catch (e) {
debugPrint('XtremFlow: Failed to clear cache: $e');
}
runApp(const ProviderScope(child: MobileApp()));
}
class MobileApp extends ConsumerWidget {
const MobileApp({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final themeState = ref.watch(themeProvider);
return MaterialApp(
title: 'XtremFlow IPTV',
debugShowCheckedModeBanner: false,
theme: AppTheme.lightTheme,
darkTheme: AppTheme.darkTheme,
themeMode: themeState.themeMode,
// Start directly at playlist selection (no auth)
home: const MobilePlaylistScreen(),
onGenerateRoute: (settings) {
// Handle navigation to dashboard with playlist
if (settings.name == '/dashboard') {
final playlist = settings.arguments as PlaylistConfig?;
if (playlist != null) {
return MaterialPageRoute(
builder: (context) => MobileDashboardScreen(playlist: playlist),
);
}
}
return null;
},
);
}
}
@@ -1,182 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../../../features/auth/providers/auth_provider.dart';
import '../../../../core/theme/app_colors.dart';
import '../../../theme/mobile_theme.dart';
class MobileLoginScreen extends ConsumerStatefulWidget {
const MobileLoginScreen({super.key});
@override
ConsumerState<MobileLoginScreen> createState() => _MobileLoginScreenState();
}
class _MobileLoginScreenState extends ConsumerState<MobileLoginScreen> {
final _formKey = GlobalKey<FormState>();
final _usernameController = TextEditingController();
final _passwordController = TextEditingController();
bool _obscurePassword = true;
@override
void dispose() {
_usernameController.dispose();
_passwordController.dispose();
super.dispose();
}
Future<void> _handleLogin() async {
if (!_formKey.currentState!.validate()) return;
final auth = ref.read(authProvider.notifier);
final success = await auth.login(
_usernameController.text.trim(),
_passwordController.text,
);
if (success && mounted) {
context.go('/playlists');
}
}
@override
Widget build(BuildContext context) {
final authState = ref.watch(authProvider);
return Theme(
data: MobileTheme.darkTheme,
child: Scaffold(
backgroundColor: AppColors.background,
body: Center(
child: SingleChildScrollView(
padding: const EdgeInsets.all(24.0),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Logo
Container(
width: 72,
height: 72,
decoration: BoxDecoration(
color: AppColors.surface,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: AppColors.border),
),
child: const Icon(
Icons.live_tv,
color: AppColors.primary,
size: 36,
),
),
const SizedBox(height: 16),
const Text(
'XtremFlow',
style: TextStyle(
fontSize: 28,
fontWeight: FontWeight.w800,
color: AppColors.textPrimary,
letterSpacing: -0.5,
),
),
const SizedBox(height: 4),
const Text(
'Mobile Edition',
style: TextStyle(
fontSize: 12,
color: AppColors.textSecondary,
letterSpacing: 1.5,
fontWeight: FontWeight.w500,
),
),
const SizedBox(height: 48),
// Form
Form(
key: _formKey,
child: Column(
children: [
TextFormField(
controller: _usernameController,
style: const TextStyle(color: AppColors.textPrimary),
decoration: const InputDecoration(
labelText: 'Username',
prefixIcon: Icon(Icons.person_outline),
),
validator: (value) =>
(value == null || value.trim().isEmpty)
? 'Required'
: null,
textInputAction: TextInputAction.next,
),
const SizedBox(height: 16),
TextFormField(
controller: _passwordController,
obscureText: _obscurePassword,
style: const TextStyle(color: AppColors.textPrimary),
decoration: InputDecoration(
labelText: 'Password',
prefixIcon: const Icon(Icons.lock_outline),
suffixIcon: IconButton(
icon: Icon(
_obscurePassword
? Icons.visibility_outlined
: Icons.visibility_off_outlined,
),
onPressed: () => setState(
() => _obscurePassword = !_obscurePassword,
),
),
),
validator: (value) => (value == null || value.isEmpty)
? 'Required'
: null,
onFieldSubmitted: (_) => _handleLogin(),
),
if (authState.errorMessage != null) ...[
const SizedBox(height: 16),
Text(
authState.errorMessage!,
style: const TextStyle(
color: AppColors.error,
fontSize: 12,
),
textAlign: TextAlign.center,
),
],
const SizedBox(height: 32),
SizedBox(
width: double.infinity,
child: FilledButton(
onPressed: authState.isLoading ? null : _handleLogin,
child: authState.isLoading
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.black,
),
)
: const Text('Sign In'),
),
),
],
),
),
const SizedBox(height: 24),
const Text(
'Default: admin / admin',
style: TextStyle(
fontSize: 12,
color: AppColors.textTertiary,
fontStyle: FontStyle.italic,
),
),
],
),
),
),
),
);
}
}
@@ -1,37 +0,0 @@
import 'package:flutter/material.dart';
import '../../../../features/iptv/screens/player_screen.dart';
export '../../../../features/iptv/screens/player_screen.dart' show StreamType;
import '../../../../core/models/playlist_config.dart';
/// Mobile wrapper for the PlayerScreen.
/// Currently reuses the desktop PlayerScreen as it adapts well to mobile (video player),
/// but allows for future mobile-specific overlays or gesture controls.
class MobilePlayerScreen extends StatelessWidget {
final String streamId;
final String title;
final StreamType streamType;
final String containerExtension;
final PlaylistConfig playlist;
const MobilePlayerScreen({
super.key,
required this.streamId,
required this.title,
required this.playlist,
this.streamType = StreamType.live,
this.containerExtension = 'mp4',
});
@override
Widget build(BuildContext context) {
// For now, we reuse the existing PlayerScreen which is responsive.
// In the future, we can add Mobile-specific gesture detectors here.
return PlayerScreen(
streamId: streamId,
title: title,
playlist: playlist,
streamType: streamType,
containerExtension: containerExtension,
);
}
}
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 425 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 425 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 425 KiB

-113
View File
@@ -1,113 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<base href="$FLUTTER_BASE_HREF">
<meta charset="UTF-8">
<meta content="IE=Edge" http-equiv="X-UA-Compatible">
<meta name="description" content="XtremFlow - Modern IPTV Streaming Application">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="apple-mobile-web-app-title" content="XtremFlow">
<meta name="theme-color" content="#0A0E1A">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>XtremFlow IPTV</title>
<!-- Favicon -->
<link rel="icon" type="image/png" href="favicon.png">
<link rel="apple-touch-icon" href="icons/Icon-192.png">
<link rel="manifest" href="manifest.json">
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
background: linear-gradient(135deg, #0A0E1A 0%, #121829 100%);
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
font-family: 'Inter', 'Roboto', sans-serif;
overflow: hidden;
}
/* Fullscreen mode - prevent scrollbars */
:fullscreen,
::backdrop {
overflow: hidden !important;
}
*:fullscreen,
*:fullscreen * {
overflow: hidden !important;
}
.loading-container {
text-align: center;
animation: fadeIn 0.5s ease-out;
}
.logo {
font-size: 48px;
font-weight: 700;
background: linear-gradient(135deg, #00BCD4 0%, #26C6DA 50%, #00ACC1 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
margin-bottom: 24px;
letter-spacing: -1px;
}
.spinner {
width: 40px;
height: 40px;
margin: 0 auto 16px;
border: 3px solid rgba(0, 188, 212, 0.2);
border-top-color: #00BCD4;
border-radius: 50%;
animation: spin 1s linear infinite;
}
.loading-text {
color: #94A3B8;
font-size: 14px;
font-weight: 500;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
</style>
</head>
<body>
<div class="loading-container">
<div class="logo">XtremFlow</div>
<div class="spinner"></div>
<div class="loading-text">Loading...</div>
</div>
<!-- HLS.js for HLS video playback on Chrome/Firefox -->
<script src="https://cdn.jsdelivr.net/npm/hls.js@latest"></script>
<script src="flutter_bootstrap.js" async></script>
</body>
</html>
-23
View File
@@ -1,23 +0,0 @@
{
"name": "XtremFlow IPTV",
"short_name": "XtremFlow",
"start_url": ".",
"display": "standalone",
"background_color": "#000000",
"theme_color": "#1976D2",
"description": "High-performance IPTV Web Application",
"orientation": "landscape-primary",
"prefer_related_applications": false,
"icons": [
{
"src": "icons/Icon-192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "icons/Icon-512.png",
"sizes": "512x512",
"type": "image/png"
}
]
}
-688
View File
@@ -1,688 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>IPTV Player</title>
<!-- Add HLS.js and mpegts.js dependencies -->
<script src="https://cdn.jsdelivr.net/npm/hls.js@1.4.12/dist/hls.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/mpegts.js@1.7.3/dist/mpegts.min.js"></script>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html,
body {
background: #000;
width: 100%;
height: 100%;
overflow: hidden;
display: flex;
justify-content: center;
align-items: center;
font-family: Arial, sans-serif;
}
#player-container {
width: 100%;
height: 100%;
position: relative;
}
#video {
width: 100%;
height: 100%;
background: #000;
}
#error {
display: none;
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
color: #fff;
text-align: center;
}
#error-icon {
font-size: 64px;
color: #f44336;
margin-bottom: 16px;
}
#loading {
display: none;
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
color: #fff;
text-align: center;
}
.spinner {
width: 48px;
height: 48px;
border: 4px solid #fff;
border-top-color: transparent;
border-radius: 50%;
animation: spin 1s linear infinite;
margin: 0 auto 16px;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
#debug {
display: none;
/* Hidden in production */
position: absolute;
bottom: 10px;
left: 10px;
color: #0f0;
font-size: 12px;
font-family: monospace;
background: rgba(0, 0, 0, 0.7);
padding: 8px;
border-radius: 4px;
max-width: 80%;
word-break: break-all;
}
</style>
</head>
<body>
<div id="player-container">
<!-- paysinline is CRITICAL for iOS to play inline instead of fullscreen -->
<video id="video" autoplay playsinline></video>
<div id="loading">
<div class="spinner"></div>
<div id="loading-text">Loading stream...</div>
</div>
<div id="error">
<div id="error-icon">⚠</div>
<div id="error-message">Failed to load stream</div>
</div>
<div id="debug"></div>
</div>
<!-- Try HLS.js first, then fall back to mpegts.js -->
<script src="https://cdn.jsdelivr.net/npm/hls.js@latest"></script>
<script src="https://cdn.jsdelivr.net/npm/mpegts.js@latest/dist/mpegts.min.js"></script>
<script>
const video = document.getElementById('video');
const loading = document.getElementById('loading');
const loadingText = document.getElementById('loading-text');
const error = document.getElementById('error');
const errorMessage = document.getElementById('error-message');
const debug = document.getElementById('debug');
// Get parameters from query string
const urlParams = new URLSearchParams(window.location.search);
let streamUrl = urlParams.get('url');
const startTime = parseFloat(urlParams.get('startTime') || '0');
let lastReportedTime = 0;
function log(msg) {
console.log(msg);
debug.textContent = msg;
}
// Report current playback position to parent (for resume feature)
function reportPosition() {
if (video.currentTime > 0 && !video.paused && video.readyState > 2) {
// Only report every 5 seconds or if time changed significantly
if (Math.abs(video.currentTime - lastReportedTime) >= 5) {
lastReportedTime = video.currentTime;
window.parent.postMessage({
type: 'playback_position',
currentTime: video.currentTime,
duration: isFinite(video.duration) ? video.duration : 0
}, '*');
}
}
}
// Set up periodic position reporting
setInterval(reportPosition, 5000);
// Also report on pause
video.addEventListener('pause', () => {
window.parent.postMessage({
type: 'playback_position',
currentTime: video.currentTime,
duration: isFinite(video.duration) ? video.duration : 0
}, '*');
});
// Report when video ends
video.addEventListener('ended', () => {
window.parent.postMessage({
type: 'playback_ended',
duration: isFinite(video.duration) ? video.duration : 0
}, '*');
});
// Listen for commands from parent (Flutter)
window.addEventListener('message', (event) => {
const data = event.data;
if (!data) return;
if (data.type === 'set_aspect_ratio') {
log('Setting aspect ratio: ' + data.value);
// value: 'contain', 'cover', 'fill' (fill = 100% 100%)
if (data.value === 'fill') {
video.style.objectFit = 'fill';
} else if (data.value === 'cover') {
video.style.objectFit = 'cover';
} else {
video.style.objectFit = 'contain';
}
} else if (data.type === 'set_audio_track') {
log('Switching audio track index: ' + data.index);
if (window.hlsInstance) {
window.hlsInstance.audioTrack = data.index;
}
} else if (data.type === 'play') {
log('Received play command');
video.play().catch(e => log('Play failed: ' + e));
} else if (data.type === 'pause') {
log('Received pause command');
video.pause();
} else if (data.type === 'seek') {
log('Seeking to: ' + data.value);
if (isFinite(data.value)) {
video.currentTime = data.value;
}
} else if (data.type === 'set_volume') {
// value is 0.0 to 1.0
log('Setting volume: ' + data.value);
if (isFinite(data.value)) {
video.volume = Math.max(0, Math.min(1, data.value));
video.muted = (data.value === 0);
}
}
});
// Notify parent about playback state changes
video.addEventListener('play', () => {
window.parent.postMessage({ type: 'playback_status', status: 'playing' }, '*');
});
video.addEventListener('pause', () => {
window.parent.postMessage({ type: 'playback_status', status: 'paused' }, '*');
});
// Report available audio tracks
function reportAudioTracks() {
let tracks = [];
if (window.hlsInstance && window.hlsInstance.audioTracks) {
tracks = window.hlsInstance.audioTracks.map((t, i) => ({
id: i,
label: t.name || t.lang || ('Track ' + (i + 1)),
lang: t.lang || ''
}));
} else if (video.audioTracks) {
// Standard HTML5 (Safari/experimental)
for (let i = 0; i < video.audioTracks.length; i++) {
const t = video.audioTracks[i];
tracks.push({
id: i,
label: t.label || t.language || ('Track ' + (i + 1)),
lang: t.language || ''
});
}
}
if (tracks.length > 0) {
window.parent.postMessage({
type: 'audio_tracks',
tracks: tracks
}, '*');
}
}
// Hook reporting into playback start
video.addEventListener('loadedmetadata', () => {
setTimeout(reportAudioTracks, 1000); // Wait a bit for tracks to parse
});
// Report user activity (mouse movement or touch) to parent
let lastActivityTime = 0;
function reportActivity() {
const now = Date.now();
if (now - lastActivityTime > 100) { // Throttle to 100ms
lastActivityTime = now;
window.parent.postMessage({
type: 'user_activity'
}, '*');
}
}
document.addEventListener('mousemove', reportActivity);
document.addEventListener('touchstart', reportActivity);
document.addEventListener('touchmove', reportActivity);
document.addEventListener('click', reportActivity);
document.addEventListener('touchend', reportActivity);
// Seek to startTime when video is ready
video.addEventListener('loadedmetadata', () => {
if (startTime > 0 && startTime < video.duration) {
video.currentTime = startTime;
log('Resuming from ' + Math.floor(startTime / 60) + 'm ' + Math.floor(startTime % 60) + 's');
}
});
function showLoading(msg) {
loading.style.display = 'block';
loadingText.textContent = msg || 'Loading stream...';
error.style.display = 'none';
}
function hideLoading() {
loading.style.display = 'none';
}
function showError(message) {
hideLoading();
error.style.display = 'block';
errorMessage.textContent = message || 'Failed to load stream';
}
async function playWithHLS(url) {
log('Trying HLS.js with: ' + url);
showLoading('Loading HLS stream...');
if (!Hls.isSupported()) {
// Try native HLS (Safari)
if (video.canPlayType('application/vnd.apple.mpegurl')) {
video.src = url;
video.addEventListener('canplay', () => {
hideLoading();
video.play().catch(e => log('Autoplay prevented: ' + e));
});
video.addEventListener('error', () => {
showError('HLS playback error');
});
return true;
}
return false;
}
// Optimized HLS.js configuration for LONG stable live streaming
const hls = new Hls({
// INCREASED buffer for longer playback stability
maxBufferLength: 60, // Max 60 seconds of buffer (doubled)
maxMaxBufferLength: 120, // Allow up to 2 minutes in stressed conditions
maxBufferSize: 120 * 1000000, // 120MB max buffer size
maxBufferHole: 1.0, // Tolerate 1s gaps in buffer
// Live streaming settings (more tolerant)
liveSyncDurationCount: 4, // Sync to 4 segments behind live
liveMaxLatencyDurationCount: 10, // Max 10 segments behind before seeking
liveDurationInfinity: true, // Treat live streams as infinite
liveBackBufferLength: 60, // Keep 60s of back buffer
// MORE AGGRESSIVE recovery from errors
manifestLoadingTimeOut: 30000, // 30s timeout for manifest
manifestLoadingMaxRetry: 10, // Retry manifest 10 times
manifestLoadingRetryDelay: 2000, // 2s between retries
levelLoadingTimeOut: 30000, // 30s for level playlist
levelLoadingMaxRetry: 10,
levelLoadingRetryDelay: 2000,
fragLoadingTimeOut: 60000, // 60s for fragments (long timeout)
fragLoadingMaxRetry: 10, // Retry fragments 10 times
fragLoadingRetryDelay: 2000,
// Better quality selection
startLevel: -1, // Auto-select start level
abrEwmaDefaultEstimate: 500000, // Start assuming 500kbps
abrBandWidthFactor: 0.95, // Use 95% of measured bandwidth
abrBandWidthUpFactor: 0.7, // Be cautious when upgrading quality
// Enable worker for better performance
enableWorker: true,
// Don't add extra headers
xhrSetup: function (xhr, url) { }
});
window.hlsInstance = hls; // Expose for external control (audio tracks)
let hlsRetryCount = 0;
const maxHlsRetries = 3;
hls.loadSource(url);
hls.attachMedia(video);
hls.on(Hls.Events.MANIFEST_PARSED, () => {
log('HLS manifest parsed, starting playback');
hlsRetryCount = 0; // Reset retry counter on success
hideLoading();
video.play().catch(e => {
log('Autoplay prevented: ' + e);
window.parent.postMessage({ type: 'playback_status', status: 'paused' }, '*');
});
});
// IMPROVED error recovery with automatic restart
hls.on(Hls.Events.ERROR, (event, data) => {
log('HLS Error: ' + data.type + ' - ' + data.details);
if (data.fatal) {
switch (data.type) {
case Hls.ErrorTypes.NETWORK_ERROR:
log('Network error, attempting recovery...');
// Try startLoad first
hls.startLoad();
// If still failing after 5s, try full restart
setTimeout(() => {
if (video.paused || video.readyState < 2) {
if (hlsRetryCount < maxHlsRetries) {
hlsRetryCount++;
log('Auto-restarting stream (attempt ' + hlsRetryCount + ')...');
showLoading('Reconnecting...');
hls.destroy();
playWithHLS(url);
} else {
showError('Connection lost - tap to retry');
}
}
}, 5000);
break;
case Hls.ErrorTypes.MEDIA_ERROR:
log('Media error, attempting recovery...');
hls.recoverMediaError(); // Try to recover media errors
break;
default:
if (hlsRetryCount < maxHlsRetries) {
hlsRetryCount++;
log('Fatal error, restarting stream (attempt ' + hlsRetryCount + ')...');
showLoading('Restarting stream...');
hls.destroy();
setTimeout(() => playWithHLS(url), 2000);
} else {
showError('Stream error: ' + data.details);
hls.destroy();
}
break;
}
}
});
return true;
}
let mpegtsRetryCount = 0;
const maxMpegtsRetries = 3;
let currentMpegtsPlayer = null;
async function playWithMpegTS(url) {
log('Trying mpegts.js with: ' + url);
showLoading('Loading MPEG-TS stream...');
if (!mpegts.isSupported()) {
return false;
}
if (mpegts.getLoggingControl) {
mpegts.getLoggingControl().enableAll = false;
mpegts.getLoggingControl().enableError = true;
}
// Cleanup previous player if exists
if (currentMpegtsPlayer) {
try {
currentMpegtsPlayer.destroy();
} catch (e) { }
}
const player = mpegts.createPlayer({
type: 'mpegts',
isLive: true,
url: url,
}, {
enableWorker: true,
enableStashBuffer: true,
stashInitialSize: 1024 * 1024, // 1MB initial stash (increased)
autoCleanupSourceBuffer: true,
autoCleanupMaxBackwardDuration: 120, // Keep 2 minutes back buffer
autoCleanupMinBackwardDuration: 60, // Min 1 minute
// Make the player more resilient to timestamp gaps
fixAudioTimestampGap: true,
lazyLoadMaxDuration: 10 * 60, // 10 minutes lazy load (increased)
seekType: 'range',
// Longer timeouts for stability
deferLoadAfterSourceOpen: false,
liveBufferLatencyChasing: true,
liveBufferLatencyMaxLatency: 30, // Max 30s latency
liveBufferLatencyMinRemain: 5, // Min 5s buffer
});
currentMpegtsPlayer = player;
window.mpegtsPlayer = player; // Expose for debugging
player.attachMediaElement(video);
player.load();
video.addEventListener('canplay', () => {
log('MPEG-TS stream ready');
mpegtsRetryCount = 0; // Reset on success
hideLoading();
video.play().catch(e => {
log('Autoplay prevented: ' + e);
window.parent.postMessage({ type: 'playback_status', status: 'paused' }, '*');
});
});
video.addEventListener('error', (e) => {
const errorMsg = video.error?.message || 'Unknown error';
log('Video element error: ' + errorMsg);
// Don't show error immediately, try recovery first
if (mpegtsRetryCount < maxMpegtsRetries) {
mpegtsRetryCount++;
log('Attempting stream restart (attempt ' + mpegtsRetryCount + ')...');
showLoading('Reconnecting...');
setTimeout(() => {
player.destroy();
playWithMpegTS(url);
}, 3000);
} else {
showError('Video error: ' + errorMsg);
}
}, { once: true });
player.on(mpegts.Events.ERROR, (errType, errDetail, errInfo) => {
log('mpegts error: ' + errType + ' - ' + errDetail);
// Auto-retry on network errors
if (errType === mpegts.ErrorTypes.NETWORK_ERROR && mpegtsRetryCount < maxMpegtsRetries) {
mpegtsRetryCount++;
log('Network error, restarting stream (attempt ' + mpegtsRetryCount + ')...');
showLoading('Reconnecting...');
setTimeout(() => {
player.destroy();
playWithMpegTS(url);
}, 3000);
} else if (mpegtsRetryCount >= maxMpegtsRetries) {
showError('Connection lost - tap to retry');
} else {
showError('Stream error: ' + errDetail);
}
});
return true;
}
async function playDirect(url) {
log('Trying direct playback: ' + url);
showLoading('Loading video...');
// Configure video for better buffering
video.preload = 'auto';
// Handle buffering events
video.addEventListener('waiting', () => {
log('Buffering...');
showLoading('Buffering...');
});
video.addEventListener('playing', () => {
hideLoading();
});
video.addEventListener('canplay', () => {
hideLoading();
video.play().catch(e => {
log('Autoplay prevented: ' + e);
window.parent.postMessage({ type: 'playback_status', status: 'paused' }, '*');
});
}, { once: true });
video.addEventListener('error', (e) => {
const error = video.error;
let msg = 'Playback error';
if (error) {
switch (error.code) {
case MediaError.MEDIA_ERR_NETWORK:
msg = 'Network error - retrying...';
log(msg);
// Retry after network error
setTimeout(() => {
video.load();
video.play().catch(() => { });
}, 2000);
return;
case MediaError.MEDIA_ERR_DECODE:
msg = 'Video decode error';
break;
case MediaError.MEDIA_ERR_SRC_NOT_SUPPORTED:
msg = 'Format not supported';
break;
}
}
showError(msg);
});
// Handle stalled playback (prevent rollback)
video.addEventListener('stalled', () => {
log('Stream stalled, waiting for data...');
});
// Set source and start loading
video.src = url;
video.load();
}
async function initPlayer() {
if (!streamUrl) {
showError('No stream URL provided');
return;
}
log('Stream URL: ' + streamUrl.substring(0, 80) + '...');
// Determine stream type and try appropriate player
const lowerUrl = streamUrl.toLowerCase();
const ext = urlParams.get('ext') ? urlParams.get('ext').toLowerCase() : '';
// HLS streams first
if (lowerUrl.includes('.m3u8') || lowerUrl.includes('/hls/')) {
await playWithHLS(streamUrl);
return;
}
// MPEG-TS streams (live or forced TS via ext param)
// Checked BEFORE generic MP4/MKV to handle transcoded streams correctly
if (ext === '.ts' || lowerUrl.includes('.ts')) {
log('Detected MPEG-TS (via extension or URL)');
// Check if mpegts.js is supported (may not work on mobile)
if (mpegts.isSupported()) {
log('Using mpegts.js');
const success = await playWithMpegTS(streamUrl);
if (success) return;
log('mpegts.js failed, trying fallbacks...');
}
// Fallback 1: Try HLS.js for better mobile compatibility
log('Trying HLS.js as fallback for MPEG-TS');
const hlsSuccess = await playWithHLS(streamUrl);
if (hlsSuccess) return;
// Fallback 2: Try direct play as last resort
log('Trying direct play as last resort');
await playDirect(streamUrl);
return;
}
// MP4 and WebM - play directly (supports seeking via Range)
if (lowerUrl.includes('.mp4') || lowerUrl.includes('.webm') || lowerUrl.includes('.mov')) {
log('Detected MP4/WebM/MOV, playing directly with seek support');
await playDirect(streamUrl);
return;
}
// MKV, AVI - not natively supported, try anyway with fallback
if (lowerUrl.includes('.mkv') || lowerUrl.includes('.avi') || lowerUrl.includes('.wmv') || lowerUrl.includes('.flv')) {
log('Detected MKV/AVI - limited browser support, trying direct play...');
showLoading('Loading video (format may not support seeking)...');
// Try direct first, some browsers/servers may transcode on the fly
video.src = streamUrl;
let playStarted = false;
const onCanPlay = () => {
playStarted = true;
hideLoading();
video.play().catch(e => {
log('Autoplay prevented: ' + e);
window.parent.postMessage({ type: 'playback_status', status: 'paused' }, '*');
});
};
const onError = async () => {
if (!playStarted) {
log('Direct play failed for MKV/AVI, trying HLS fallback...');
video.removeEventListener('canplay', onCanPlay);
video.removeEventListener('error', onError);
// Try HLS as fallback (in case URL was actually HLS disguised)
const hlsSuccess = await playWithHLS(streamUrl);
if (!hlsSuccess) {
showError('Format not supported by browser. Try MP4 version if available.');
}
}
};
video.addEventListener('canplay', onCanPlay, { once: true });
video.addEventListener('error', onError, { once: true });
return;
}
// Unknown format - try direct first (for VOD with Range support), then HLS
log('Unknown format, trying direct first then HLS fallback');
video.src = streamUrl;
video.addEventListener('canplay', () => {
hideLoading();
video.play().catch(e => {
log('Autoplay prevented: ' + e);
window.parent.postMessage({ type: 'playback_status', status: 'paused' }, '*');
});
}, { once: true });
video.addEventListener('error', async () => {
log('Direct play failed, trying HLS...');
await playWithHLS(streamUrl);
}, { once: true });
}
initPlayer();
</script>
</body>
</html>