diff --git a/example/android/app/build.gradle b/example/android/app/build.gradle index 7f0f1a52..4074859c 100644 --- a/example/android/app/build.gradle +++ b/example/android/app/build.gradle @@ -1,71 +1,45 @@ -def localProperties = new Properties() -def localPropertiesFile = rootProject.file('local.properties') -if (localPropertiesFile.exists()) { - localPropertiesFile.withReader('UTF-8') { reader -> - localProperties.load(reader) - } -} - -def flutterRoot = localProperties.getProperty('flutter.sdk') -if (flutterRoot == null) { - throw new GradleException("Flutter SDK not found. Define location with flutter.sdk in the local.properties file.") +plugins { + id "com.android.application" + id "kotlin-android" + // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins. + id "dev.flutter.flutter-gradle-plugin" } -def flutterVersionCode = localProperties.getProperty('flutter.versionCode') -if (flutterVersionCode == null) { - flutterVersionCode = '1' -} - -def flutterVersionName = localProperties.getProperty('flutter.versionName') -if (flutterVersionName == null) { - flutterVersionName = '1.0' -} - -apply plugin: 'com.android.application' -apply plugin: 'kotlin-android' -apply from: "$flutterRoot/packages/flutter_tools/gradle/flutter.gradle" - android { - compileSdkVersion flutter.compileSdkVersion - ndkVersion flutter.ndkVersion + namespace = "com.example.example" + compileSdk = 35 + ndkVersion = flutter.ndkVersion compileOptions { - sourceCompatibility JavaVersion.VERSION_1_8 - targetCompatibility JavaVersion.VERSION_1_8 + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 } kotlinOptions { - jvmTarget = '1.8' + jvmTarget = JavaVersion.VERSION_1_8 } - sourceSets { - main.java.srcDirs += 'src/main/kotlin' - } defaultConfig { // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). applicationId "com.example.example" // You can update the following values to match your application needs. // For more information, see: https://docs.flutter.dev/deployment/android#reviewing-the-build-configuration. - minSdkVersion 19 - targetSdkVersion flutter.targetSdkVersion - versionCode flutterVersionCode.toInteger() - versionName flutterVersionName + minSdk = 21 + targetSdk = 35 + versionCode = flutter.versionCode + versionName = flutter.versionName } buildTypes { release { // TODO: Add your own signing config for the release build. // Signing with the debug keys for now, so `flutter run --release` works. - signingConfig signingConfigs.debug + signingConfig = signingConfigs.debug } } } flutter { - source '../..' -} - -dependencies { - implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version" + source = "../.." } diff --git a/example/android/build.gradle b/example/android/build.gradle index 3cdaac95..d2ffbffa 100644 --- a/example/android/build.gradle +++ b/example/android/build.gradle @@ -1,16 +1,3 @@ -buildscript { - ext.kotlin_version = '1.6.10' - repositories { - google() - mavenCentral() - } - - dependencies { - classpath 'com.android.tools.build:gradle:7.1.2' - classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" - } -} - allprojects { repositories { google() @@ -18,12 +5,12 @@ allprojects { } } -rootProject.buildDir = '../build' +rootProject.buildDir = "../build" subprojects { project.buildDir = "${rootProject.buildDir}/${project.name}" } subprojects { - project.evaluationDependsOn(':app') + project.evaluationDependsOn(":app") } tasks.register("clean", Delete) { diff --git a/example/android/gradle.properties b/example/android/gradle.properties index 94adc3a3..25971708 100644 --- a/example/android/gradle.properties +++ b/example/android/gradle.properties @@ -1,3 +1,3 @@ -org.gradle.jvmargs=-Xmx1536M +org.gradle.jvmargs=-Xmx4G -XX:MaxMetaspaceSize=2G -XX:+HeapDumpOnOutOfMemoryError android.useAndroidX=true android.enableJetifier=true diff --git a/example/android/gradle/wrapper/gradle-wrapper.properties b/example/android/gradle/wrapper/gradle-wrapper.properties index cc5527d7..7bb2df6b 100644 --- a/example/android/gradle/wrapper/gradle-wrapper.properties +++ b/example/android/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,5 @@ -#Fri Jun 23 08:50:38 CEST 2017 distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-7.4-all.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.3-all.zip diff --git a/example/android/settings.gradle b/example/android/settings.gradle index 44e62bcf..4cae017e 100644 --- a/example/android/settings.gradle +++ b/example/android/settings.gradle @@ -1,11 +1,25 @@ -include ':app' +pluginManagement { + def flutterSdkPath = { + def properties = new Properties() + file("local.properties").withInputStream { properties.load(it) } + def flutterSdkPath = properties.getProperty("flutter.sdk") + assert flutterSdkPath != null, "flutter.sdk not set in local.properties" + return flutterSdkPath + }() + + includeBuild("$flutterSdkPath/packages/flutter_tools/gradle") -def localPropertiesFile = new File(rootProject.projectDir, "local.properties") -def properties = new Properties() + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} -assert localPropertiesFile.exists() -localPropertiesFile.withReader("UTF-8") { reader -> properties.load(reader) } +plugins { + id "dev.flutter.flutter-plugin-loader" version "1.0.0" + id "com.android.application" version "8.1.0" apply false + id "org.jetbrains.kotlin.android" version "1.8.22" apply false +} -def flutterSdkPath = properties.getProperty("flutter.sdk") -assert flutterSdkPath != null, "flutter.sdk not set in local.properties" -apply from: "$flutterSdkPath/packages/flutter_tools/gradle/app_plugin_loader.gradle" +include ':app' diff --git a/example/ios/Podfile.lock b/example/ios/Podfile.lock index 9e54c99f..dfa3e3a7 100644 --- a/example/ios/Podfile.lock +++ b/example/ios/Podfile.lock @@ -26,10 +26,10 @@ EXTERNAL SOURCES: SPEC CHECKSUMS: Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7 - package_info_plus: 58f0028419748fad15bf008b270aaa8e54380b1c - video_player_avfoundation: 02011213dab73ae3687df27ce441fbbcc82b5579 - wakelock_plus: 78ec7c5b202cab7761af8e2b2b3d0671be6c4ae1 + package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499 + video_player_avfoundation: 2cef49524dd1f16c5300b9cd6efd9611ce03639b + wakelock_plus: e29112ab3ef0b318e58cfa5c32326458be66b556 PODFILE CHECKSUM: c4c93c5f6502fe2754f48404d3594bf779584011 -COCOAPODS: 1.15.2 +COCOAPODS: 1.16.2 diff --git a/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme index 5e31d3d3..9c12df59 100644 --- a/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme +++ b/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -26,6 +26,7 @@ buildConfiguration = "Debug" selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB" selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" + customLLDBInitFile = "$(SRCROOT)/Flutter/ephemeral/flutter_lldbinit" shouldUseLaunchSchemeArgsEnv = "YES"> diff --git a/example/ios/Runner/AppDelegate.swift b/example/ios/Runner/AppDelegate.swift index 70693e4a..b6363034 100644 --- a/example/ios/Runner/AppDelegate.swift +++ b/example/ios/Runner/AppDelegate.swift @@ -1,7 +1,7 @@ import UIKit import Flutter -@UIApplicationMain +@main @objc class AppDelegate: FlutterAppDelegate { override func application( _ application: UIApplication, diff --git a/example/lib/examples/load_vimeo_from_urls.dart b/example/lib/examples/load_vimeo_from_urls.dart index c5c87fd2..9f74b594 100644 --- a/example/lib/examples/load_vimeo_from_urls.dart +++ b/example/lib/examples/load_vimeo_from_urls.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:pod_player/pod_player.dart'; +import '../utils/video_api_handler.dart'; void main(List args) { runApp(const VimeoApp()); @@ -29,6 +30,8 @@ class VimeoVideoViewer extends StatefulWidget { class VimeoVideoViewerState extends State { late final PodPlayerController controller; bool isLoading = true; + String? errorMessage; + @override void initState() { loadVideo(); @@ -36,20 +39,69 @@ class VimeoVideoViewerState extends State { } void loadVideo() async { - final urls = await PodPlayerController.getVimeoUrls('518228118'); - setState(() => isLoading = false); - controller = PodPlayerController( - playVideoFrom: PlayVideoFrom.networkQualityUrls(videoUrls: urls!), - podPlayerConfig: const PodPlayerConfig( - videoQualityPriority: [360], - ), - )..initialise(); + try { + final urls = await PodPlayerController.getVimeoUrls('518228118'); + setState(() => isLoading = false); + controller = PodPlayerController( + playVideoFrom: PlayVideoFrom.networkQualityUrls(videoUrls: urls!), + podPlayerConfig: const PodPlayerConfig( + videoQualityPriority: [360], + ), + )..initialise(); + } catch (e) { + setState(() { + isLoading = false; + errorMessage = VideoApiHandler.getDetailedErrorMessage(e); + }); + } } @override Widget build(BuildContext context) { - return isLoading - ? const Center(child: CircularProgressIndicator()) - : Center(child: PodVideoPlayer(controller: controller)); + if (isLoading) { + return const Center(child: CircularProgressIndicator()); + } + + if (errorMessage != null) { + return Center( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon( + Icons.error_outline, + color: Colors.red, + size: 48, + ), + const SizedBox(height: 16), + Text( + 'Error Loading Video', + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: 8), + Text( + errorMessage!, + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.bodyMedium, + ), + const SizedBox(height: 16), + ElevatedButton( + onPressed: () { + setState(() { + isLoading = true; + errorMessage = null; + }); + loadVideo(); + }, + child: const Text('Retry'), + ), + ], + ), + ), + ); + } + + return Center(child: PodVideoPlayer(controller: controller)); } } diff --git a/example/lib/examples/safe_player_demo.dart b/example/lib/examples/safe_player_demo.dart new file mode 100644 index 00000000..61119701 --- /dev/null +++ b/example/lib/examples/safe_player_demo.dart @@ -0,0 +1,173 @@ +import 'package:flutter/material.dart'; +import 'package:pod_player/pod_player.dart'; +import '../utils/safe_pod_player.dart'; + +void main() { + runApp(const SafePlayerDemo()); +} + +class SafePlayerDemo extends StatelessWidget { + const SafePlayerDemo({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + return MaterialApp( + title: 'Safe Pod Player Demo', + theme: ThemeData(primarySwatch: Colors.blue), + home: const SafePlayerScreen(), + ); + } +} + +class SafePlayerScreen extends StatefulWidget { + const SafePlayerScreen({Key? key}) : super(key: key); + + @override + State createState() => _SafePlayerScreenState(); +} + +class _SafePlayerScreenState extends State { + late PodPlayerController controller; + + @override + void initState() { + super.initState(); + // Initialize with a video that might fail + controller = PodPlayerController( + playVideoFrom: PlayVideoFrom.vimeo('518228118'), + ); + } + + @override + void dispose() { + controller.dispose(); + super.dispose(); + } + + void _loadWorkingVideo() { + controller.changeVideo( + playVideoFrom: PlayVideoFrom.network( + 'https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4', + ), + ); + } + + void _loadBrokenVideo() { + controller.changeVideo( + playVideoFrom: PlayVideoFrom.vimeo('nonexistent123'), + ); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('Safe Pod Player Demo'), + ), + body: Column( + children: [ + Expanded( + child: SafePodPlayer( + controller: controller, + errorBuilder: (errorMessage) { + // Custom error UI + return Container( + color: Colors.black, + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon( + Icons.videocam_off, + color: Colors.white54, + size: 64, + ), + const SizedBox(height: 16), + const Text( + 'Video Unavailable', + style: TextStyle( + color: Colors.white, + fontSize: 20, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 8), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 32), + child: Text( + errorMessage, + textAlign: TextAlign.center, + style: const TextStyle( + color: Colors.white70, + fontSize: 14, + ), + ), + ), + const SizedBox(height: 24), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + TextButton( + onPressed: () { + setState(() { + // Force rebuild to retry + }); + }, + child: const Text( + 'RETRY', + style: TextStyle(color: Colors.white), + ), + ), + const SizedBox(width: 16), + TextButton( + onPressed: _loadWorkingVideo, + child: const Text( + 'LOAD SAMPLE', + style: TextStyle(color: Colors.white), + ), + ), + ], + ), + ], + ), + ), + ); + }, + ), + ), + Container( + padding: const EdgeInsets.all(16), + child: Column( + children: [ + const Text( + 'Test Different Scenarios:', + style: TextStyle(fontWeight: FontWeight.bold), + ), + const SizedBox(height: 16), + Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + ElevatedButton( + onPressed: _loadWorkingVideo, + style: ElevatedButton.styleFrom( + backgroundColor: Colors.green, + ), + child: const Text('Load Working Video'), + ), + ElevatedButton( + onPressed: _loadBrokenVideo, + style: ElevatedButton.styleFrom( + backgroundColor: Colors.red, + ), + child: const Text('Load Broken Video'), + ), + ], + ), + ], + ), + ), + ], + ), + ); + } +} \ No newline at end of file diff --git a/example/lib/examples/vimeo_error_handling_demo.dart b/example/lib/examples/vimeo_error_handling_demo.dart new file mode 100644 index 00000000..7229bb3f --- /dev/null +++ b/example/lib/examples/vimeo_error_handling_demo.dart @@ -0,0 +1,247 @@ +import 'package:flutter/material.dart'; +import 'package:pod_player/pod_player.dart'; +import '../utils/video_api_handler.dart'; +import '../utils/vimeo_410_exception.dart'; + +void main() { + runApp(const VimeoErrorHandlingDemo()); +} + +class VimeoErrorHandlingDemo extends StatelessWidget { + const VimeoErrorHandlingDemo({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + return MaterialApp( + title: 'Vimeo Error Handling Demo', + theme: ThemeData( + primarySwatch: Colors.blue, + ), + home: const VimeoErrorHandlingScreen(), + ); + } +} + +class VimeoErrorHandlingScreen extends StatefulWidget { + const VimeoErrorHandlingScreen({Key? key}) : super(key: key); + + @override + State createState() => _VimeoErrorHandlingScreenState(); +} + +class _VimeoErrorHandlingScreenState extends State { + final videoIdController = TextEditingController(); + final hashController = TextEditingController(); + PodPlayerController? podController; + bool isLoading = false; + String? errorMessage; + String? errorType; + + @override + void dispose() { + videoIdController.dispose(); + hashController.dispose(); + podController?.dispose(); + super.dispose(); + } + + Future loadVideo() async { + if (videoIdController.text.isEmpty) { + setState(() { + errorMessage = 'Please enter a Vimeo video ID'; + errorType = 'validation'; + }); + return; + } + + setState(() { + isLoading = true; + errorMessage = null; + errorType = null; + }); + + // Dispose of previous controller + podController?.dispose(); + + try { + final hash = hashController.text.isEmpty ? null : hashController.text; + + podController = PodPlayerController( + playVideoFrom: PlayVideoFrom.vimeo( + videoIdController.text, + hash: hash, + ), + ); + + await podController!.initialise(); + + setState(() { + isLoading = false; + }); + } catch (e) { + setState(() { + isLoading = false; + errorMessage = VideoApiHandler.getDetailedErrorMessage(e); + + // Determine error type for better UI + if (e is Vimeo410Exception) { + errorType = '410'; + } else if (e is FormatException && e.toString().contains('HTML')) { + errorType = 'html'; + } else if (e.toString().contains('403')) { + errorType = '403'; + } else { + errorType = 'generic'; + } + }); + + // Log detailed error for debugging + debugPrint('Error loading video: $e'); + } + } + + Widget buildErrorWidget() { + IconData icon; + Color color; + String title; + + switch (errorType) { + case '410': + icon = Icons.access_time; + color = Colors.orange; + title = 'Links Expired'; + break; + case '403': + icon = Icons.lock; + color = Colors.red; + title = 'Access Denied'; + break; + case 'html': + icon = Icons.code_off; + color = Colors.purple; + title = 'Invalid Response'; + break; + default: + icon = Icons.error_outline; + color = Colors.red; + title = 'Error'; + } + + return Center( + child: Padding( + padding: const EdgeInsets.all(24.0), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(icon, size: 64, color: color), + const SizedBox(height: 16), + Text( + title, + style: Theme.of(context).textTheme.headlineSmall, + ), + const SizedBox(height: 8), + Text( + errorMessage ?? 'An unknown error occurred', + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.bodyLarge, + ), + const SizedBox(height: 24), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + ElevatedButton.icon( + onPressed: loadVideo, + icon: const Icon(Icons.refresh), + label: const Text('Retry'), + ), + const SizedBox(width: 16), + if (errorType == '410') + ElevatedButton.icon( + onPressed: () { + // Clear hash for 410 errors + hashController.clear(); + loadVideo(); + }, + icon: const Icon(Icons.link_off), + label: const Text('Try Without Hash'), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.orange, + ), + ), + ], + ), + ], + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('Vimeo Error Handling Demo'), + ), + body: SafeArea( + child: Column( + children: [ + Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + children: [ + TextField( + controller: videoIdController, + decoration: const InputDecoration( + labelText: 'Vimeo Video ID', + hintText: 'e.g., 518228118', + border: OutlineInputBorder(), + ), + ), + const SizedBox(height: 16), + TextField( + controller: hashController, + decoration: const InputDecoration( + labelText: 'Hash (optional)', + hintText: 'e.g., abcdef123456', + border: OutlineInputBorder(), + ), + ), + const SizedBox(height: 16), + SizedBox( + width: double.infinity, + child: ElevatedButton.icon( + onPressed: isLoading ? null : loadVideo, + icon: isLoading + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator( + strokeWidth: 2, + valueColor: AlwaysStoppedAnimation(Colors.white), + ), + ) + : const Icon(Icons.play_arrow), + label: Text(isLoading ? 'Loading...' : 'Load Video'), + ), + ), + ], + ), + ), + Expanded( + child: errorMessage != null + ? buildErrorWidget() + : podController != null + ? PodVideoPlayer(controller: podController!) + : const Center( + child: Text( + 'Enter a Vimeo video ID and press Load Video', + style: TextStyle(fontSize: 16), + ), + ), + ), + ], + ), + ), + ); + } +} \ No newline at end of file diff --git a/example/lib/main.dart b/example/lib/main.dart index ffe73f8e..543b189d 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -8,6 +8,7 @@ import 'package:pod_player/pod_player.dart'; import 'screens/cutom_video_controllers.dart'; import 'screens/from_vimeo_id.dart'; +import 'examples/vimeo_error_handling_demo.dart'; void main() { PodVideoPlayer.enableLogs = true; @@ -30,6 +31,7 @@ class MyApp extends StatelessWidget { '/fromNetworkQualityUrls': (context) => const PlayVideoFromNetworkQualityUrls(), '/customVideo': (context) => const CustomVideoControlls(), + '/vimeoErrorDemo': (context) => const VimeoErrorHandlingDemo(), }, home: const MainPage(), ); @@ -82,6 +84,10 @@ class _MainPageState extends State { 'Custom Video player', onPressed: () => Navigator.of(context).pushNamed('/customVideo'), ), + _button( + 'Vimeo Error Handling Demo', + onPressed: () => Navigator.of(context).pushNamed('/vimeoErrorDemo'), + ), ], ), ), diff --git a/example/lib/screens/from_vimeo_id.dart b/example/lib/screens/from_vimeo_id.dart index 93a67efb..339cdd70 100644 --- a/example/lib/screens/from_vimeo_id.dart +++ b/example/lib/screens/from_vimeo_id.dart @@ -1,5 +1,6 @@ import 'package:pod_player/pod_player.dart'; import 'package:flutter/material.dart'; +import '../utils/video_api_handler.dart'; class PlayVideoFromVimeoId extends StatefulWidget { const PlayVideoFromVimeoId({Key? key}) : super(key: key); @@ -12,14 +13,30 @@ class _PlayVideoFromVimeoIdState extends State { late final PodPlayerController controller; final videoTextFieldCtr = TextEditingController(); final hashTextFieldCtr = TextEditingController(); + bool hasInitError = false; + String? initErrorMessage; @override void initState() { controller = PodPlayerController( playVideoFrom: PlayVideoFrom.vimeo('518228118'), - )..initialise(); + ); + _initializeController(); super.initState(); } + + Future _initializeController() async { + try { + await controller.initialise(); + } catch (e) { + if (mounted) { + setState(() { + hasInitError = true; + initErrorMessage = VideoApiHandler.getDetailedErrorMessage(e); + }); + } + } + } @override void dispose() { @@ -36,7 +53,10 @@ class _PlayVideoFromVimeoIdState extends State { child: ListView( shrinkWrap: true, children: [ - PodVideoPlayer(controller: controller), + if (hasInitError) + _buildErrorWidget() + else + PodVideoPlayer(controller: controller), const SizedBox(height: 40), _loadVideoFromUrl() ], @@ -45,6 +65,38 @@ class _PlayVideoFromVimeoIdState extends State { ), ); } + + Widget _buildErrorWidget() { + return Container( + padding: const EdgeInsets.all(16), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon( + Icons.error_outline, + color: Colors.red, + size: 48, + ), + const SizedBox(height: 16), + Text( + 'Failed to load initial video', + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 8), + Text( + initErrorMessage ?? 'Unknown error', + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.bodyMedium, + ), + const SizedBox(height: 16), + Text( + 'Try loading a different video below', + style: Theme.of(context).textTheme.bodySmall, + ), + ], + ), + ); + } Row _loadVideoFromUrl() { return Row( @@ -86,6 +138,13 @@ class _PlayVideoFromVimeoIdState extends State { snackBar('Loading....'); FocusScope.of(context).unfocus(); final vimeoHash = hashTextFieldCtr.text; + + // Reset error state when trying new video + setState(() { + hasInitError = false; + initErrorMessage = null; + }); + await controller.changeVideo( playVideoFrom: PlayVideoFrom.vimeo( videoTextFieldCtr.text, @@ -95,7 +154,14 @@ class _PlayVideoFromVimeoIdState extends State { if (!mounted) return; ScaffoldMessenger.of(context).hideCurrentSnackBar(); } catch (e) { - snackBar('Unable to load,\n $e'); + final errorMessage = VideoApiHandler.getDetailedErrorMessage(e); + snackBar('Unable to load:\n$errorMessage'); + + // Update error state for UI + setState(() { + hasInitError = true; + initErrorMessage = errorMessage; + }); } }, child: const Text('Load Video'), diff --git a/example/lib/screens/from_vimeo_private_id.dart b/example/lib/screens/from_vimeo_private_id.dart index da1baec0..fb654492 100644 --- a/example/lib/screens/from_vimeo_private_id.dart +++ b/example/lib/screens/from_vimeo_private_id.dart @@ -1,5 +1,6 @@ import 'package:pod_player/pod_player.dart'; import 'package:flutter/material.dart'; +import '../utils/video_api_handler.dart'; class PlayVideoFromVimeoPrivateId extends StatefulWidget { const PlayVideoFromVimeoPrivateId({Key? key}) : super(key: key); @@ -14,14 +15,30 @@ class _PlayVideoFromVimeoPrivateIdState late final PodPlayerController controller; final videoTextFieldCtr = TextEditingController(); final tokenTextFieldCtr = TextEditingController(); + bool hasInitError = false; + String? initErrorMessage; @override void initState() { controller = PodPlayerController( playVideoFrom: PlayVideoFrom.vimeo('518228118'), - )..initialise(); + ); + _initializeController(); super.initState(); } + + Future _initializeController() async { + try { + await controller.initialise(); + } catch (e) { + if (mounted) { + setState(() { + hasInitError = true; + initErrorMessage = VideoApiHandler.getDetailedErrorMessage(e); + }); + } + } + } @override void dispose() { @@ -38,7 +55,10 @@ class _PlayVideoFromVimeoPrivateIdState child: ListView( shrinkWrap: true, children: [ - PodVideoPlayer(controller: controller), + if (hasInitError) + _buildErrorWidget() + else + PodVideoPlayer(controller: controller), const SizedBox(height: 40), _loadVideoFromUrl() ], @@ -47,6 +67,38 @@ class _PlayVideoFromVimeoPrivateIdState ), ); } + + Widget _buildErrorWidget() { + return Container( + padding: const EdgeInsets.all(16), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon( + Icons.error_outline, + color: Colors.red, + size: 48, + ), + const SizedBox(height: 16), + Text( + 'Failed to load initial video', + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 8), + Text( + initErrorMessage ?? 'Unknown error', + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.bodyMedium, + ), + const SizedBox(height: 16), + Text( + 'Try loading a different video below', + style: Theme.of(context).textTheme.bodySmall, + ), + ], + ), + ); + } Row _loadVideoFromUrl() { return Row( @@ -91,6 +143,12 @@ class _PlayVideoFromVimeoPrivateIdState try { snackBar('Loading....'); FocusScope.of(context).unfocus(); + + // Reset error state when trying new video + setState(() { + hasInitError = false; + initErrorMessage = null; + }); final Map headers = {}; headers['Authorization'] = 'Bearer ${tokenTextFieldCtr.text}'; @@ -104,7 +162,14 @@ class _PlayVideoFromVimeoPrivateIdState if (!mounted) return; ScaffoldMessenger.of(context).hideCurrentSnackBar(); } catch (e) { - snackBar('Unable to load,\n $e'); + final errorMessage = VideoApiHandler.getDetailedErrorMessage(e); + snackBar('Unable to load:\n$errorMessage'); + + // Update error state for UI + setState(() { + hasInitError = true; + initErrorMessage = errorMessage; + }); } }, child: const Text('Load Video'), diff --git a/example/lib/utils/README.md b/example/lib/utils/README.md new file mode 100644 index 00000000..56727c7b --- /dev/null +++ b/example/lib/utils/README.md @@ -0,0 +1,81 @@ +# Vimeo Error Handling Utilities + +This directory contains utilities for robust error handling when working with Vimeo APIs in the example application. + +## Files + +### vimeo_410_exception.dart +Custom exception class for handling Vimeo 410 Gone errors, which occur when progressive links have expired. + +### video_api_handler.dart +Main utility class that provides: +- `handleVimeoApiResponse()`: Validates Vimeo API responses and throws appropriate exceptions +- `getDetailedErrorMessage()`: Converts exceptions into user-friendly error messages + +### safe_pod_player.dart +A wrapper widget that safely handles PodPlayer initialization errors: +- Automatically catches and displays initialization errors +- Provides retry functionality +- Supports custom error UI via `errorBuilder` parameter +- Shows loading state during initialization + +## Error Types Handled + +1. **410 Gone**: Progressive links have expired + - User message: "The video links have expired. Please refresh the video to get new links." + +2. **403 Forbidden**: Access denied to private/restricted videos + - User message: "Access denied. The video may be private or restricted." + +3. **HTML Response**: Server returns HTML instead of expected JSON + - User message: "Vimeo returned an unexpected HTML response. This usually indicates a server issue or that the video is unavailable." + +4. **CORS Errors**: Browser security restrictions + - User message: "CORS error: To play Vimeo videos in web, please enable CORS in your browser." + +## Usage Example + +```dart +import '../utils/video_api_handler.dart'; + +try { + await controller.changeVideo( + playVideoFrom: PlayVideoFrom.vimeo(videoId, hash: hash), + ); +} catch (e) { + final errorMessage = VideoApiHandler.getDetailedErrorMessage(e); + // Show errorMessage to user +} +``` + +## Implementation Notes + +- All error responses are logged with detailed debug information +- HTML responses show the first 500 characters for debugging +- Custom error messages provide actionable guidance to users +- The error handling matches the implementation in the main pod_player package + +## Handling Initialization Errors + +Since the pod_player package throws exceptions during initialization, you need to wrap the `initialise()` call in a try-catch: + +```dart +try { + await controller.initialise(); +} catch (e) { + final errorMessage = VideoApiHandler.getDetailedErrorMessage(e); + // Handle error appropriately +} +``` + +Or use the `SafePodPlayer` widget which handles this automatically: + +```dart +SafePodPlayer( + controller: controller, + errorBuilder: (errorMessage) { + // Custom error UI (optional) + return MyCustomErrorWidget(message: errorMessage); + }, +) +``` \ No newline at end of file diff --git a/example/lib/utils/safe_pod_player.dart b/example/lib/utils/safe_pod_player.dart new file mode 100644 index 00000000..219670ec --- /dev/null +++ b/example/lib/utils/safe_pod_player.dart @@ -0,0 +1,124 @@ +import 'package:flutter/material.dart'; +import 'package:pod_player/pod_player.dart'; +import 'video_api_handler.dart'; + +/// A wrapper widget that safely handles PodPlayer initialization errors +class SafePodPlayer extends StatefulWidget { + final PodPlayerController controller; + final Widget Function(String errorMessage)? errorBuilder; + + const SafePodPlayer({ + Key? key, + required this.controller, + this.errorBuilder, + }) : super(key: key); + + @override + State createState() => _SafePodPlayerState(); +} + +class _SafePodPlayerState extends State { + bool isInitializing = true; + bool hasError = false; + String? errorMessage; + + @override + void initState() { + super.initState(); + _initializeController(); + } + + Future _initializeController() async { + try { + // Check if already initialized + if (!widget.controller.isInitialised) { + await widget.controller.initialise(); + } + if (mounted) { + setState(() { + isInitializing = false; + hasError = false; + }); + } + } catch (e) { + if (mounted) { + setState(() { + isInitializing = false; + hasError = true; + errorMessage = VideoApiHandler.getDetailedErrorMessage(e); + }); + } + } + } + + @override + void didUpdateWidget(SafePodPlayer oldWidget) { + super.didUpdateWidget(oldWidget); + // If controller changed, reinitialize + if (oldWidget.controller != widget.controller) { + setState(() { + isInitializing = true; + hasError = false; + errorMessage = null; + }); + _initializeController(); + } + } + + Widget _buildDefaultError() { + return Container( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.error_outline, + color: Colors.red, + size: 48, + ), + const SizedBox(height: 16), + Text( + 'Video Failed to Load', + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: 8), + Text( + errorMessage ?? 'An unknown error occurred', + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.bodyMedium, + ), + const SizedBox(height: 16), + ElevatedButton.icon( + onPressed: () { + setState(() { + isInitializing = true; + hasError = false; + errorMessage = null; + }); + _initializeController(); + }, + icon: const Icon(Icons.refresh), + label: const Text('Retry'), + ), + ], + ), + ); + } + + @override + Widget build(BuildContext context) { + if (isInitializing) { + return const Center( + child: CircularProgressIndicator(), + ); + } + + if (hasError) { + return widget.errorBuilder?.call(errorMessage ?? 'Unknown error') ?? + _buildDefaultError(); + } + + return PodVideoPlayer(controller: widget.controller); + } +} \ No newline at end of file diff --git a/example/lib/utils/video_api_handler.dart b/example/lib/utils/video_api_handler.dart new file mode 100644 index 00000000..ba13b65a --- /dev/null +++ b/example/lib/utils/video_api_handler.dart @@ -0,0 +1,81 @@ +import 'dart:convert'; +import 'package:flutter/material.dart'; +import 'package:http/http.dart' as http; +import 'package:pod_player/pod_player.dart'; +import 'vimeo_410_exception.dart'; + +class VideoApiHandler { + static Future handleVimeoApiResponse(http.Response response, String videoId, String? hash) async { + // Check for 410 Gone status + if (response.statusCode == 410) { + debugPrint('===== VIMEO API: Received 410 Gone status ====='); + debugPrint('Video ID: $videoId'); + debugPrint('This indicates the progressive links have expired and need to be refreshed.'); + throw Vimeo410Exception( + message: 'Progressive links expired', + videoId: videoId, + hash: hash, + ); + } + + // Check for 403 Forbidden status + if (response.statusCode == 403) { + debugPrint('===== VIMEO API: Received 403 Forbidden status ====='); + debugPrint('Video ID: $videoId'); + debugPrint('This video may be private, restricted, or authentication is invalid.'); + if (response.body.contains('Sorry')) { + debugPrint('Vimeo returned a "Sorry" page - video access denied.'); + } + throw Exception('Vimeo API returned 403 Forbidden - video access denied (Video ID: $videoId)'); + } + + // Check for HTML response instead of JSON + if (response.body.trim().startsWith(' 500) { + debugPrint('HTML Response (first 500 chars): ${response.body.substring(0, 500)}...'); + } else { + debugPrint('HTML Response: ${response.body}'); + } + debugPrint('===== End of HTML Response ====='); + throw FormatException('Vimeo API returned HTML instead of JSON (Status: ${response.statusCode})'); + } + } + + static String getDetailedErrorMessage(dynamic error, {String? responseBody}) { + if (error is Vimeo410Exception) { + return 'The video links have expired. Please refresh the video to get new links.'; + } else if (error is FormatException && error.message.contains('HTML instead of JSON')) { + return 'Vimeo returned an unexpected HTML response. This usually indicates a server issue or that the video is unavailable.'; + } else if (error is FormatException && error.toString().contains('Unexpected character')) { + // Enhanced logging for HTML response errors + debugPrint('===== VIMEO API ERROR: FormatException: Unexpected character (at character 1)'); + debugPrint(''); + debugPrint('^'); + debugPrint(' =========='); + debugPrint('Error Details: $error'); + + // Print the full HTML response if available + if (responseBody != null) { + debugPrint('===== FULL HTML RESPONSE ====='); + if (responseBody.length > 2000) { + debugPrint('HTML Response (first 2000 chars): ${responseBody.substring(0, 2000)}...'); + } else { + debugPrint('HTML Response: $responseBody'); + } + debugPrint('===== END OF HTML RESPONSE ====='); + } + + debugPrint('===== End of Error Details ====='); + return 'Vimeo API returned HTML instead of JSON. This usually means the video is unavailable or there\'s a server issue.'; + } else if (error.toString().contains('403 Forbidden')) { + return 'Access denied. The video may be private or restricted.'; + } else if (error.toString().contains('XMLHttpRequest')) { + return 'CORS error: To play Vimeo videos in web, please enable CORS in your browser.'; + } + return error.toString(); + } +} \ No newline at end of file diff --git a/example/lib/utils/vimeo_410_exception.dart b/example/lib/utils/vimeo_410_exception.dart new file mode 100644 index 00000000..994d339e --- /dev/null +++ b/example/lib/utils/vimeo_410_exception.dart @@ -0,0 +1,15 @@ +/// Custom exception for Vimeo 410 Gone errors +class Vimeo410Exception implements Exception { + final String message; + final String videoId; + final String? hash; + + Vimeo410Exception({ + required this.message, + required this.videoId, + this.hash, + }); + + @override + String toString() => 'Vimeo410Exception: $message (videoId: $videoId)'; +} \ No newline at end of file diff --git a/example/packages/CHANGELOG.md b/example/packages/CHANGELOG.md new file mode 100644 index 00000000..26d68b0f --- /dev/null +++ b/example/packages/CHANGELOG.md @@ -0,0 +1,108 @@ +## 0.2.2 +- fixed broken vimeo api requests +- upgraded minimum sdk version from ">=2.17.0 <4.0.0" to ">=3.0.0 <4.0.0" +- upgraded all the dependencies to latest version +- fix typos in README.md + +## 0.2.1 +- upgraded all dependencies +- upgraded `http` lib to `^1.1.0` +- fix fullscreen issue in IOS + +## 0.2.0 + - upgraded dependencies + - migrate from `wakelock` to `wakelock_plus` PR [#129](https://github.com/newtaDev/pod_player/pull/129) + - fixed all lint rules + - migrated `VideoPlayerController.network` to `VideoPlayerController.networkUrl` + - Breaking: + - In `PlayVideoFrom.file` [file] param datatype changed from [dynamic] to [File] +## 0.1.5 + - merged PR #103 + - support unlisted vimeo videos + - upgraded dependencies + - Updated Readme file +## 0.1.4 + - added pod player logo to pub.dev +## 0.1.3 + - fix: unable to find directory entry in pubspec.yaml #114 + - merged PR #109 +## 0.1.2 + - fixed #82 +## 0.1.1 + - Feature + - support vimeo private video [ref](https://github.com/newtaDev/pod_player#how-to-play-video-from-vimeo-private-videos) + - double tap ripple effect added + - upgraded dependencies + - merged PR #66 #77 #78 +## 0.1.0 + +- Breaking change: + + - In `PodPlayerConfig` `initialVideoQuality` changed to `videoQualityPriority` to support priority of video qualities + + ```dart + controller = PodPlayerController( + podPlayerConfig: const PodPlayerConfig( + videoQualityPriority: [1080, 720, 360], + ), + )..initialise() + ``` + +- Features + + - Support for youtube live videos By [`(@vodino)`](https://github.com/vodino) + - Added: `videoQualityPriority` to `PodPlayerConfig` By [`(@emersonsiega)`](https://github.com/emersonsiega) + - Added: callback `onToggleFullScreen` when changes in fullscreen mode [#48](https://github.com/newtaDev/pod_player/issues/48) + - Added: `hideOverlay` and `showOverlay` functions to controller + +- Bug Fixes + - Merged PR #54 By [`(@emersonsiega)`](https://github.com/emersonsiega) + - Fix unhandled exception on initialization [#49](https://github.com/newtaDev/pod_player/issues/49) + - Add video quality priority list + - Changes in `onToggleFullScreen` + +## 0.0.8 + +- Merged PR #37 & #38, By [`(@Jeferson505)`](https://github.com/Jeferson505) + - Added `PodPlayerLabels` param to `PodVideoPlayer` widget + - Added PodPlayerLabels usage example in `from_asset` file + - Seted `normal` playback speed to `1x` +- bug fix and added example for playing videos in list + +## 0.0.7 + +- dependencies upgraded + - video_player: ^2.4.5 +- code refactor + +## 0.0.6 + +- Upgraded to Dart 2.17.0 +- Bug fixes +- Added some examples + +## 0.0.5 + +- Features + - Added support for thumbnails + - Added `isFullScreen` getter to controller +- Updated docs + +## 0.0.4 + +- Features + - support for RTL (by @karbalaidev) + - initialVideoQuality added +- Bug fixes + +## 0.0.3 + +- Bug fix #4 + +## 0.0.2 + +- Ignored .mp4 video file in pub + +## 0.0.1 + +- Initial release diff --git a/example/packages/LICENSE b/example/packages/LICENSE new file mode 100644 index 00000000..e9b98ce4 --- /dev/null +++ b/example/packages/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2022 Newton Michael + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/example/packages/README.md b/example/packages/README.md new file mode 100644 index 00000000..e5515225 --- /dev/null +++ b/example/packages/README.md @@ -0,0 +1,496 @@ +

+ pod_player +

+ +

+ pub likes + pub version + score + pub points + +

+

newta

+ +Video player for flutter web & mobile devices, pod player supports playing video from `Youtube` and `Vimeo` + +pod player is a simple and easy-to-use video player. Its video controls are similar to Youtube player (with customizable controls) and also can play videos from `Youtube` and `Vimeo` (By providing url/video_id). + +This plugin built upon flutter's official [`video_player`](https://pub.dartlang.org/packages/video_player) plugin + +--- + +| PLATFORM | AVAILABLE | +| :------: | :-------: | +| Android | ✅ | +| IOS | ✅ | +| WEB | ✅ | + +## Features + +- Play `youtube` videos (using video URL or ID) +- Play `vimeo` videos (using video ID [with ou without hash]) +- Play `vimeo` private videos (using video ID [with ou without hash], access token) +- Video overlay similar to youtube +- `Double tap` to seek video. +- On video tap show/hide video overlay. +- Auto hide overlay +- Change `playback speed` +- Custom overlay +- Custom progress bar +- Custom labels +- `Change video quality` (for vimeo and youtube) +- Enable/disable full-screen player +- support for live youtube video +- [TODO] support for video playlist + +## Features on web + +- Double tap on Video player to enable/disable full-screen +- `Mute/unMute` volume +- Video player integration with keyboard + + - `SPACE` play/pause video + - `M` mute/unMute video + - `F` enable/disable full-screen + - `ESC` enable/disable full-screen + - `->` seek video forward + - `<-` seek video backward + +- Double tap on video (enable/disables full-screen) + +## Demo + +--- + +- Playing videos from youtube + +--- + +

+ pod_player +

+ +- Video player on web + +--- + +

+ pod_player +

+ +- Vimeo player and custom video player + +--- + +| Change quality and playback speed | Control video from any where | +| :--------------------------------------------------------------------------------------------------------: | :--------------------------------------------------------------------------------------------------------: | +| ![](https://user-images.githubusercontent.com/85326522/160657119-7295ef4e-851b-42a3-a792-856fb6045b11.gif) | ![](https://user-images.githubusercontent.com/85326522/160657075-a17876c1-680b-472d-b1b9-ab06ba315b96.gif) | + +--- + +- Controls similar to youtube + +--- + +| with overlay | without overlay `(alwaysShowProgressBar = true)` | +| :--------------------------------------------------------------------------------------------------------: | :--------------------------------------------------------------------------------------------------------: | +| ![](https://user-images.githubusercontent.com/85326522/156813671-ba562deb-3607-46a6-800c-d3a731b22cdd.jpg) | ![](https://user-images.githubusercontent.com/85326522/156813681-fad9f1f9-d73c-478f-8477-b42342424b4a.jpg) | + +--- + +- On mobile full-screen + +--- + +![](https://user-images.githubusercontent.com/85326522/156813701-aa722624-fde3-4036-9392-a0107ee863b2.jpg) + +--- + +- Video controls + +--- + +| On Double tap | Custom progress bar | +| :--------------------------------------------------------------------------------------------------------: | :--------------------------------------------------------------------------------------------------------: | +| ![](https://user-images.githubusercontent.com/85326522/156813691-cd75c638-a4d3-4dda-8a22-eed3e43bd299.jpg) | ![](https://user-images.githubusercontent.com/85326522/156815812-e85bd5bc-2401-42d9-a7ba-c5ad2be494fa.jpg) | + +--- + +- Video player on web + +--- + +![](https://user-images.githubusercontent.com/85326522/156824569-d1ec705d-c278-4503-81fb-84e9dcb58336.jpg) + +--- + +## Usage + +- [Installation](#installation) + - [Android](#android) + - [Ios](#ios) + - [Web](#web--not-recommended-in-production) +- [How to use](#how-to-use) +- [Configure pod player](#configure-pod-player) +- [Add Thumbnail](#add-thumbnail) +- [How to play video from youtube](#how-to-play-video-from-youtube) +- [How to play video from vimeo](#how-to-play-video-from-vimeo) +- [How to play video from vimeo private videos](#How-to-play-video-from-vimeo-private-videos) +- [video player Options](#options) +- [Example](#example) + +## Installation + +--- + +In your `pubspec.yaml` file within your Flutter Project: + +```yaml +dependencies: + pod_player: +``` + +### Android + +--- + +If you are using network-based videos, ensure that the following permission is present in your Android Manifest file, located in `/android/app/src/main/AndroidManifest.xml`: + +```xml + +``` + +If you need to access videos using http (rather than https) URLs. + +Located inside application tag + +```xml +/ios/Runner/Info.plist` + +```xml +NSAppTransportSecurity + + NSAllowsArbitraryLoads + + +``` + +### Web ( Not recommended in production) + +--- + +if u are using `youtube` or `vimeo` player on web, then there will be some issue with `CORS` only in web, +so use this [`flutter_cors`](https://pub.dev/packages/flutter_cors) package + +#### using [`flutter_cors`](https://pub.dev/packages/flutter_cors) package to enable or disable CORS + +> To Enable CORS (run this command ) + +``` +dart pub global activate flutter_cors +fluttercors --enable +``` + +> To Disable CORS (run this command ) + +``` +fluttercors --disable +``` + +## How to use + +--- + +```dart +import 'package:pod_player/pod_player.dart'; +import 'package:flutter/material.dart'; + +class PlayVideoFromNetwork extends StatefulWidget { + const PlayVideoFromNetwork({Key? key}) : super(key: key); + + @override + State createState() => _PlayVideoFromNetworkState(); +} + +class _PlayVideoFromNetworkState extends State { + late final PodPlayerController controller; + + @override + void initState() { + controller = PodPlayerController( + playVideoFrom: PlayVideoFrom.network( + 'https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ForBiggerFun.mp4', + ), + )..initialise(); + super.initState(); + } + + @override + void dispose() { + controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + body: PodVideoPlayer(controller: controller), + ); + } +} + +``` + +## Configure pod player + +```dart + controller = PodPlayerController( + playVideoFrom: PlayVideoFrom.youtube('https://youtu.be/A3ltMaM6noM'), + podPlayerConfig: const PodPlayerConfig( + autoPlay: true, + isLooping: false, + videoQualityPriority: [720, 360] + ) + )..initialise(); +``` + +## Add Thumbnail + +```dart +PodVideoPlayer( + controller: controller, + videoThumbnail: const DecorationImage( + /// load from asset: AssetImage('asset_path') + image: NetworkImage('https://images.unsplash.com/photo-1569317002804-ab77bcf1bce4?ixlib=rb-1.2.1&ixid=MnwxMjA3fDB8MHxzZWFyY2h8MXx8dW5zcGxhc2h8ZW58MHx8MHx8&w=1000&q=80', + ), + fit: BoxFit.cover, + ), +), +``` + +## Add PodPlayerLabels (custom labels) + +```dart +@override +Widget build(BuildContext context) { + return Scaffold( + body: PodVideoPlayer( + controller: controller, + podPlayerLabels: const PodPlayerLabels( + play: "Play label customized", + pause: "Pause label customized", + ... + ), + ), + ); +} +``` + +## How to play video from youtube + +--- + +```dart +import 'package:pod_player/pod_player.dart'; +import 'package:flutter/material.dart'; + +class PlayVideoFromYoutube extends StatefulWidget { + const PlayVideoFromYoutube({Key? key}) : super(key: key); + + @override + State createState() => _PlayVideoFromYoutubeState(); +} + +class _PlayVideoFromYoutubeState extends State { + late final PodPlayerController controller; + + @override + void initState() { + controller = PodPlayerController( + playVideoFrom: PlayVideoFrom.youtube('https://youtu.be/A3ltMaM6noM'), + )..initialise(); + super.initState(); + } + + @override + void dispose() { + controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + body: PodVideoPlayer(controller: controller), + ); + } +} + +``` + +## How to play video from vimeo + +--- + +```dart +import 'package:pod_player/pod_player.dart'; +import 'package:flutter/material.dart'; + +class PlayVideoFromVimeo extends StatefulWidget { + const PlayVideoFromVimeo({Key? key}) : super(key: key); + + @override + State createState() => _PlayVideoFromVimeoState(); +} + +class _PlayVideoFromVimeoState extends State { + late final PodPlayerController controller; + + @override + void initState() { + controller = PodPlayerController( + playVideoFrom: PlayVideoFrom.vimeo('518228118'), + )..initialise(); + super.initState(); + } + + @override + void dispose() { + controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + body: PodVideoPlayer(controller: controller), + ); + } +} + +``` + +## How to play video from vimeo with hash + +--- + +```dart +import 'package:pod_player/pod_player.dart'; +import 'package:flutter/material.dart'; + +class PlayVideoFromVimeo extends StatefulWidget { + const PlayVideoFromVimeo({Key? key}) : super(key: key); + + @override + State createState() => _PlayVideoFromVimeoState(); +} + +class _PlayVideoFromVimeoState extends State { + late final PodPlayerController controller; + + @override + void initState() { + controller = PodPlayerController( + playVideoFrom: PlayVideoFrom.vimeo('518228118', hash: '7cc595e1f8'), + )..initialise(); + super.initState(); + } + + @override + void dispose() { + controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + body: PodVideoPlayer(controller: controller), + ); + } +} + +``` + +## How to play video from vimeo private videos + +--- + +```dart +import 'package:pod_player/pod_player.dart'; +import 'package:flutter/material.dart'; + +class PlayVideoFromVimeoPrivateVideo extends StatefulWidget { + const PlayVideoFromVimeoPrivateVideo({Key? key}) : super(key: key); + + @override + State createState() => + _PlayVideoFromVimeoPrivateVideoState(); +} + +class _PlayVideoFromVimeoPrivateVideoState + extends State { + late final PodPlayerController controller; + + @override + void initState() { + String videoId = 'your private video id'; + String token = 'your access token'; + final Map headers = {}; + headers['Authorization'] = 'Bearer ${token}'; + + controller = PodPlayerController( + playVideoFrom: PlayVideoFrom.vimeoPrivateVideos( + videoId, + httpHeaders: headers + ), + )..initialise(); + super.initState(); + } + + @override + void dispose() { + controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + body: PodVideoPlayer(controller: controller), + ); + } +} + +``` + +## Options + +--- + +- Options for mobile + +--- + +| `Normal player option` | `Vimeo player option` | `Change quality of video` | +| :--------------------------------------------------------------------------------------------------------: | :--------------------------------------------------------------------------------------------------------: | :--------------------------------------------------------------------------------------------------------: | +| ![](https://user-images.githubusercontent.com/85326522/156813694-65cc70ff-f87f-4668-9ac4-7c0ee14c40cb.jpg) | ![](https://user-images.githubusercontent.com/85326522/156821283-f5470bd2-21ad-4fee-90ac-85176ccc788f.jpg) | ![](https://user-images.githubusercontent.com/85326522/156821301-7c6b1a6d-68a6-4945-8cca-d5e417042e30.jpg) | + +## Example + +--- + +Please run the app in the [`example/`](https://github.com/newtaDev/fl_video_player/tree/master/example) folder to start playing! diff --git a/example/packages/analysis_options.yaml b/example/packages/analysis_options.yaml new file mode 100644 index 00000000..18aea978 --- /dev/null +++ b/example/packages/analysis_options.yaml @@ -0,0 +1,34 @@ +include: package:very_good_analysis/analysis_options.yaml +analyzer: + errors: + missing_required_param: error + prefer_const_declarations: warning + prefer_const_constructors: warning + import_of_legacy_library_into_null_safe: warning + public_member_api_docs: ignore + language: + strict-casts: true + strict-raw-types: true +linter: + rules: + omit_local_variable_types: false + prefer_const_declarations: true + prefer_const_constructors: true + public_member_api_docs: false + use_key_in_widget_constructors: true + prefer_int_literals: true + lines_longer_than_80_chars: false + prefer_relative_imports: true + always_use_package_imports: false + avoid_print: true + prefer_single_quotes: true + avoid_redundant_argument_values: true + unnecessary_parenthesis: true + prefer_final_locals: true + avoid_dynamic_calls: false + sort_constructors_first: false + sort_pub_dependencies: false + avoid_positional_boolean_parameters: false + use_build_context_synchronously: false + use_setters_to_change_properties: false + avoid_bool_literals_in_conditional_expressions: false diff --git a/example/packages/lib/pod_player.dart b/example/packages/lib/pod_player.dart new file mode 100644 index 00000000..e7a0d631 --- /dev/null +++ b/example/packages/lib/pod_player.dart @@ -0,0 +1,15 @@ +/// Pod Player library +library pod_player; + +export 'package:video_player/video_player.dart'; + +export 'src/controllers/pod_player_controller.dart'; +export 'src/models/overlay_options.dart'; +export 'src/models/play_video_from.dart'; +export 'src/models/pod_player_config.dart'; +export 'src/models/pod_player_labels.dart'; +export 'src/models/pod_progress_bar_config.dart'; +export 'src/models/vimeo_models.dart'; +export 'src/pod_player.dart'; +export 'src/utils/enums.dart'; +export 'src/widgets/pod_progress_bar.dart'; diff --git a/example/packages/lib/src/controllers/pod_base_controller.dart b/example/packages/lib/src/controllers/pod_base_controller.dart new file mode 100644 index 00000000..4edc604c --- /dev/null +++ b/example/packages/lib/src/controllers/pod_base_controller.dart @@ -0,0 +1,125 @@ +part of 'pod_getx_video_controller.dart'; +// ignore_for_file: prefer_final_fields + +class _PodBaseController extends GetxController { + ///main video controller + VideoPlayerController? _videoCtr; + + /// + late PodVideoPlayerType _videoPlayerType; + + bool isMute = false; + FocusNode? keyboardFocusWeb; + + bool autoPlay = true; + bool _isWebAutoPlayDone = false; + + /// + PodVideoState _podVideoState = PodVideoState.loading; + + /// + bool isWebPopupOverlayOpen = false; + + /// + Duration _videoDuration = Duration.zero; + + Duration _videoPosition = Duration.zero; + + String _currentPaybackSpeed = '1x'; + + bool? isVideoUiBinded; + + bool? wasVideoPlayingOnUiDispose; + + int doubleTapForwardSeconds = 10; + String? playingVideoUrl; + + late BuildContext mainContext; + late BuildContext fullScreenContext; + + ///**listners + + Future videoListner() async { + if (!_videoCtr!.value.isInitialized) { + await _videoCtr!.initialize(); + } + if (_videoCtr!.value.isInitialized) { + // _listneToVideoState(); + _listneToVideoPosition(); + _listneToVolume(); + if (kIsWeb && autoPlay && isMute && !_isWebAutoPlayDone) _webAutoPlay(); + } + } + + void _webAutoPlay() => _videoCtr!.setVolume(1); + + void _listneToVolume() { + if (_videoCtr!.value.volume == 0) { + if (!isMute) { + isMute = true; + update(['volume']); + update(['update-all']); + } + } else { + if (isMute) { + isMute = false; + update(['volume']); + update(['update-all']); + } + } + } + + // void _listneToVideoState() { + // podVideoStateChanger( + // _videoCtr!.value.isBuffering || !_videoCtr!.value.isInitialized + // ? PodVideoState.loading + // : _videoCtr!.value.isPlaying + // ? PodVideoState.playing + // : PodVideoState.paused, + // ); + // } + + ///updates state with id `_podVideoState` + void podVideoStateChanger(PodVideoState? val, {bool updateUi = true}) { + if (_podVideoState != (val ?? _podVideoState)) { + _podVideoState = val ?? _podVideoState; + if (updateUi) { + update(['podVideoState']); + update(['update-all']); + } + } + } + + void _listneToVideoPosition() { + if ((_videoCtr?.value.duration.inSeconds ?? Duration.zero.inSeconds) < 60) { + _videoPosition = _videoCtr?.value.position ?? Duration.zero; + update(['video-progress']); + update(['update-all']); + } else { + if (_videoPosition.inSeconds != + (_videoCtr?.value.position ?? Duration.zero).inSeconds) { + _videoPosition = _videoCtr?.value.position ?? Duration.zero; + update(['video-progress']); + update(['update-all']); + } + } + } + + void keyboadListner() { + if (keyboardFocusWeb != null && !keyboardFocusWeb!.hasFocus) { + if (keyboardFocusWeb!.canRequestFocus) { + keyboardFocusWeb!.requestFocus(); + } + } + } + + // void keyboadFullScreenListner() { + // print(keyboardFocusOnFullScreen?.hasFocus); + // if (keyboardFocusOnFullScreen != null && + // !keyboardFocusOnFullScreen!.hasFocus) { + // if (keyboardFocusOnFullScreen!.canRequestFocus) { + // keyboardFocusOnFullScreen!.requestFocus(); + // } + // } + // } +} diff --git a/example/packages/lib/src/controllers/pod_gestures_controller.dart b/example/packages/lib/src/controllers/pod_gestures_controller.dart new file mode 100644 index 00000000..5438034a --- /dev/null +++ b/example/packages/lib/src/controllers/pod_gestures_controller.dart @@ -0,0 +1,91 @@ +part of 'pod_getx_video_controller.dart'; + +class _PodGesturesController extends _PodVideoQualityController { + //double tap + Timer? leftDoubleTapTimer; + Timer? rightDoubleTapTimer; + int leftDoubleTapduration = 0; + int rightDubleTapduration = 0; + bool isLeftDbTapIconVisible = false; + bool isRightDbTapIconVisible = false; + + Timer? hoverOverlayTimer; + + ///*handle double tap + + void onLeftDoubleTap({int? seconds}) { + isShowOverlay(true); + leftDoubleTapTimer?.cancel(); + rightDoubleTapTimer?.cancel(); + + isRightDbTapIconVisible = false; + isLeftDbTapIconVisible = true; + updateLeftTapDuration( + leftDoubleTapduration += seconds ?? doubleTapForwardSeconds, + ); + seekBackward(Duration(seconds: seconds ?? doubleTapForwardSeconds)); + update(['double-tap-left']); + leftDoubleTapTimer = Timer(const Duration(milliseconds: 500), () { + isLeftDbTapIconVisible = false; + updateLeftTapDuration(0); + leftDoubleTapTimer?.cancel(); + if (isvideoPlaying) { + playVideo(true); + } + isShowOverlay(false); + }); + } + + void onRightDoubleTap({int? seconds}) { + isShowOverlay(true); + rightDoubleTapTimer?.cancel(); + leftDoubleTapTimer?.cancel(); + + isLeftDbTapIconVisible = false; + isRightDbTapIconVisible = true; + updateRightTapDuration( + rightDubleTapduration += seconds ?? doubleTapForwardSeconds, + ); + seekForward(Duration(seconds: seconds ?? doubleTapForwardSeconds)); + update(['double-tap-right']); + rightDoubleTapTimer = Timer(const Duration(milliseconds: 500), () { + isRightDbTapIconVisible = false; + updateRightTapDuration(0); + rightDoubleTapTimer?.cancel(); + if (isvideoPlaying) { + playVideo(true); + } + isShowOverlay(false); + }); + } + + void onOverlayHover() { + if (kIsWeb) { + hoverOverlayTimer?.cancel(); + isShowOverlay(true); + hoverOverlayTimer = Timer( + const Duration(seconds: 3), + () => isShowOverlay(false), + ); + } + } + + void onOverlayHoverExit() { + if (kIsWeb) { + isShowOverlay(false); + } + } + + ///update doubletap durations + void updateLeftTapDuration(int val) { + leftDoubleTapduration = val; + update(['double-tap']); + update(['update-all']); + } + + void updateRightTapDuration(int val) { + rightDubleTapduration = val; + update(['double-tap']); + update(['update-all']); + } +} diff --git a/example/packages/lib/src/controllers/pod_getx_video_controller.dart b/example/packages/lib/src/controllers/pod_getx_video_controller.dart new file mode 100644 index 00000000..193d061b --- /dev/null +++ b/example/packages/lib/src/controllers/pod_getx_video_controller.dart @@ -0,0 +1,306 @@ +import 'dart:async'; + +import 'package:flutter/cupertino.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:get/get.dart'; +import 'package:universal_html/html.dart' as uni_html; +import 'package:wakelock_plus/wakelock_plus.dart'; + +import '../../pod_player.dart'; +import '../utils/logger.dart'; +import '../utils/video_apis.dart'; +import '../utils/vimeo_410_exception.dart'; + +part 'pod_base_controller.dart'; +part 'pod_gestures_controller.dart'; +part 'pod_ui_controller.dart'; +part 'pod_video_controller.dart'; +part 'pod_video_quality_controller.dart'; + +class PodGetXVideoController extends _PodGesturesController { + ///main videoplayer controller + VideoPlayerController? get videoCtr => _videoCtr; + + ///podVideoPlayer state notifier + PodVideoState get podVideoState => _podVideoState; + + ///vimeo or general --video player type + PodVideoPlayerType get videoPlayerType => _videoPlayerType; + + String get currentPaybackSpeed => _currentPaybackSpeed; + + /// + Duration get videoDuration => _videoDuration; + + /// + Duration get videoPosition => _videoPosition; + + bool controllerInitialized = false; + late PodPlayerConfig podPlayerConfig; + late PlayVideoFrom playVideoFrom; + void config({ + required PlayVideoFrom playVideoFrom, + required PodPlayerConfig playerConfig, + }) { + this.playVideoFrom = playVideoFrom; + _videoPlayerType = playVideoFrom.playerType; + podPlayerConfig = playerConfig; + autoPlay = playerConfig.autoPlay; + isLooping = playerConfig.isLooping; + } + + ///*init + Future videoInit() async { + /// + // checkPlayerType(); + podLog(_videoPlayerType.toString()); + try { + await _initializePlayer(); + await _videoCtr?.initialize(); + _videoDuration = _videoCtr?.value.duration ?? Duration.zero; + await setLooping(isLooping); + _videoCtr?.addListener(videoListner); + addListenerId('podVideoState', podStateListner); + + checkAutoPlayVideo(); + controllerInitialized = true; + update(); + + update(['update-all']); + // ignore: unawaited_futures + Future.delayed(const Duration(milliseconds: 600)) + .then((_) => _isWebAutoPlayDone = true); + } catch (e) { + podVideoStateChanger(PodVideoState.error); + update(['errorState']); + update(['update-all']); + podLog('ERROR ON POD_PLAYER: $e'); + rethrow; + } + } + + Future _initializePlayer() async { + switch (_videoPlayerType) { + case PodVideoPlayerType.network: + + /// + _videoCtr = VideoPlayerController.networkUrl( + Uri.parse(playVideoFrom.dataSource!), + closedCaptionFile: playVideoFrom.closedCaptionFile, + formatHint: playVideoFrom.formatHint, + videoPlayerOptions: playVideoFrom.videoPlayerOptions, + httpHeaders: playVideoFrom.httpHeaders, + ); + playingVideoUrl = playVideoFrom.dataSource; + break; + case PodVideoPlayerType.networkQualityUrls: + final url = await getUrlFromVideoQualityUrls( + qualityList: podPlayerConfig.videoQualityPriority, + videoUrls: playVideoFrom.videoQualityUrls!, + ); + + /// + _videoCtr = VideoPlayerController.networkUrl( + Uri.parse(url), + closedCaptionFile: playVideoFrom.closedCaptionFile, + formatHint: playVideoFrom.formatHint, + videoPlayerOptions: playVideoFrom.videoPlayerOptions, + httpHeaders: playVideoFrom.httpHeaders, + ); + playingVideoUrl = url; + + break; + case PodVideoPlayerType.youtube: + final urls = await getVideoQualityUrlsFromYoutube( + playVideoFrom.dataSource!, + playVideoFrom.live, + ); + final url = await getUrlFromVideoQualityUrls( + qualityList: podPlayerConfig.videoQualityPriority, + videoUrls: urls, + ); + + /// + _videoCtr = VideoPlayerController.networkUrl( + Uri.parse(url), + closedCaptionFile: playVideoFrom.closedCaptionFile, + formatHint: playVideoFrom.formatHint, + videoPlayerOptions: playVideoFrom.videoPlayerOptions, + httpHeaders: playVideoFrom.httpHeaders, + ); + playingVideoUrl = url; + + break; + case PodVideoPlayerType.vimeo: + await getQualityUrlsFromVimeoId( + playVideoFrom.dataSource!, + hash: playVideoFrom.hash, + ); + final url = await getUrlFromVideoQualityUrls( + qualityList: podPlayerConfig.videoQualityPriority, + videoUrls: vimeoOrVideoUrls, + ); + + _videoCtr = VideoPlayerController.networkUrl( + Uri.parse(url), + closedCaptionFile: playVideoFrom.closedCaptionFile, + formatHint: playVideoFrom.formatHint, + videoPlayerOptions: playVideoFrom.videoPlayerOptions, + httpHeaders: playVideoFrom.httpHeaders, + ); + playingVideoUrl = url; + + break; + case PodVideoPlayerType.asset: + + /// + _videoCtr = VideoPlayerController.asset( + playVideoFrom.dataSource!, + closedCaptionFile: playVideoFrom.closedCaptionFile, + package: playVideoFrom.package, + videoPlayerOptions: playVideoFrom.videoPlayerOptions, + ); + playingVideoUrl = playVideoFrom.dataSource; + + break; + case PodVideoPlayerType.file: + if (kIsWeb) { + throw Exception('file doesnt support web'); + } + + /// + _videoCtr = VideoPlayerController.file( + playVideoFrom.file!, + closedCaptionFile: playVideoFrom.closedCaptionFile, + videoPlayerOptions: playVideoFrom.videoPlayerOptions, + ); + + break; + case PodVideoPlayerType.vimeoPrivateVideos: + await getQualityUrlsFromVimeoPrivateId( + playVideoFrom.dataSource!, + playVideoFrom.httpHeaders, + ); + final url = await getUrlFromVideoQualityUrls( + qualityList: podPlayerConfig.videoQualityPriority, + videoUrls: vimeoOrVideoUrls, + ); + + _videoCtr = VideoPlayerController.networkUrl( + Uri.parse(url), + closedCaptionFile: playVideoFrom.closedCaptionFile, + formatHint: playVideoFrom.formatHint, + videoPlayerOptions: playVideoFrom.videoPlayerOptions, + httpHeaders: playVideoFrom.httpHeaders, + ); + playingVideoUrl = url; + + break; + } + } + + ///Listning on keyboard events + void onKeyBoardEvents({ + required RawKeyEvent event, + required BuildContext appContext, + required String tag, + }) { + if (kIsWeb) { + if (event.isKeyPressed(LogicalKeyboardKey.space)) { + togglePlayPauseVideo(); + return; + } + if (event.isKeyPressed(LogicalKeyboardKey.keyM)) { + toggleMute(); + return; + } + if (event.isKeyPressed(LogicalKeyboardKey.arrowLeft)) { + onLeftDoubleTap(); + return; + } + if (event.isKeyPressed(LogicalKeyboardKey.arrowRight)) { + onRightDoubleTap(); + return; + } + if (event.isKeyPressed(LogicalKeyboardKey.keyF) && + event.logicalKey.keyLabel == 'F') { + toggleFullScreenOnWeb(appContext, tag); + } + if (event.isKeyPressed(LogicalKeyboardKey.escape)) { + if (isFullScreen) { + uni_html.document.exitFullscreen(); + if (!isWebPopupOverlayOpen) { + disableFullScreen(appContext, tag); + } + } + } + + return; + } + } + + void toggleFullScreenOnWeb(BuildContext context, String tag) { + if (isFullScreen) { + uni_html.document.exitFullscreen(); + if (!isWebPopupOverlayOpen) { + disableFullScreen(context, tag); + } + } else { + uni_html.document.documentElement?.requestFullscreen(); + enableFullScreen(tag); + } + } + + ///this func will listne to update id `_podVideoState` + void podStateListner() { + podLog(_podVideoState.toString()); + switch (_podVideoState) { + case PodVideoState.playing: + if (podPlayerConfig.wakelockEnabled) WakelockPlus.enable(); + playVideo(true); + break; + case PodVideoState.paused: + if (podPlayerConfig.wakelockEnabled) WakelockPlus.disable(); + playVideo(false); + break; + case PodVideoState.loading: + isShowOverlay(true); + break; + case PodVideoState.error: + if (podPlayerConfig.wakelockEnabled) WakelockPlus.disable(); + playVideo(false); + break; + } + } + + ///checkes wether video should be `autoplayed` initially + void checkAutoPlayVideo() { + WidgetsBinding.instance.addPostFrameCallback((timeStamp) async { + if (autoPlay && (isVideoUiBinded ?? false)) { + if (kIsWeb) await _videoCtr?.setVolume(0); + podVideoStateChanger(PodVideoState.playing); + } else { + podVideoStateChanger(PodVideoState.paused); + } + }); + } + + Future changeVideo({ + required PlayVideoFrom playVideoFrom, + required PodPlayerConfig playerConfig, + }) async { + _videoCtr?.removeListener(videoListner); + podVideoStateChanger(PodVideoState.paused); + podVideoStateChanger(PodVideoState.loading); + keyboardFocusWeb?.removeListener(keyboadListner); + removeListenerId('podVideoState', podStateListner); + _isWebAutoPlayDone = false; + vimeoOrVideoUrls = []; + config(playVideoFrom: playVideoFrom, playerConfig: playerConfig); + keyboardFocusWeb?.requestFocus(); + keyboardFocusWeb?.addListener(keyboadListner); + await videoInit(); + } +} diff --git a/example/packages/lib/src/controllers/pod_player_controller.dart b/example/packages/lib/src/controllers/pod_player_controller.dart new file mode 100644 index 00000000..229ce884 --- /dev/null +++ b/example/packages/lib/src/controllers/pod_player_controller.dart @@ -0,0 +1,271 @@ +import 'dart:async'; + +import 'package:flutter/cupertino.dart'; +import 'package:get/get.dart'; +import 'package:universal_html/html.dart' as uni_html; +import 'package:wakelock_plus/wakelock_plus.dart'; + +import '../../pod_player.dart'; +import '../utils/logger.dart'; +import '../utils/video_apis.dart'; +import 'pod_getx_video_controller.dart'; + +class PodPlayerController { + late PodGetXVideoController _ctr; + late String getTag; + bool _isCtrInitialised = false; + + Object? _initializationError; + + final PlayVideoFrom playVideoFrom; + final PodPlayerConfig podPlayerConfig; + + /// controller for pod player + PodPlayerController({ + required this.playVideoFrom, + this.podPlayerConfig = const PodPlayerConfig(), + }) { + _init(); + } + + void _init() { + getTag = UniqueKey().toString(); + Get.config(enableLog: PodVideoPlayer.enableGetxLogs); + _ctr = Get.put(PodGetXVideoController(), permanent: true, tag: getTag) + ..config( + playVideoFrom: playVideoFrom, + playerConfig: podPlayerConfig, + ); + } + + /// Initializes the video player. + /// + /// If the provided video cannot be loaded, an exception could be thrown. + Future initialise() async { + if (!_isCtrInitialised) { + _init(); + } + WidgetsBinding.instance.addPostFrameCallback((timeStamp) async { + try { + if (!_isCtrInitialised) { + await _ctr.videoInit(); + podLog('$getTag Pod player Initialized'); + } else { + podLog('$getTag Pod Player Controller Already Initialized'); + } + } catch (error) { + podLog('$getTag Pod Player Controller failed to initialize'); + _initializationError = error; + } + }); + await _checkAndWaitTillInitialized(); + } + + Future _checkAndWaitTillInitialized() async { + if (_ctr.controllerInitialized) { + _isCtrInitialised = true; + return; + } + + /// If a wrong video is passed to the player, it'll never being loaded. + if (_initializationError != null) { + if (_initializationError! is Exception) { + throw _initializationError! as Exception; + } + if (_initializationError! is Error) { + throw _initializationError! as Error; + } + throw Exception(_initializationError.toString()); + } + + await Future.delayed(const Duration(milliseconds: 500)); + await _checkAndWaitTillInitialized(); + } + + /// returns the url of current playing video + String? get videoUrl => _ctr.playingVideoUrl; + + /// returns true if video player is initialized + bool get isInitialised => _ctr.videoCtr?.value.isInitialized ?? false; + + /// returns true if video is playing + bool get isVideoPlaying => _ctr.videoCtr?.value.isPlaying ?? false; + + /// returns true if video is in buffering state + bool get isVideoBuffering => _ctr.videoCtr?.value.isBuffering ?? false; + + /// returns true if `loop` is enabled + bool get isVideoLooping => _ctr.videoCtr?.value.isLooping ?? false; + + /// returns true if video is in fullscreen mode + bool get isFullScreen => _ctr.isFullScreen; + + bool get isMute => _ctr.isMute; + + PodVideoState get videoState => _ctr.podVideoState; + + VideoPlayerValue? get videoPlayerValue => _ctr.videoCtr?.value; + + PodVideoPlayerType get videoPlayerType => _ctr.videoPlayerType; + + // Future initialize() async => _ctr.videoCtr?.initialize; + + //! video positions + + /// Returns the video total duration + Duration get totalVideoLength => _ctr.videoDuration; + + /// Returns the current position of the video + Duration get currentVideoPosition => _ctr.videoPosition; + + //! video play/pause + + /// plays the video + void play() => _ctr.podVideoStateChanger(PodVideoState.playing); + + /// pauses the video + void pause() => _ctr.podVideoStateChanger(PodVideoState.paused); + + /// toogle play and pause + void togglePlayPause() { + isVideoPlaying ? pause() : play(); + } + + /// Listen to changes in video. + /// + /// It only adds a listener if the player is successfully initialized + void addListener(VoidCallback listener) { + _checkAndWaitTillInitialized().then( + (value) => _ctr.videoCtr?.addListener(listener), + ); + } + + /// Remove registered listeners + void removeListener(VoidCallback listener) { + _checkAndWaitTillInitialized().then( + (value) => _ctr.videoCtr?.removeListener(listener), + ); + } + + //! volume Controllers + + /// mute the volume of the video + Future mute() async => _ctr.mute(); + + /// unmute the volume of the video + Future unMute() async => _ctr.unMute(); + + /// toggle the volume + Future toggleVolume() async { + _ctr.isMute ? await _ctr.unMute() : await _ctr.mute(); + } + + ///Dispose pod video player controller + void dispose() { + _isCtrInitialised = false; + _ctr.videoCtr?.removeListener(_ctr.videoListner); + _ctr.videoCtr?.dispose(); + _ctr.removeListenerId('podVideoState', _ctr.podStateListner); + if (podPlayerConfig.wakelockEnabled) WakelockPlus.disable(); + Get.delete( + force: true, + tag: getTag, + ); + podLog('$getTag Pod player Disposed'); + } + + /// used to change the video + Future changeVideo({ + required PlayVideoFrom playVideoFrom, + PodPlayerConfig playerConfig = const PodPlayerConfig(), + }) => + _ctr.changeVideo( + playVideoFrom: playVideoFrom, + playerConfig: playerConfig, + ); + + //Change double tap duration + void setDoubeTapForwarDuration(int seconds) => + _ctr.doubleTapForwardSeconds = seconds; + + ///Jumps to specific position of the video + Future videoSeekTo(Duration moment) async { + await _checkAndWaitTillInitialized(); + if (!_isCtrInitialised) return; + return _ctr.seekTo(moment); + } + + ///Moves video forward from current duration to `_duration` + Future videoSeekForward(Duration duration) async { + await _checkAndWaitTillInitialized(); + if (!_isCtrInitialised) return; + return _ctr.seekForward(duration); + } + + ///Moves video backward from current duration to `_duration` + Future videoSeekBackward(Duration duration) async { + await _checkAndWaitTillInitialized(); + if (!_isCtrInitialised) return; + return _ctr.seekBackward(duration); + } + + ///on right double tap + Future doubleTapVideoForward(int seconds) async { + await _checkAndWaitTillInitialized(); + if (!_isCtrInitialised) return; + return _ctr.onRightDoubleTap(seconds: seconds); + } + + ///on left double tap + Future doubleTapVideoBackward(int seconds) async { + await _checkAndWaitTillInitialized(); + if (!_isCtrInitialised) return; + return _ctr.onLeftDoubleTap(seconds: seconds); + } + + /// Enables video player to fullscreen mode. + /// + /// If onToggleFullScreen is set, you must handle the device + /// orientation by yourself. + void enableFullScreen() { + uni_html.document.documentElement?.requestFullscreen(); + _ctr.enableFullScreen(getTag); + } + + /// Disables fullscreen mode. + /// + /// If onToggleFullScreen is set, you must handle the device + /// orientation by yourself. + void disableFullScreen(BuildContext context) { + uni_html.document.exitFullscreen(); + + if (!_ctr.isWebPopupOverlayOpen) { + _ctr.disableFullScreen(context, getTag); + } + } + + /// listener for the changes in the quality of the video + void onVideoQualityChanged(VoidCallback callback) { + _ctr.onVimeoVideoQualityChanged = callback; + } + + static Future?> getYoutubeUrls( + String youtubeIdOrUrl, { + bool live = false, + }) { + return VideoApis.getYoutubeVideoQualityUrls(youtubeIdOrUrl, live); + } + + static Future?> getVimeoUrls( + String videoId, { + String? hash, + }) { + return VideoApis.getVimeoVideoQualityUrls(videoId, hash); + } + + /// Hide overlay of video + void hideOverlay() => _ctr.isShowOverlay(false); + + /// Show overlay of video + void showOverlay() => _ctr.isShowOverlay(true); +} diff --git a/example/packages/lib/src/controllers/pod_ui_controller.dart b/example/packages/lib/src/controllers/pod_ui_controller.dart new file mode 100644 index 00000000..7b19cf2a --- /dev/null +++ b/example/packages/lib/src/controllers/pod_ui_controller.dart @@ -0,0 +1,18 @@ +part of 'pod_getx_video_controller.dart'; + +class _PodUiController extends _PodBaseController { + bool alwaysShowProgressBar = true; + PodProgressBarConfig podProgressBarConfig = const PodProgressBarConfig(); + Widget Function(OverLayOptions options)? overlayBuilder; + Widget? videoTitle; + DecorationImage? videoThumbnail; + + /// Callback when fullscreen mode changes + Future Function(bool isFullScreen)? onToggleFullScreen; + + /// Builder for custom loading widget + WidgetBuilder? onLoading; + + ///video player labels + PodPlayerLabels podPlayerLabels = const PodPlayerLabels(); +} diff --git a/example/packages/lib/src/controllers/pod_video_controller.dart b/example/packages/lib/src/controllers/pod_video_controller.dart new file mode 100644 index 00000000..0a0d4ea6 --- /dev/null +++ b/example/packages/lib/src/controllers/pod_video_controller.dart @@ -0,0 +1,258 @@ +part of 'pod_getx_video_controller.dart'; + +class _PodVideoController extends _PodUiController { + Timer? showOverlayTimer; + Timer? showOverlayTimer1; + + bool isOverlayVisible = true; + bool isLooping = false; + bool isFullScreen = false; + bool isvideoPlaying = false; + + List videoPlaybackSpeeds = [ + '0.25x', + '0.5x', + '0.75x', + '1x', + '1.25x', + '1.5x', + '1.75x', + '2x', + ]; + + /// + + ///*seek video + /// Seek video to a duration. + Future seekTo(Duration moment) async { + await _videoCtr!.seekTo(moment); + } + + /// Seek video forward by the duration. + Future seekForward(Duration videoSeekDuration) async { + await seekTo(_videoCtr!.value.position + videoSeekDuration); + } + + /// Seek video backward by the duration. + Future seekBackward(Duration videoSeekDuration) async { + await seekTo(_videoCtr!.value.position - videoSeekDuration); + } + + ///mute + /// Toggle mute. + Future toggleMute() async { + isMute = !isMute; + if (isMute) { + await mute(); + } else { + await unMute(); + } + } + + Future mute() async { + await setVolume(0); + update(['volume']); + update(['update-all']); + } + + Future unMute() async { + await setVolume(1); + update(['volume']); + update(['update-all']); + } + +// Set volume between 0.0 - 1.0, + /// 0.0 is mute and 1.0 max volume. + Future setVolume( + double volume, + ) async { + await _videoCtr?.setVolume(volume); + if (volume <= 0) { + isMute = true; + } else { + isMute = false; + } + update(['volume']); + update(['update-all']); + } + + ///*controll play pause + Future playVideo(bool val) async { + isvideoPlaying = val; + if (isvideoPlaying) { + isShowOverlay(true); + // ignore: unawaited_futures + _videoCtr?.play(); + isShowOverlay(false, delay: const Duration(seconds: 1)); + } else { + isShowOverlay(true); + // ignore: unawaited_futures + _videoCtr?.pause(); + } + } + + ///toogle play pause + void togglePlayPauseVideo() { + isvideoPlaying = !isvideoPlaying; + podVideoStateChanger( + isvideoPlaying ? PodVideoState.playing : PodVideoState.paused, + ); + } + + ///toogle video player controls + void isShowOverlay(bool val, {Duration? delay}) { + showOverlayTimer1?.cancel(); + showOverlayTimer1 = Timer(delay ?? Duration.zero, () { + if (isOverlayVisible != val) { + isOverlayVisible = val; + update(['overlay']); + update(['update-all']); + } + }); + } + + ///overlay above video contrller + void toggleVideoOverlay() { + if (!isOverlayVisible) { + isOverlayVisible = true; + update(['overlay']); + update(['update-all']); + return; + } + if (isOverlayVisible) { + isOverlayVisible = false; + update(['overlay']); + update(['update-all']); + showOverlayTimer?.cancel(); + showOverlayTimer = Timer(const Duration(seconds: 3), () { + if (isOverlayVisible) { + isOverlayVisible = false; + update(['overlay']); + update(['update-all']); + } + }); + } + } + + void setVideoPlayBack(String speed) { + late double pickedSpeed; + + if (speed == 'Normal') { + pickedSpeed = 1.0; + _currentPaybackSpeed = 'Normal'; + } else { + pickedSpeed = double.parse(speed.split('x').first); + _currentPaybackSpeed = speed; + } + _videoCtr?.setPlaybackSpeed(pickedSpeed); + } + + Future setLooping(bool isLooped) async { + isLooping = isLooped; + await _videoCtr?.setLooping(isLooping); + } + + Future toggleLooping() async { + isLooping = !isLooping; + await _videoCtr?.setLooping(isLooping); + update(); + update(['update-all']); + } + + Future enableFullScreen(String tag) async { + podLog('-full-screen-enable-entred'); + if (!isFullScreen) { + if (onToggleFullScreen != null) { + await onToggleFullScreen!(true); + } else { + await Future.wait([ + SystemChrome.setPreferredOrientations( + [ + if (!kIsWeb) DeviceOrientation.landscapeLeft, + DeviceOrientation.landscapeRight, + ], + ), + SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky), + ]); + } + + _enableFullScreenView(tag); + isFullScreen = true; + WidgetsBinding.instance.addPostFrameCallback((timeStamp) { + update(['full-screen']); + update(['update-all']); + }); + } + } + + Future disableFullScreen( + BuildContext context, + String tag, { + bool enablePop = true, + }) async { + podLog('-full-screen-disable-entred'); + if (isFullScreen) { + if (onToggleFullScreen != null) { + await onToggleFullScreen!(false); + } else { + await Future.wait([ + SystemChrome.setPreferredOrientations([ + DeviceOrientation.portraitUp, + DeviceOrientation.portraitDown, + ]), + if (!(defaultTargetPlatform == TargetPlatform.iOS)) ...[ + SystemChrome.setPreferredOrientations(DeviceOrientation.values), + SystemChrome.setEnabledSystemUIMode( + SystemUiMode.manual, + overlays: SystemUiOverlay.values, + ), + ] + ]); + } + + if (enablePop) _exitFullScreenView(context, tag); + isFullScreen = false; + update(['full-screen']); + update(['update-all']); + } + } + + void _exitFullScreenView(BuildContext context, String tag) { + podLog('popped-full-screen'); + Navigator.of(fullScreenContext).pop(); + } + + void _enableFullScreenView(String tag) { + if (!isFullScreen) { + podLog('full-screen-enabled'); + + Navigator.push( + mainContext, + PageRouteBuilder( + fullscreenDialog: true, + pageBuilder: (BuildContext context, _, __) => FullScreenView( + tag: tag, + ), + reverseTransitionDuration: const Duration(milliseconds: 400), + transitionsBuilder: (context, animation, secondaryAnimation, child) => + FadeTransition( + opacity: animation, + child: child, + ), + ), + ); + } + } + + /// Calculates video `position` or `duration` + String calculateVideoDuration(Duration duration) { + final totalHour = duration.inHours == 0 ? '' : '${duration.inHours}:'; + final totalMinute = duration.toString().split(':')[1]; + final totalSeconds = (duration - Duration(minutes: duration.inMinutes)) + .inSeconds + .toString() + .padLeft(2, '0'); + final String videoLength = '$totalHour$totalMinute:$totalSeconds'; + return videoLength; + } +} diff --git a/example/packages/lib/src/controllers/pod_video_quality_controller.dart b/example/packages/lib/src/controllers/pod_video_quality_controller.dart new file mode 100644 index 00000000..a058e405 --- /dev/null +++ b/example/packages/lib/src/controllers/pod_video_quality_controller.dart @@ -0,0 +1,174 @@ +part of 'pod_getx_video_controller.dart'; + +class _PodVideoQualityController extends _PodVideoController { + /// + int? vimeoPlayingVideoQuality; + + ///vimeo all quality urls + List vimeoOrVideoUrls = []; + late String _videoQualityUrl; + + ///invokes callback from external controller + VoidCallback? onVimeoVideoQualityChanged; + + ///*vimeo player configs + /// + ///get all `quality urls` + Future getQualityUrlsFromVimeoId( + String videoId, { + String? hash, + int retryCount = 0, + }) async { + try { + podVideoStateChanger(PodVideoState.loading); + final vimeoVideoUrls = await VideoApis.getVimeoVideoQualityUrls( + videoId, + hash, + ); + + /// + vimeoOrVideoUrls = vimeoVideoUrls ?? []; + } catch (e) { + // Handle 410 Gone errors by retrying once + if (e is Vimeo410Exception && retryCount < 1) { + podLog('===== VIMEO 410 RETRY ====='); + podLog('Video ID: $videoId'); + podLog('Attempt: ${retryCount + 1}/1'); + podLog('Refreshing progressive links...'); + // Wait a short delay before retry + await Future.delayed(const Duration(milliseconds: 500)); + // Retry once to get fresh progressive links + return getQualityUrlsFromVimeoId( + videoId, + hash: hash, + retryCount: retryCount + 1, + ); + } + rethrow; + } + } + + Future getQualityUrlsFromVimeoPrivateId( + String videoId, + Map httpHeader, { + int retryCount = 0, + }) async { + try { + podVideoStateChanger(PodVideoState.loading); + final vimeoVideoUrls = + await VideoApis.getVimeoPrivateVideoQualityUrls(videoId, httpHeader); + + /// + vimeoOrVideoUrls = vimeoVideoUrls ?? []; + } catch (e) { + // Handle 410 Gone errors by retrying once + if (e is Vimeo410Exception && retryCount < 1) { + podLog('===== VIMEO PRIVATE API 410 RETRY ====='); + podLog('Video ID: $videoId'); + podLog('Attempt: ${retryCount + 1}/1'); + podLog('Refreshing progressive links...'); + // Wait a short delay before retry + await Future.delayed(const Duration(milliseconds: 500)); + // Retry once to get fresh progressive links + return getQualityUrlsFromVimeoPrivateId( + videoId, + httpHeader, + retryCount: retryCount + 1, + ); + } + rethrow; + } + } + + void sortQualityVideoUrls( + List? urls, + ) { + final urls0 = urls; + + ///has issues with 240p + urls0?.removeWhere((element) => element.quality == 240); + + ///has issues with 144p in web + if (kIsWeb) { + urls0?.removeWhere((element) => element.quality == 144); + } + + ///sort + urls0?.sort((a, b) => a.quality.compareTo(b.quality)); + + /// + vimeoOrVideoUrls = urls0 ?? []; + } + + ///get vimeo quality `ex: 1080p` url + VideoQalityUrls getQualityUrl(int quality) { + return vimeoOrVideoUrls.firstWhere( + (element) => element.quality == quality, + orElse: () => vimeoOrVideoUrls.first, + ); + } + + Future getUrlFromVideoQualityUrls({ + required List qualityList, + required List videoUrls, + }) async { + sortQualityVideoUrls(videoUrls); + if (vimeoOrVideoUrls.isEmpty) { + throw Exception('videoQuality cannot be empty'); + } + + final fallback = vimeoOrVideoUrls[0]; + VideoQalityUrls? urlWithQuality; + for (final quality in qualityList) { + urlWithQuality = vimeoOrVideoUrls.firstWhere( + (url) => url.quality == quality, + orElse: () => fallback, + ); + + if (urlWithQuality != fallback) { + break; + } + } + + urlWithQuality ??= fallback; + _videoQualityUrl = urlWithQuality.url; + vimeoPlayingVideoQuality = urlWithQuality.quality; + return _videoQualityUrl; + } + + Future> getVideoQualityUrlsFromYoutube( + String youtubeIdOrUrl, + bool live, + ) async { + return await VideoApis.getYoutubeVideoQualityUrls(youtubeIdOrUrl, live) ?? + []; + } + + Future changeVideoQuality(int? quality) async { + if (vimeoOrVideoUrls.isEmpty) { + throw Exception('videoQuality cannot be empty'); + } + if (vimeoPlayingVideoQuality != quality) { + _videoQualityUrl = vimeoOrVideoUrls + .where((element) => element.quality == quality) + .first + .url; + podLog(_videoQualityUrl); + vimeoPlayingVideoQuality = quality; + _videoCtr?.removeListener(videoListner); + podVideoStateChanger(PodVideoState.paused); + podVideoStateChanger(PodVideoState.loading); + playingVideoUrl = _videoQualityUrl; + _videoCtr = VideoPlayerController.networkUrl(Uri.parse(_videoQualityUrl)); + await _videoCtr?.initialize(); + _videoDuration = _videoCtr?.value.duration ?? Duration.zero; + _videoCtr?.addListener(videoListner); + await _videoCtr?.seekTo(_videoPosition); + setVideoPlayBack(_currentPaybackSpeed); + podVideoStateChanger(PodVideoState.playing); + onVimeoVideoQualityChanged?.call(); + update(); + update(['update-all']); + } + } +} diff --git a/example/packages/lib/src/models/overlay_options.dart b/example/packages/lib/src/models/overlay_options.dart new file mode 100644 index 00000000..e48b04d9 --- /dev/null +++ b/example/packages/lib/src/models/overlay_options.dart @@ -0,0 +1,30 @@ +import '../../pod_player.dart'; + +class OverLayOptions { + final PodVideoState podVideoState; + final Duration videoDuration; + final Duration videoPosition; + final bool isFullScreen; + final bool isLooping; + final bool isOverlayVisible; + final bool isMute; + final bool autoPlay; + final String currentVideoPlaybackSpeed; + final List videoPlayBackSpeeds; + final PodVideoPlayerType videoPlayerType; + final PodProgressBar podProgresssBar; + OverLayOptions({ + required this.podVideoState, + required this.videoDuration, + required this.videoPosition, + required this.isFullScreen, + required this.isLooping, + required this.isOverlayVisible, + required this.isMute, + required this.autoPlay, + required this.currentVideoPlaybackSpeed, + required this.videoPlayBackSpeeds, + required this.videoPlayerType, + required this.podProgresssBar, + }); +} diff --git a/example/packages/lib/src/models/play_video_from.dart b/example/packages/lib/src/models/play_video_from.dart new file mode 100644 index 00000000..d3927bbb --- /dev/null +++ b/example/packages/lib/src/models/play_video_from.dart @@ -0,0 +1,148 @@ +import 'dart:io'; + +import '../../pod_player.dart'; + +class PlayVideoFrom { + final String? dataSource; + final String? hash; + final PodVideoPlayerType playerType; + final VideoFormat? formatHint; + final String? package; + final File? file; + final List? videoQualityUrls; + final Future? closedCaptionFile; + final VideoPlayerOptions? videoPlayerOptions; + final Map httpHeaders; + final bool live; + + const PlayVideoFrom._({ + required this.playerType, + this.live = false, + this.dataSource, + this.hash, + this.formatHint, + this.package, + this.file, + this.videoQualityUrls, + this.closedCaptionFile, + this.videoPlayerOptions, + this.httpHeaders = const {}, + }); + + factory PlayVideoFrom.network( + String dataSource, { + VideoFormat? formatHint, + Future? closedCaptionFile, + VideoPlayerOptions? videoPlayerOptions, + Map httpHeaders = const {}, + }) { + return PlayVideoFrom._( + playerType: PodVideoPlayerType.network, + dataSource: dataSource, + formatHint: formatHint, + closedCaptionFile: closedCaptionFile, + videoPlayerOptions: videoPlayerOptions, + httpHeaders: httpHeaders, + ); + } + + factory PlayVideoFrom.asset( + String dataSource, { + String? package, + Future? closedCaptionFile, + VideoPlayerOptions? videoPlayerOptions, + }) { + return PlayVideoFrom._( + playerType: PodVideoPlayerType.asset, + dataSource: dataSource, + package: package, + closedCaptionFile: closedCaptionFile, + videoPlayerOptions: videoPlayerOptions, + ); + } + + ///File Doesnot support web apps + ///[file] is `File` Datatype import it from `dart:io` + factory PlayVideoFrom.file( + File file, { + Future? closedCaptionFile, + VideoPlayerOptions? videoPlayerOptions, + }) { + return PlayVideoFrom._( + file: file, + playerType: PodVideoPlayerType.file, + closedCaptionFile: closedCaptionFile, + videoPlayerOptions: videoPlayerOptions, + ); + } + + factory PlayVideoFrom.vimeo( + String dataSource, { + String? hash, + VideoFormat? formatHint, + Future? closedCaptionFile, + VideoPlayerOptions? videoPlayerOptions, + Map httpHeaders = const {}, + }) { + return PlayVideoFrom._( + playerType: PodVideoPlayerType.vimeo, + dataSource: dataSource, + hash: hash, + formatHint: formatHint, + closedCaptionFile: closedCaptionFile, + videoPlayerOptions: videoPlayerOptions, + httpHeaders: httpHeaders, + ); + } + + factory PlayVideoFrom.vimeoPrivateVideos( + String dataSource, { + VideoFormat? formatHint, + Future? closedCaptionFile, + VideoPlayerOptions? videoPlayerOptions, + Map httpHeaders = const {}, + }) { + return PlayVideoFrom._( + playerType: PodVideoPlayerType.vimeoPrivateVideos, + dataSource: dataSource, + formatHint: formatHint, + closedCaptionFile: closedCaptionFile, + videoPlayerOptions: videoPlayerOptions, + httpHeaders: httpHeaders, + ); + } + factory PlayVideoFrom.youtube( + String dataSource, { + bool live = false, + VideoFormat? formatHint, + Future? closedCaptionFile, + VideoPlayerOptions? videoPlayerOptions, + Map httpHeaders = const {}, + }) { + return PlayVideoFrom._( + live: live, + playerType: PodVideoPlayerType.youtube, + dataSource: dataSource, + formatHint: formatHint, + closedCaptionFile: closedCaptionFile, + videoPlayerOptions: videoPlayerOptions, + httpHeaders: httpHeaders, + ); + } + factory PlayVideoFrom.networkQualityUrls({ + required List videoUrls, + VideoFormat? formatHint, + Future? closedCaptionFile, + VideoPlayerOptions? videoPlayerOptions, + Map httpHeaders = const {}, + }) { + return PlayVideoFrom._( + playerType: PodVideoPlayerType.networkQualityUrls, + videoQualityUrls: videoUrls, + formatHint: formatHint, + closedCaptionFile: closedCaptionFile, + videoPlayerOptions: videoPlayerOptions, + httpHeaders: httpHeaders, + ); + } +} diff --git a/example/packages/lib/src/models/pod_player_config.dart b/example/packages/lib/src/models/pod_player_config.dart new file mode 100644 index 00000000..f44c7918 --- /dev/null +++ b/example/packages/lib/src/models/pod_player_config.dart @@ -0,0 +1,37 @@ +class PodPlayerConfig { + final bool autoPlay; + final bool isLooping; + final bool forcedVideoFocus; + final bool wakelockEnabled; + + /// Initial video quality priority. The first available option will be used, + /// from start to the end of this list. If all options informed are not + /// available or if nothing is provided, 360p is used. + /// + /// Default value is [1080, 720, 360] + final List videoQualityPriority; + + const PodPlayerConfig({ + this.autoPlay = true, + this.isLooping = false, + this.forcedVideoFocus = false, + this.wakelockEnabled = true, + this.videoQualityPriority = const [1080, 720, 360], + }); + + PodPlayerConfig copyWith({ + bool? autoPlay, + bool? isLooping, + bool? forcedVideoFocus, + bool? wakelockEnabled, + List? videoQualityPriority, + }) { + return PodPlayerConfig( + autoPlay: autoPlay ?? this.autoPlay, + isLooping: isLooping ?? this.isLooping, + forcedVideoFocus: forcedVideoFocus ?? this.forcedVideoFocus, + wakelockEnabled: wakelockEnabled ?? this.wakelockEnabled, + videoQualityPriority: videoQualityPriority ?? this.videoQualityPriority, + ); + } +} diff --git a/example/packages/lib/src/models/pod_player_labels.dart b/example/packages/lib/src/models/pod_player_labels.dart new file mode 100644 index 00000000..97678a57 --- /dev/null +++ b/example/packages/lib/src/models/pod_player_labels.dart @@ -0,0 +1,32 @@ +class PodPlayerLabels { + final String? play; + final String? pause; + final String? mute; + final String? unmute; + final String settings; + final String? fullscreen; + final String? exitFullScreen; + final String loopVideo; + final String playbackSpeed; + final String quality; + final String optionEnabled; + final String optionDisabled; + final String error; + + /// Labels displayed in the video player progress bar and when an error occurs + const PodPlayerLabels({ + this.play, + this.pause, + this.mute, + this.unmute, + this.settings = 'Settings', + this.fullscreen, + this.exitFullScreen, + this.loopVideo = 'Loop Video', + this.playbackSpeed = 'Playback speed', + this.error = 'Error while playing video', + this.quality = 'Quality', + this.optionEnabled = 'on', + this.optionDisabled = 'off', + }); +} diff --git a/example/packages/lib/src/models/pod_progress_bar_config.dart b/example/packages/lib/src/models/pod_progress_bar_config.dart new file mode 100644 index 00000000..dc6b9a87 --- /dev/null +++ b/example/packages/lib/src/models/pod_progress_bar_config.dart @@ -0,0 +1,123 @@ +import 'package:flutter/material.dart'; + +typedef GetProgressBarBackgroundPaint = Paint Function({ + double? width, + double? height, + double? circleHandlerRadius, +}); + +typedef GetProgressBarPlayedPaint = Paint Function({ + double? width, + double? height, + double? playedPart, + double? circleHandlerRadius, +}); + +typedef GetProgressBarBufferedPaint = Paint Function({ + double? width, + double? height, + double? playedPart, + double? circleHandlerRadius, + double? bufferedStart, + double? bufferedEnd, +}); + +typedef GetProgressBarHandlePaint = Paint Function({ + double? width, + double? height, + double? playedPart, + double? circleHandlerRadius, +}); + +class PodProgressBarConfig { + const PodProgressBarConfig({ + this.playingBarColor = Colors.red, + this.bufferedBarColor = const Color.fromRGBO(255, 255, 255, 0.38), + this.circleHandlerColor = Colors.red, + this.alwaysVisibleCircleHandler = false, + this.backgroundColor = const Color.fromRGBO(255, 255, 255, 0.24), + this.getPlayedPaint, + this.getBufferedPaint, + this.getCircleHandlerPaint, + this.getBackgroundPaint, + this.height = 3.6, + this.padding = EdgeInsets.zero, + this.circleHandlerRadius = 8, + this.curveRadius = 4, + }); + + /// Color for played area, not applied if [getPlayedPaint] is provided. + final Color playingBarColor; + + /// Color for buffered area, not applied if [getBufferedPaint] is provided. + final Color bufferedBarColor; + + /// Color for handle, not applied if [getCircleHandlerPaint] is provided. + final Color circleHandlerColor; + + final bool alwaysVisibleCircleHandler; + + /// Color for background area, not applied if [getBackgroundPaint] is provided. + final Color backgroundColor; + + /// Paint for played area. + final GetProgressBarPlayedPaint? getPlayedPaint; + + /// Paint for buffered area. + final GetProgressBarBufferedPaint? getBufferedPaint; + + /// Paint for handle. + final GetProgressBarHandlePaint? getCircleHandlerPaint; + + /// Paint for background area. + final GetProgressBarBackgroundPaint? getBackgroundPaint; + + /// Height of the progress bar. + final double height; + + /// Padding for the progress bar. + /// Padding area is included in the [GestureDetector]. + final EdgeInsetsGeometry padding; + + /// Handle radius. + /// Should be bigger then [height] so that handle is visible. + /// 0.0 will hide the handle. + final double circleHandlerRadius; + + /// Radius to curve the ends of the bar. + final double curveRadius; + + PodProgressBarConfig copyWith({ + Color? playingBarColor, + Color? bufferedBarColor, + Color? circleHandlerColor, + bool? alwaysVisibleCircleHandler, + Color? backgroundColor, + GetProgressBarPlayedPaint? getPlayedPaint, + GetProgressBarBufferedPaint? getBufferedPaint, + GetProgressBarHandlePaint? getCircleHandlerPaint, + GetProgressBarBackgroundPaint? getBackgroundPaint, + double? height, + EdgeInsetsGeometry? padding, + double? circleHandlerRadius, + double? curveRadius, + }) { + return PodProgressBarConfig( + playingBarColor: playingBarColor ?? this.playingBarColor, + bufferedBarColor: bufferedBarColor ?? this.bufferedBarColor, + circleHandlerColor: circleHandlerColor ?? this.circleHandlerColor, + alwaysVisibleCircleHandler: + alwaysVisibleCircleHandler ?? this.alwaysVisibleCircleHandler, + backgroundColor: backgroundColor ?? this.backgroundColor, + getPlayedPaint: getPlayedPaint ?? this.getPlayedPaint, + getBufferedPaint: getBufferedPaint ?? this.getBufferedPaint, + getCircleHandlerPaint: + getCircleHandlerPaint ?? this.getCircleHandlerPaint, + getBackgroundPaint: getBackgroundPaint ?? this.getBackgroundPaint, + height: height ?? this.height, + padding: padding ?? this.padding, + circleHandlerRadius: circleHandlerRadius ?? this.circleHandlerRadius, + curveRadius: curveRadius ?? this.curveRadius, + ); + } +} diff --git a/example/packages/lib/src/models/vimeo_models.dart b/example/packages/lib/src/models/vimeo_models.dart new file mode 100644 index 00000000..1dc7d6c3 --- /dev/null +++ b/example/packages/lib/src/models/vimeo_models.dart @@ -0,0 +1,11 @@ +class VideoQalityUrls { + int quality; + String url; + VideoQalityUrls({ + required this.quality, + required this.url, + }); + + @override + String toString() => 'VideoQalityUrls(quality: $quality, urls: $url)'; +} diff --git a/example/packages/lib/src/pod_player.dart b/example/packages/lib/src/pod_player.dart new file mode 100644 index 00000000..82f451f8 --- /dev/null +++ b/example/packages/lib/src/pod_player.dart @@ -0,0 +1,270 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:get/get.dart'; +import 'package:universal_html/html.dart' as uni_html; + +import '../pod_player.dart'; +import 'controllers/pod_getx_video_controller.dart'; +import 'utils/logger.dart'; +import 'widgets/double_tap_icon.dart'; +import 'widgets/material_icon_button.dart'; + +part 'widgets/animated_play_pause_icon.dart'; + +part 'widgets/core/overlays/mobile_bottomsheet.dart'; + +part 'widgets/core/overlays/mobile_overlay.dart'; + +part 'widgets/core/overlays/overlays.dart'; + +part 'widgets/core/overlays/web_dropdown_menu.dart'; + +part 'widgets/core/overlays/web_overlay.dart'; + +part 'widgets/core/pod_core_player.dart'; + +part 'widgets/core/video_gesture_detector.dart'; + +part 'widgets/full_screen_view.dart'; + +class PodVideoPlayer extends StatefulWidget { + final PodPlayerController controller; + final double frameAspectRatio; + final double videoAspectRatio; + final bool alwaysShowProgressBar; + final bool matchVideoAspectRatioToFrame; + final bool matchFrameAspectRatioToVideo; + final PodProgressBarConfig podProgressBarConfig; + final PodPlayerLabels podPlayerLabels; + final Widget Function(OverLayOptions options)? overlayBuilder; + final Widget Function()? onVideoError; + final Widget? videoTitle; + final Color? backgroundColor; + final DecorationImage? videoThumbnail; + + /// Optional callback, fired when full screen mode toggles. + /// + /// Important: If this method is set, the configuration of [DeviceOrientation] + /// and [SystemUiMode] is up to you. + final Future Function(bool isFullScreen)? onToggleFullScreen; + + /// Sets a custom loading widget. + /// If no widget is informed, a default [CircularProgressIndicator] will be shown. + final WidgetBuilder? onLoading; + + PodVideoPlayer({ + required this.controller, + super.key, + this.frameAspectRatio = 16 / 9, + this.videoAspectRatio = 16 / 9, + this.alwaysShowProgressBar = true, + this.podProgressBarConfig = const PodProgressBarConfig(), + this.podPlayerLabels = const PodPlayerLabels(), + this.overlayBuilder, + this.videoTitle, + this.matchVideoAspectRatioToFrame = false, + this.matchFrameAspectRatioToVideo = false, + this.onVideoError, + this.backgroundColor, + this.videoThumbnail, + this.onToggleFullScreen, + this.onLoading, + }) { + addToUiController(); + } + + static bool enableLogs = false; + static bool enableGetxLogs = false; + + void addToUiController() { + Get.find(tag: controller.getTag) + + ///add to ui controller + ..podPlayerLabels = podPlayerLabels + ..alwaysShowProgressBar = alwaysShowProgressBar + ..podProgressBarConfig = podProgressBarConfig + ..overlayBuilder = overlayBuilder + ..videoTitle = videoTitle + ..onToggleFullScreen = onToggleFullScreen + ..onLoading = onLoading + ..videoThumbnail = videoThumbnail; + } + + @override + State createState() => _PodVideoPlayerState(); +} + +class _PodVideoPlayerState extends State + with TickerProviderStateMixin { + late PodGetXVideoController _podCtr; + + // late String tag; + @override + void initState() { + super.initState(); + // tag = widget.controller?.tag ?? UniqueKey().toString(); + _podCtr = Get.put( + PodGetXVideoController(), + permanent: true, + tag: widget.controller.getTag, + )..isVideoUiBinded = true; + if (_podCtr.wasVideoPlayingOnUiDispose ?? false) { + _podCtr.podVideoStateChanger(PodVideoState.playing, updateUi: false); + } + if (kIsWeb) { + if (widget.controller.podPlayerConfig.forcedVideoFocus) { + _podCtr.keyboardFocusWeb = FocusNode(); + _podCtr.keyboardFocusWeb?.addListener(_podCtr.keyboadListner); + } + //to disable mouse right click + uni_html.document.onContextMenu.listen((event) => event.preventDefault()); + } + } + + @override + void dispose() { + super.dispose(); + + ///Checking if the video was playing when this widget is disposed + if (_podCtr.isvideoPlaying) { + _podCtr.wasVideoPlayingOnUiDispose = true; + } else { + _podCtr.wasVideoPlayingOnUiDispose = false; + } + _podCtr + ..isVideoUiBinded = false + ..podVideoStateChanger(PodVideoState.paused, updateUi: false); + if (kIsWeb) { + _podCtr.keyboardFocusWeb?.removeListener(_podCtr.keyboadListner); + } + // _podCtr.keyboardFocus?.unfocus(); + // _podCtr.keyboardFocusOnFullScreen?.unfocus(); + _podCtr.hoverOverlayTimer?.cancel(); + _podCtr.showOverlayTimer?.cancel(); + _podCtr.showOverlayTimer1?.cancel(); + _podCtr.leftDoubleTapTimer?.cancel(); + _podCtr.rightDoubleTapTimer?.cancel(); + podLog('local PodVideoPlayer disposed'); + } + + /// + double _frameAspectRatio = 16 / 9; + + @override + Widget build(BuildContext context) { + final circularProgressIndicator = _thumbnailAndLoadingWidget(); + _podCtr.mainContext = context; + + final videoErrorWidget = AspectRatio( + aspectRatio: _frameAspectRatio, + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.warning, + color: Colors.yellow, + size: 32, + ), + const SizedBox(height: 20), + Text( + widget.podPlayerLabels.error, + style: const TextStyle(color: Colors.red), + ), + ], + ), + ), + ); + return GetBuilder( + tag: widget.controller.getTag, + builder: (_) { + _frameAspectRatio = widget.matchFrameAspectRatioToVideo + ? _podCtr.videoCtr?.value.aspectRatio ?? widget.frameAspectRatio + : widget.frameAspectRatio; + return Center( + child: ColoredBox( + color: widget.backgroundColor ?? Colors.black, + child: GetBuilder( + tag: widget.controller.getTag, + id: 'errorState', + builder: (podCtr) { + /// Check if has any error + if (podCtr.podVideoState == PodVideoState.error) { + return widget.onVideoError?.call() ?? videoErrorWidget; + } + + return AspectRatio( + aspectRatio: _frameAspectRatio, + child: podCtr.videoCtr?.value.isInitialized ?? false + ? _buildPlayer() + : Center(child: circularProgressIndicator), + ); + }, + ), + ), + ); + }, + ); + } + + Widget _buildLoading() { + return widget.onLoading?.call(context) ?? + const CircularProgressIndicator( + backgroundColor: Colors.black87, + color: Colors.white, + strokeWidth: 2, + ); + } + + Widget _thumbnailAndLoadingWidget() { + if (widget.videoThumbnail == null) { + return _buildLoading(); + } + + return SizedBox.expand( + child: TweenAnimationBuilder( + builder: (context, value, child) => Opacity( + opacity: value, + child: child, + ), + tween: Tween(begin: 0.2, end: 0.7), + duration: const Duration(milliseconds: 400), + child: DecoratedBox( + decoration: BoxDecoration(image: widget.videoThumbnail), + child: Center( + child: _buildLoading(), + ), + ), + ), + ); + } + + Widget _buildPlayer() { + final videoAspectRatio = widget.matchVideoAspectRatioToFrame + ? _podCtr.videoCtr?.value.aspectRatio ?? widget.videoAspectRatio + : widget.videoAspectRatio; + if (kIsWeb) { + return GetBuilder( + tag: widget.controller.getTag, + id: 'full-screen', + builder: (podCtr) { + if (podCtr.isFullScreen) return _thumbnailAndLoadingWidget(); + return _PodCoreVideoPlayer( + videoPlayerCtr: podCtr.videoCtr!, + videoAspectRatio: videoAspectRatio, + tag: widget.controller.getTag, + ); + }, + ); + } else { + return _PodCoreVideoPlayer( + videoPlayerCtr: _podCtr.videoCtr!, + videoAspectRatio: videoAspectRatio, + tag: widget.controller.getTag, + ); + } + } +} diff --git a/example/packages/lib/src/utils/enums.dart b/example/packages/lib/src/utils/enums.dart new file mode 100644 index 00000000..905c6e95 --- /dev/null +++ b/example/packages/lib/src/utils/enums.dart @@ -0,0 +1,16 @@ +enum PodVideoState { + loading, + playing, + paused, + error, +} + +enum PodVideoPlayerType { + network, + networkQualityUrls, + file, + asset, + vimeo, + youtube, + vimeoPrivateVideos, +} diff --git a/example/packages/lib/src/utils/logger.dart b/example/packages/lib/src/utils/logger.dart new file mode 100644 index 00000000..b8cc1c47 --- /dev/null +++ b/example/packages/lib/src/utils/logger.dart @@ -0,0 +1,6 @@ +import 'dart:developer'; + +import '../../pod_player.dart'; + +void podLog(String message) => + PodVideoPlayer.enableLogs ? log(message, name: 'POD') : null; diff --git a/example/packages/lib/src/utils/video_apis.dart b/example/packages/lib/src/utils/video_apis.dart new file mode 100644 index 00000000..0f30b26c --- /dev/null +++ b/example/packages/lib/src/utils/video_apis.dart @@ -0,0 +1,251 @@ +import 'dart:convert'; +import 'dart:developer'; + +import 'package:flutter/material.dart'; +import 'package:http/http.dart' as http; +import 'package:http/http.dart'; +import 'package:youtube_explode_dart/youtube_explode_dart.dart'; +import '../models/vimeo_models.dart'; +import 'vimeo_410_exception.dart'; + +String podErrorString(String val) { + return '*\n------error------\n\n$val\n\n------end------\n*'; +} + +class VideoApis { + static Future _makeRequestHash(String videoId, String? hash) { + if (hash == null) { + return http.get( + Uri.parse('https://player.vimeo.com/video/$videoId/config'), + ); + } else { + return http.get( + Uri.parse('https://player.vimeo.com/video/$videoId/config?h=$hash'), + ); + } + } + + static Future?> getVimeoVideoQualityUrls( + String videoId, + String? hash, + ) async { + try { + final response = await _makeRequestHash(videoId, hash); + + // Check for 410 Gone status + if (response.statusCode == 410) { + debugPrint('===== VIMEO API: Received 410 Gone status ====='); + debugPrint('Video ID: $videoId'); + debugPrint('This indicates the progressive links have expired and need to be refreshed.'); + throw Vimeo410Exception( + message: 'Progressive links expired', + videoId: videoId, + hash: hash, + ); + } + + // Check for 403 Forbidden status + if (response.statusCode == 403) { + debugPrint('===== VIMEO API: Received 403 Forbidden status ====='); + debugPrint('Video ID: $videoId'); + debugPrint('This video may be private, restricted, or authentication is invalid.'); + if (response.body.contains('Sorry')) { + debugPrint('Vimeo returned a "Sorry" page - video access denied.'); + } + throw Exception('Vimeo API returned 403 Forbidden - video access denied (Video ID: $videoId)'); + } + + // Check for HTML response instead of JSON + if (response.body.trim().startsWith(' 500) { + debugPrint('HTML Response (first 500 chars): ${response.body.substring(0, 500)}...'); + } else { + debugPrint('HTML Response: ${response.body}'); + } + debugPrint('===== End of HTML Response ====='); + throw FormatException('Vimeo API returned HTML instead of JSON (Status: ${response.statusCode})'); + } + + final jsonData = jsonDecode(response.body)['request']['files']; + final dashData = jsonData['dash']; + final hlsData = jsonData['hls']; + final defaultCDN = hlsData['default_cdn']; + final cdnVideoUrl = (hlsData['cdns'][defaultCDN]['url'] as String?) ?? ''; + final List rawStreamUrls = + (dashData['streams'] as List?) ?? []; + + final List vimeoQualityUrls = []; + + for (final item in rawStreamUrls) { + final sepList = cdnVideoUrl.split('/sep/video/'); + final firstUrlPiece = sepList.firstOrNull ?? ''; + final lastUrlPiece = + ((sepList.lastOrNull ?? '').split('/').lastOrNull) ?? + (sepList.lastOrNull ?? ''); + final String urlId = + ((item['id'] ?? '') as String).split('-').firstOrNull ?? ''; + vimeoQualityUrls.add( + VideoQalityUrls( + quality: int.parse( + (item['quality'] as String?)?.split('p').first ?? '0', + ), + url: '$firstUrlPiece/sep/video/$urlId/$lastUrlPiece', + ), + ); + } + if (vimeoQualityUrls.isEmpty) { + vimeoQualityUrls.add( + VideoQalityUrls( + quality: 720, + url: cdnVideoUrl, + ), + ); + } + + return vimeoQualityUrls; + } catch (error) { + if (error.toString().contains('XMLHttpRequest')) { + log( + podErrorString( + '(INFO) To play vimeo video in WEB, Please enable CORS in your browser', + ), + ); + } + debugPrint('===== VIMEO API ERROR ====='); + debugPrint('Error Type: ${error.runtimeType}'); + debugPrint('Error Message: $error'); + debugPrint('===== End of Error ====='); + rethrow; + } + } + + static Future?> getVimeoPrivateVideoQualityUrls( + String videoId, + Map httpHeader, + ) async { + try { + final response = await http.get( + Uri.parse('https://api.vimeo.com/videos/$videoId'), + headers: httpHeader, + ); + + // Check for 410 Gone status + if (response.statusCode == 410) { + debugPrint('===== VIMEO PRIVATE API: Received 410 Gone status ====='); + debugPrint('Video ID: $videoId'); + debugPrint('This indicates the progressive links have expired and need to be refreshed.'); + throw Vimeo410Exception( + message: 'Progressive links expired', + videoId: videoId, + ); + } + + // Check for 403 Forbidden status + if (response.statusCode == 403) { + debugPrint('===== VIMEO PRIVATE API: Received 403 Forbidden status ====='); + debugPrint('Video ID: $videoId'); + debugPrint('This video may be private, restricted, or authentication is invalid.'); + if (response.body.contains('Sorry')) { + debugPrint('Vimeo returned a "Sorry" page - video access denied.'); + } + throw Exception('Vimeo Private API returned 403 Forbidden - video access denied (Video ID: $videoId)'); + } + + // Check for HTML response instead of JSON + if (response.body.trim().startsWith(' 500) { + debugPrint('HTML Response (first 500 chars): ${response.body.substring(0, 500)}...'); + } else { + debugPrint('HTML Response: ${response.body}'); + } + debugPrint('===== End of HTML Response ====='); + throw FormatException('Vimeo Private API returned HTML instead of JSON (Status: ${response.statusCode})'); + } + + final jsonData = + (jsonDecode(response.body)['files'] as List?) ?? []; + + final List list = []; + for (int i = 0; i < jsonData.length; i++) { + final String quality = + (jsonData[i]['rendition'] as String?)?.split('p').first ?? '0'; + final int? number = int.tryParse(quality); + if (number != null && number != 0) { + list.add( + VideoQalityUrls( + quality: number, + url: jsonData[i]['link'] as String, + ), + ); + } + } + return list; + } catch (error) { + if (error.toString().contains('XMLHttpRequest')) { + log( + podErrorString( + '(INFO) To play vimeo video in WEB, Please enable CORS in your browser', + ), + ); + } + debugPrint('===== VIMEO PRIVATE API ERROR ====='); + debugPrint('Error Type: ${error.runtimeType}'); + debugPrint('Error Message: $error'); + debugPrint('===== End of Error ====='); + rethrow; + } + } + + static Future?> getYoutubeVideoQualityUrls( + String youtubeIdOrUrl, + bool live, + ) async { + try { + final yt = YoutubeExplode(); + final urls = []; + if (live) { + final url = await yt.videos.streamsClient.getHttpLiveStreamUrl( + VideoId(youtubeIdOrUrl), + ); + urls.add( + VideoQalityUrls( + quality: 360, + url: url, + ), + ); + } else { + final manifest = + await yt.videos.streamsClient.getManifest(youtubeIdOrUrl); + urls.addAll( + manifest.muxed.map( + (element) => VideoQalityUrls( + quality: int.parse(element.qualityLabel.split('p')[0]), + url: element.url.toString(), + ), + ), + ); + } + // Close the YoutubeExplode's http client. + yt.close(); + return urls; + } catch (error) { + if (error.toString().contains('XMLHttpRequest')) { + log( + podErrorString( + '(INFO) To play youtube video in WEB, Please enable CORS in your browser', + ), + ); + } + debugPrint('===== YOUTUBE API ERROR: $error =========='); + rethrow; + } + } +} diff --git a/example/packages/lib/src/utils/vimeo_410_exception.dart b/example/packages/lib/src/utils/vimeo_410_exception.dart new file mode 100644 index 00000000..994d339e --- /dev/null +++ b/example/packages/lib/src/utils/vimeo_410_exception.dart @@ -0,0 +1,15 @@ +/// Custom exception for Vimeo 410 Gone errors +class Vimeo410Exception implements Exception { + final String message; + final String videoId; + final String? hash; + + Vimeo410Exception({ + required this.message, + required this.videoId, + this.hash, + }); + + @override + String toString() => 'Vimeo410Exception: $message (videoId: $videoId)'; +} \ No newline at end of file diff --git a/example/packages/lib/src/widgets/animated_play_pause_icon.dart b/example/packages/lib/src/widgets/animated_play_pause_icon.dart new file mode 100644 index 00000000..0aabdd3a --- /dev/null +++ b/example/packages/lib/src/widgets/animated_play_pause_icon.dart @@ -0,0 +1,93 @@ +part of 'package:pod_player/src/pod_player.dart'; + +class _AnimatedPlayPauseIcon extends StatefulWidget { + final double? size; + final String tag; + + const _AnimatedPlayPauseIcon({ + required this.tag, + this.size, + }); + + @override + State<_AnimatedPlayPauseIcon> createState() => _AnimatedPlayPauseIconState(); +} + +class _AnimatedPlayPauseIconState extends State<_AnimatedPlayPauseIcon> + with SingleTickerProviderStateMixin { + late final AnimationController _payCtr; + late PodGetXVideoController _podCtr; + @override + void initState() { + _podCtr = Get.find(tag: widget.tag); + _payCtr = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 450), + ); + _podCtr.addListenerId('podVideoState', playPauseListner); + if (_podCtr.isvideoPlaying) { + if (mounted) _payCtr.forward(); + } + super.initState(); + } + + void playPauseListner() { + WidgetsBinding.instance.addPostFrameCallback((timeStamp) { + if (_podCtr.podVideoState == PodVideoState.playing) { + if (mounted) _payCtr.forward(); + } + if (_podCtr.podVideoState == PodVideoState.paused) { + if (mounted) _payCtr.reverse(); + } + }); + } + + @override + void dispose() { + // podLog('Play-pause-controller-disposed'); + _payCtr.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return GetBuilder( + tag: widget.tag, + id: 'overlay', + builder: (podCtr) { + return GetBuilder( + tag: widget.tag, + id: 'podVideoState', + builder: (f) => MaterialIconButton( + toolTipMesg: f.isvideoPlaying + ? podCtr.podPlayerLabels.pause ?? + 'Pause${kIsWeb ? ' (space)' : ''}' + : podCtr.podPlayerLabels.play ?? + 'Play${kIsWeb ? ' (space)' : ''}', + onPressed: + podCtr.isOverlayVisible ? podCtr.togglePlayPauseVideo : null, + child: onStateChange(podCtr), + ), + ); + }, + ); + } + + Widget onStateChange(PodGetXVideoController podCtr) { + if (kIsWeb) return _playPause(podCtr); + if (podCtr.podVideoState == PodVideoState.loading) { + return const SizedBox(); + } else { + return _playPause(podCtr); + } + } + + Widget _playPause(PodGetXVideoController podCtr) { + return AnimatedIcon( + icon: AnimatedIcons.play_pause, + progress: _payCtr, + color: Colors.white, + size: widget.size, + ); + } +} diff --git a/example/packages/lib/src/widgets/core/overlays/mobile_bottomsheet.dart b/example/packages/lib/src/widgets/core/overlays/mobile_bottomsheet.dart new file mode 100644 index 00000000..5cf988e9 --- /dev/null +++ b/example/packages/lib/src/widgets/core/overlays/mobile_bottomsheet.dart @@ -0,0 +1,279 @@ +part of 'package:pod_player/src/pod_player.dart'; + +class _MobileBottomSheet extends StatelessWidget { + final String tag; + + const _MobileBottomSheet({ + required this.tag, + }); + + @override + Widget build(BuildContext context) { + return GetBuilder( + tag: tag, + builder: (podCtr) => Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (podCtr.vimeoOrVideoUrls.isNotEmpty) + _bottomSheetTiles( + title: podCtr.podPlayerLabels.quality, + icon: Icons.video_settings_rounded, + subText: '${podCtr.vimeoPlayingVideoQuality}p', + onTap: () { + Navigator.of(context).pop(); + Timer(const Duration(milliseconds: 100), () { + showModalBottomSheet( + context: context, + builder: (context) => SafeArea( + child: _VideoQualitySelectorMob( + tag: tag, + onTap: null, + ), + ), + ); + }); + // await Future.delayed( + // const Duration(milliseconds: 100), + // ); + }, + ), + _bottomSheetTiles( + title: podCtr.podPlayerLabels.loopVideo, + icon: Icons.loop_rounded, + subText: podCtr.isLooping + ? podCtr.podPlayerLabels.optionEnabled + : podCtr.podPlayerLabels.optionDisabled, + onTap: () { + Navigator.of(context).pop(); + podCtr.toggleLooping(); + }, + ), + _bottomSheetTiles( + title: podCtr.podPlayerLabels.playbackSpeed, + icon: Icons.slow_motion_video_rounded, + subText: podCtr.currentPaybackSpeed, + onTap: () { + Navigator.of(context).pop(); + Timer(const Duration(milliseconds: 100), () { + showModalBottomSheet( + context: context, + isScrollControlled: true, + builder: (context) => SafeArea( + child: _VideoPlaybackSelectorMob( + tag: tag, + onTap: null, + ), + ), + ); + }); + }, + ), + ], + ), + ); + } + + ListTile _bottomSheetTiles({ + required String title, + required IconData icon, + String? subText, + void Function()? onTap, + }) { + return ListTile( + leading: Icon(icon), + onTap: onTap, + title: FittedBox( + fit: BoxFit.scaleDown, + alignment: Alignment.centerLeft, + child: Row( + children: [ + Text( + title, + ), + if (subText != null) const SizedBox(width: 6), + if (subText != null) + const SizedBox( + height: 4, + width: 4, + child: DecoratedBox( + decoration: BoxDecoration( + color: Colors.grey, + shape: BoxShape.circle, + ), + ), + ), + if (subText != null) const SizedBox(width: 6), + if (subText != null) + Text( + subText, + style: const TextStyle(color: Colors.grey), + ), + ], + ), + ), + ); + } +} + +class _VideoQualitySelectorMob extends StatelessWidget { + final void Function()? onTap; + final String tag; + + const _VideoQualitySelectorMob({ + required this.onTap, + required this.tag, + }); + + @override + Widget build(BuildContext context) { + final podCtr = Get.find(tag: tag); + return SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: podCtr.vimeoOrVideoUrls + .map( + (e) => ListTile( + title: Text('${e.quality}p'), + onTap: () { + onTap != null ? onTap!() : Navigator.of(context).pop(); + + podCtr.changeVideoQuality(e.quality); + }, + ), + ) + .toList(), + ), + ); + } +} + +class _VideoPlaybackSelectorMob extends StatelessWidget { + final void Function()? onTap; + final String tag; + + const _VideoPlaybackSelectorMob({ + required this.onTap, + required this.tag, + }); + + @override + Widget build(BuildContext context) { + final podCtr = Get.find(tag: tag); + return SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: podCtr.videoPlaybackSpeeds + .map( + (e) => ListTile( + title: Text(e), + onTap: () { + onTap != null ? onTap!() : Navigator.of(context).pop(); + podCtr.setVideoPlayBack(e); + }, + ), + ) + .toList(), + ), + ); + } +} + +class _MobileOverlayBottomControlles extends StatelessWidget { + final String tag; + + const _MobileOverlayBottomControlles({ + required this.tag, + }); + + @override + Widget build(BuildContext context) { + const durationTextStyle = TextStyle(color: Colors.white70); + const itemColor = Colors.white; + + return GetBuilder( + tag: tag, + id: 'full-screen', + builder: (podCtr) => Column( + mainAxisSize: MainAxisSize.min, + children: [ + Row( + children: [ + const SizedBox(width: 12), + GetBuilder( + tag: tag, + id: 'video-progress', + builder: (podCtr) { + return Row( + children: [ + Text( + podCtr.calculateVideoDuration(podCtr.videoPosition), + style: const TextStyle(color: itemColor), + ), + const Text( + ' / ', + style: durationTextStyle, + ), + Text( + podCtr.calculateVideoDuration(podCtr.videoDuration), + style: durationTextStyle, + ), + ], + ); + }, + ), + const Spacer(), + MaterialIconButton( + toolTipMesg: podCtr.isFullScreen + ? podCtr.podPlayerLabels.exitFullScreen ?? + 'Exit full screen${kIsWeb ? ' (f)' : ''}' + : podCtr.podPlayerLabels.fullscreen ?? + 'Fullscreen${kIsWeb ? ' (f)' : ''}', + color: itemColor, + onPressed: () { + if (podCtr.isOverlayVisible) { + if (podCtr.isFullScreen) { + podCtr.disableFullScreen(context, tag); + } else { + podCtr.enableFullScreen(tag); + } + } else { + podCtr.toggleVideoOverlay(); + } + }, + child: Icon( + podCtr.isFullScreen + ? Icons.fullscreen_exit + : Icons.fullscreen, + ), + ), + ], + ), + GetBuilder( + tag: tag, + id: 'overlay', + builder: (podCtr) { + if (podCtr.isFullScreen) { + return Padding( + padding: const EdgeInsets.fromLTRB(12, 0, 12, 20), + child: Visibility( + visible: podCtr.isOverlayVisible, + child: PodProgressBar( + tag: tag, + alignment: Alignment.topCenter, + podProgressBarConfig: podCtr.podProgressBarConfig, + ), + ), + ); + } + return PodProgressBar( + tag: tag, + alignment: Alignment.bottomCenter, + podProgressBarConfig: podCtr.podProgressBarConfig, + ); + }, + ), + ], + ), + ); + } +} diff --git a/example/packages/lib/src/widgets/core/overlays/mobile_overlay.dart b/example/packages/lib/src/widgets/core/overlays/mobile_overlay.dart new file mode 100644 index 00000000..1afe558d --- /dev/null +++ b/example/packages/lib/src/widgets/core/overlays/mobile_overlay.dart @@ -0,0 +1,113 @@ +part of 'package:pod_player/src/pod_player.dart'; + +class _MobileOverlay extends StatelessWidget { + final String tag; + + const _MobileOverlay({ + required this.tag, + }); + + @override + Widget build(BuildContext context) { + const overlayColor = Colors.black38; + const itemColor = Colors.white; + final podCtr = Get.find(tag: tag); + return Stack( + alignment: Alignment.center, + children: [ + _VideoGestureDetector( + tag: tag, + child: ColoredBox( + color: overlayColor, + child: Row( + children: [ + Expanded( + child: DoubleTapIcon( + tag: tag, + isForward: false, + height: double.maxFinite, + onDoubleTap: _isRtl() + ? podCtr.onRightDoubleTap + : podCtr.onLeftDoubleTap, + ), + ), + SizedBox( + height: double.infinity, + child: Center( + child: _AnimatedPlayPauseIcon(tag: tag, size: 42), + ), + ), + Expanded( + child: DoubleTapIcon( + isForward: true, + tag: tag, + height: double.maxFinite, + onDoubleTap: _isRtl() + ? podCtr.onLeftDoubleTap + : podCtr.onRightDoubleTap, + ), + ), + ], + ), + ), + ), + Align( + alignment: Alignment.topCenter, + child: Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + Expanded( + child: IgnorePointer( + child: podCtr.videoTitle ?? const SizedBox(), + ), + ), + MaterialIconButton( + toolTipMesg: podCtr.podPlayerLabels.settings, + color: itemColor, + onPressed: () { + if (podCtr.isOverlayVisible) { + _bottomSheet(context); + } else { + podCtr.toggleVideoOverlay(); + } + }, + child: const Icon( + Icons.more_vert_rounded, + ), + ), + ], + ), + ), + Align( + alignment: Alignment.bottomLeft, + child: _MobileOverlayBottomControlles(tag: tag), + ), + ], + ); + } + + bool _isRtl() { + final Locale locale = WidgetsBinding.instance.platformDispatcher.locale; + final langs = [ + 'ar', // Arabic + 'fa', // Farsi + 'he', // Hebrew + 'ps', // Pashto + 'ur', // Urdu + ]; + for (int i = 0; i < langs.length; i++) { + final lang = langs[i]; + if (locale.toString().contains(lang)) { + return true; + } + } + return false; + } + + void _bottomSheet(BuildContext context) { + showModalBottomSheet( + context: context, + builder: (context) => SafeArea(child: _MobileBottomSheet(tag: tag)), + ); + } +} diff --git a/example/packages/lib/src/widgets/core/overlays/overlays.dart b/example/packages/lib/src/widgets/core/overlays/overlays.dart new file mode 100644 index 00000000..d634c609 --- /dev/null +++ b/example/packages/lib/src/widgets/core/overlays/overlays.dart @@ -0,0 +1,64 @@ +part of 'package:pod_player/src/pod_player.dart'; + +class _VideoOverlays extends StatelessWidget { + final String tag; + + const _VideoOverlays({ + required this.tag, + }); + + @override + Widget build(BuildContext context) { + final podCtr = Get.find(tag: tag); + if (podCtr.overlayBuilder != null) { + return GetBuilder( + id: 'update-all', + tag: tag, + builder: (podCtr) { + ///Custom overlay + final progressBar = PodProgressBar( + tag: tag, + podProgressBarConfig: podCtr.podProgressBarConfig, + ); + final overlayOptions = OverLayOptions( + podVideoState: podCtr.podVideoState, + videoDuration: podCtr.videoDuration, + videoPosition: podCtr.videoPosition, + isFullScreen: podCtr.isFullScreen, + isLooping: podCtr.isLooping, + isOverlayVisible: podCtr.isOverlayVisible, + isMute: podCtr.isMute, + autoPlay: podCtr.autoPlay, + currentVideoPlaybackSpeed: podCtr.currentPaybackSpeed, + videoPlayBackSpeeds: podCtr.videoPlaybackSpeeds, + videoPlayerType: podCtr.videoPlayerType, + podProgresssBar: progressBar, + ); + + /// Returns the custom overlay, otherwise returns the default + /// overlay with gesture detector + return podCtr.overlayBuilder!(overlayOptions); + }, + ); + } else { + ///Built in overlay + return GetBuilder( + tag: tag, + id: 'overlay', + builder: (podCtr) { + return AnimatedOpacity( + duration: const Duration(milliseconds: 200), + opacity: podCtr.isOverlayVisible ? 1 : 0, + child: Stack( + fit: StackFit.passthrough, + children: [ + if (!kIsWeb) _MobileOverlay(tag: tag), + if (kIsWeb) _WebOverlay(tag: tag), + ], + ), + ); + }, + ); + } + } +} diff --git a/example/packages/lib/src/widgets/core/overlays/web_dropdown_menu.dart b/example/packages/lib/src/widgets/core/overlays/web_dropdown_menu.dart new file mode 100644 index 00000000..78965f89 --- /dev/null +++ b/example/packages/lib/src/widgets/core/overlays/web_dropdown_menu.dart @@ -0,0 +1,191 @@ +part of 'package:pod_player/src/pod_player.dart'; + +class _WebSettingsDropdown extends StatefulWidget { + final String tag; + + const _WebSettingsDropdown({ + required this.tag, + }); + + @override + State<_WebSettingsDropdown> createState() => _WebSettingsDropdownState(); +} + +class _WebSettingsDropdownState extends State<_WebSettingsDropdown> { + @override + Widget build(BuildContext context) { + return Theme( + data: Theme.of(context).copyWith( + focusColor: Colors.white, + ), + child: GetBuilder( + tag: widget.tag, + builder: (podCtr) { + return MaterialIconButton( + toolTipMesg: podCtr.podPlayerLabels.settings, + color: Colors.white, + child: const Icon(Icons.settings), + onPressed: () => podCtr.isFullScreen + ? podCtr.isWebPopupOverlayOpen = true + : podCtr.isWebPopupOverlayOpen = false, + onTapDown: (details) async { + final settingsMenu = await showMenu( + context: context, + items: [ + if (podCtr.vimeoOrVideoUrls.isNotEmpty) + PopupMenuItem( + value: 'OUALITY', + child: _bottomSheetTiles( + title: podCtr.podPlayerLabels.quality, + icon: Icons.video_settings_rounded, + subText: '${podCtr.vimeoPlayingVideoQuality}p', + ), + ), + PopupMenuItem( + value: 'LOOP', + child: _bottomSheetTiles( + title: podCtr.podPlayerLabels.loopVideo, + icon: Icons.loop_rounded, + subText: podCtr.isLooping + ? podCtr.podPlayerLabels.optionEnabled + : podCtr.podPlayerLabels.optionDisabled, + ), + ), + PopupMenuItem( + value: 'SPEED', + child: _bottomSheetTiles( + title: podCtr.podPlayerLabels.playbackSpeed, + icon: Icons.slow_motion_video_rounded, + subText: podCtr.currentPaybackSpeed, + ), + ), + ], + position: RelativeRect.fromSize( + details.globalPosition & Size.zero, + MediaQuery.of(context).size, + ), + ); + switch (settingsMenu) { + case 'OUALITY': + await _onVimeoQualitySelect(details, podCtr); + break; + case 'SPEED': + await _onPlaybackSpeedSelect(details, podCtr); + break; + case 'LOOP': + podCtr.isWebPopupOverlayOpen = false; + await podCtr.toggleLooping(); + break; + default: + podCtr.isWebPopupOverlayOpen = false; + } + }, + ); + }, + ), + ); + } + + Future _onPlaybackSpeedSelect( + TapDownDetails details, + PodGetXVideoController podCtr, + ) async { + await Future.delayed( + const Duration(milliseconds: 400), + ); + await showMenu( + context: context, + items: podCtr.videoPlaybackSpeeds + .map( + (e) => PopupMenuItem( + child: ListTile( + title: Text(e), + ), + onTap: () { + podCtr.setVideoPlayBack(e); + }, + ), + ) + .toList(), + position: RelativeRect.fromSize( + details.globalPosition & Size.zero, + // ignore: use_build_context_synchronously + MediaQuery.of(context).size, + ), + ); + podCtr.isWebPopupOverlayOpen = false; + } + + Future _onVimeoQualitySelect( + TapDownDetails details, + PodGetXVideoController podCtr, + ) async { + await Future.delayed( + const Duration(milliseconds: 400), + ); + await showMenu( + context: context, + items: podCtr.vimeoOrVideoUrls + .map( + (e) => PopupMenuItem( + child: ListTile( + title: Text('${e.quality}p'), + ), + onTap: () { + podCtr.changeVideoQuality( + e.quality, + ); + }, + ), + ) + .toList(), + position: RelativeRect.fromSize( + details.globalPosition & Size.zero, + // ignore: use_build_context_synchronously + MediaQuery.of(context).size, + ), + ); + podCtr.isWebPopupOverlayOpen = false; + } + + Widget _bottomSheetTiles({ + required String title, + required IconData icon, + String? subText, + }) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 15), + child: FittedBox( + fit: BoxFit.scaleDown, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon), + const SizedBox(width: 20), + Text( + title, + ), + if (subText != null) const SizedBox(width: 10), + if (subText != null) + const SizedBox( + height: 4, + width: 4, + child: DecoratedBox( + decoration: BoxDecoration( + color: Colors.grey, + shape: BoxShape.circle, + ), + ), + ), + if (subText != null) const SizedBox(width: 6), + if (subText != null) + Text( + subText, + style: const TextStyle(color: Colors.grey), + ), + ], + ), + ), + ); + } +} diff --git a/example/packages/lib/src/widgets/core/overlays/web_overlay.dart b/example/packages/lib/src/widgets/core/overlays/web_overlay.dart new file mode 100644 index 00000000..3371b2fc --- /dev/null +++ b/example/packages/lib/src/widgets/core/overlays/web_overlay.dart @@ -0,0 +1,213 @@ +part of 'package:pod_player/src/pod_player.dart'; + +class _WebOverlay extends StatelessWidget { + final String tag; + const _WebOverlay({ + required this.tag, + }); + + @override + Widget build(BuildContext context) { + const overlayColor = Colors.black38; + final podCtr = Get.find(tag: tag); + return Stack( + children: [ + Positioned.fill( + child: _VideoGestureDetector( + tag: tag, + onTap: podCtr.togglePlayPauseVideo, + onDoubleTap: () => podCtr.toggleFullScreenOnWeb(context, tag), + child: const ColoredBox( + color: overlayColor, + child: SizedBox.expand(), + ), + ), + ), + Align( + alignment: Alignment.bottomLeft, + child: _WebOverlayBottomControlles( + tag: tag, + ), + ), + Positioned.fill( + child: GetBuilder( + tag: tag, + id: 'double-tap', + builder: (podCtr) { + return Row( + children: [ + Expanded( + child: IgnorePointer( + child: DoubleTapIcon( + onDoubleTap: () {}, + tag: tag, + isForward: false, + iconOnly: true, + ), + ), + ), + Expanded( + child: IgnorePointer( + child: DoubleTapIcon( + onDoubleTap: () {}, + tag: tag, + isForward: true, + iconOnly: true, + ), + ), + ), + ], + ); + }, + ), + ), + IgnorePointer(child: podCtr.videoTitle ?? const SizedBox()), + ], + ); + } +} + +class _WebOverlayBottomControlles extends StatelessWidget { + final String tag; + + const _WebOverlayBottomControlles({ + required this.tag, + }); + + @override + Widget build(BuildContext context) { + final podCtr = Get.find(tag: tag); + const durationTextStyle = TextStyle(color: Colors.white70); + const itemColor = Colors.white; + + return MouseRegion( + onHover: (event) => podCtr.onOverlayHover(), + onExit: (event) => podCtr.onOverlayHoverExit(), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 10), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + PodProgressBar( + tag: tag, + podProgressBarConfig: podCtr.podProgressBarConfig, + ), + Row( + children: [ + Expanded( + flex: 2, + child: FittedBox( + fit: BoxFit.scaleDown, + alignment: Alignment.centerLeft, + child: Row( + children: [ + _AnimatedPlayPauseIcon(tag: tag), + GetBuilder( + tag: tag, + id: 'volume', + builder: (podCtr) => MaterialIconButton( + toolTipMesg: podCtr.isMute + ? podCtr.podPlayerLabels.unmute ?? + 'Unmute${kIsWeb ? ' (m)' : ''}' + : podCtr.podPlayerLabels.mute ?? + 'Mute${kIsWeb ? ' (m)' : ''}', + color: itemColor, + onPressed: podCtr.toggleMute, + child: Icon( + podCtr.isMute + ? Icons.volume_off_rounded + : Icons.volume_up_rounded, + ), + ), + ), + GetBuilder( + tag: tag, + id: 'video-progress', + builder: (podCtr) { + return Row( + children: [ + Text( + podCtr.calculateVideoDuration( + podCtr.videoPosition, + ), + style: durationTextStyle, + ), + const Text( + ' / ', + style: durationTextStyle, + ), + Text( + podCtr.calculateVideoDuration( + podCtr.videoDuration, + ), + style: durationTextStyle, + ), + ], + ); + }, + ), + ], + ), + ), + ), + const SizedBox(width: 20), + Expanded( + child: FittedBox( + fit: BoxFit.scaleDown, + alignment: Alignment.centerRight, + child: Row( + children: [ + _WebSettingsDropdown(tag: tag), + MaterialIconButton( + toolTipMesg: podCtr.isFullScreen + ? podCtr.podPlayerLabels.exitFullScreen ?? + 'Exit full screen${kIsWeb ? ' (f)' : ''}' + : podCtr.podPlayerLabels.fullscreen ?? + 'Fullscreen${kIsWeb ? ' (f)' : ''}', + color: itemColor, + onPressed: () => _onFullScreenToggle(podCtr, context), + child: Icon( + podCtr.isFullScreen + ? Icons.fullscreen_exit + : Icons.fullscreen, + ), + ), + ], + ), + ), + ), + ], + ), + ], + ), + ), + ); + } + + void _onFullScreenToggle( + PodGetXVideoController podCtr, + BuildContext context, + ) { + if (podCtr.isOverlayVisible) { + if (podCtr.isFullScreen) { + if (kIsWeb) { + uni_html.document.exitFullscreen(); + podCtr.disableFullScreen(context, tag); + return; + } else { + podCtr.disableFullScreen(context, tag); + } + } else { + if (kIsWeb) { + uni_html.document.documentElement?.requestFullscreen(); + podCtr.enableFullScreen(tag); + return; + } else { + podCtr.enableFullScreen(tag); + } + } + } else { + podCtr.toggleVideoOverlay(); + } + } +} diff --git a/example/packages/lib/src/widgets/core/pod_core_player.dart b/example/packages/lib/src/widgets/core/pod_core_player.dart new file mode 100644 index 00000000..7a7a3e47 --- /dev/null +++ b/example/packages/lib/src/widgets/core/pod_core_player.dart @@ -0,0 +1,155 @@ +part of 'package:pod_player/src/pod_player.dart'; + +class _PodCoreVideoPlayer extends StatelessWidget { + final VideoPlayerController videoPlayerCtr; + final double videoAspectRatio; + final String tag; + + const _PodCoreVideoPlayer({ + required this.videoPlayerCtr, + required this.videoAspectRatio, + required this.tag, + }); + + @override + Widget build(BuildContext context) { + final podCtr = Get.find(tag: tag); + return Builder( + builder: (ctrx) { + return RawKeyboardListener( + autofocus: true, + focusNode: + (podCtr.isFullScreen ? FocusNode() : podCtr.keyboardFocusWeb) ?? + FocusNode(), + onKey: (value) => podCtr.onKeyBoardEvents( + event: value, + appContext: ctrx, + tag: tag, + ), + child: Stack( + fit: StackFit.expand, + children: [ + Center( + child: AspectRatio( + aspectRatio: videoAspectRatio, + child: VideoPlayer(videoPlayerCtr), + ), + ), + GetBuilder( + tag: tag, + id: 'podVideoState', + builder: (_) => GetBuilder( + tag: tag, + id: 'video-progress', + builder: (podCtr) { + if (podCtr.videoThumbnail == null) { + return const SizedBox(); + } + + if (podCtr.podVideoState == PodVideoState.paused && + podCtr.videoPosition == Duration.zero) { + return SizedBox.expand( + child: TweenAnimationBuilder( + builder: (context, value, child) => Opacity( + opacity: value, + child: child, + ), + tween: Tween(begin: 0.7, end: 1), + duration: const Duration(milliseconds: 400), + child: DecoratedBox( + decoration: BoxDecoration( + image: podCtr.videoThumbnail, + ), + ), + ), + ); + } + return const SizedBox(); + }, + ), + ), + _VideoOverlays(tag: tag), + IgnorePointer( + child: GetBuilder( + tag: tag, + id: 'podVideoState', + builder: (podCtr) { + final loadingWidget = podCtr.onLoading?.call(context) ?? + const Center( + child: CircularProgressIndicator( + backgroundColor: Colors.transparent, + color: Colors.white, + strokeWidth: 2, + ), + ); + + if (kIsWeb) { + switch (podCtr.podVideoState) { + case PodVideoState.loading: + return loadingWidget; + case PodVideoState.paused: + return const Center( + child: Icon( + Icons.play_arrow, + size: 45, + color: Colors.white, + ), + ); + case PodVideoState.playing: + return Center( + child: TweenAnimationBuilder( + builder: (context, value, child) => Opacity( + opacity: value, + child: child, + ), + tween: Tween(begin: 1, end: 0), + duration: const Duration(seconds: 1), + child: const Icon( + Icons.pause, + size: 45, + color: Colors.white, + ), + ), + ); + case PodVideoState.error: + return const SizedBox(); + } + } else { + if (podCtr.podVideoState == PodVideoState.loading) { + return loadingWidget; + } + return const SizedBox(); + } + }, + ), + ), + if (!kIsWeb) + GetBuilder( + tag: tag, + id: 'full-screen', + builder: (podCtr) => podCtr.isFullScreen + ? const SizedBox() + : GetBuilder( + tag: tag, + id: 'overlay', + builder: (podCtr) => podCtr.isOverlayVisible || + !podCtr.alwaysShowProgressBar + ? const SizedBox() + : Align( + alignment: Alignment.bottomCenter, + child: PodProgressBar( + tag: tag, + alignment: Alignment.bottomCenter, + podProgressBarConfig: + podCtr.podProgressBarConfig, + ), + ), + ), + ), + ], + ), + ); + }, + ); + } +} diff --git a/example/packages/lib/src/widgets/core/video_gesture_detector.dart b/example/packages/lib/src/widgets/core/video_gesture_detector.dart new file mode 100644 index 00000000..763582e5 --- /dev/null +++ b/example/packages/lib/src/widgets/core/video_gesture_detector.dart @@ -0,0 +1,29 @@ +part of 'package:pod_player/src/pod_player.dart'; + +class _VideoGestureDetector extends StatelessWidget { + final Widget? child; + final void Function()? onDoubleTap; + final void Function()? onTap; + final String tag; + + const _VideoGestureDetector({ + required this.tag, + this.child, + this.onDoubleTap, + this.onTap, + }); + + @override + Widget build(BuildContext context) { + final podCtr = Get.find(tag: tag); + return MouseRegion( + onHover: (event) => podCtr.onOverlayHover(), + onExit: (event) => podCtr.onOverlayHoverExit(), + child: GestureDetector( + onTap: onTap ?? podCtr.toggleVideoOverlay, + onDoubleTap: onDoubleTap, + child: child, + ), + ); + } +} diff --git a/example/packages/lib/src/widgets/doubble_tap_effect.dart b/example/packages/lib/src/widgets/doubble_tap_effect.dart new file mode 100644 index 00000000..f23915f9 --- /dev/null +++ b/example/packages/lib/src/widgets/doubble_tap_effect.dart @@ -0,0 +1,238 @@ +import 'package:flutter/material.dart'; + +class DoubleTapRippleEffect extends StatefulWidget { + /// child widget [child] + final Widget? child; + + /// Helps to wrap child widget inside a parent widget + final Widget Function(Widget parentWidget, double curveRadius)? wrapper; + + /// touch effect color of widget [rippleColor] + final Color? rippleColor; + + /// TouchRippleEffect widget background color [backgroundColor] + final Color? backgroundColor; + + /// if you have border of child widget then you should apply [borderRadius] + final BorderRadius? borderRadius; + + /// animation duration of touch effect. [rippleDuration] + final Duration? rippleDuration; + + /// duration to stay the frame. [rippleEndingDuraiton] + final Duration? rippleEndingDuraiton; + + /// user click or tap handle [onDoubleTap]. + final void Function()? onDoubleTap; + + /// TouchRippleEffect widget width size [width] + final double? width; + + /// TouchRippleEffect widget height size [height] + final double? height; + + const DoubleTapRippleEffect({ + super.key, + this.child, + this.wrapper, + this.rippleColor, + this.backgroundColor, + this.borderRadius, + this.rippleDuration, + this.rippleEndingDuraiton, + this.onDoubleTap, + this.width, + this.height, + }); + + @override + State createState() => _DoubleTapRippleEffectState(); +} + +class _DoubleTapRippleEffectState extends State + with SingleTickerProviderStateMixin { + // by default offset will be 0,0 + // it will be set when user tap on widget + Offset _tapOffset = Offset.zero; + + // globalKey variable decleared + final GlobalKey _globalKey = GlobalKey(); + + // animation global variable decleared and + // type cast is double + late Animation _anim; + + // animation controller global variable decleared + late AnimationController _animationController; + + /// width of user child widget + double _mWidth = 0; + + // height of user child widget + double _mHeight = 0; + + // tween animation global variable decleared and + // type cast is double + late Tween _tweenAnim; + + // animation count of Tween anim. + // by default value is 0. + double _animRadiusValue = 0; + + @override + void initState() { + super.initState(); + // animation controller initialized + _animationController = AnimationController( + vsync: this, + duration: widget.rippleDuration ?? const Duration(milliseconds: 300), + ); + // animation controller listener added or iitialized + _animationController.addListener(_update); + } + + // update animation when started + + void _update() { + setState(() { + // [_anim.value] setting to [_animRadiusValue] global variable + _animRadiusValue = _anim.value; + }); + // animation status function calling + _animStatus(); + } + + // checking animation status is completed + void _animStatus() { + if (_anim.status == AnimationStatus.completed) { + Future.delayed( + widget.rippleEndingDuraiton ?? const Duration(milliseconds: 600), + ).then((value) { + setState(() { + _animRadiusValue = 0; + }); + // stoping animation after completed + _animationController.stop(); + }); + } + } + + @override + void dispose() { + // disposing [_animationController] when parent exist of close + _animationController.dispose(); + super.dispose(); + } + + // animation initialize reset and start + void _animate() { + final width = widget.width ?? _mWidth; + final height = widget.height ?? _mHeight; + // [Tween] animation initialize to global variable + _tweenAnim = Tween(begin: 0, end: (width + height) / 1.5); + + // adding [_animationController] to [_tweenanim] to animate + _anim = _tweenAnim.animate(_animationController); + + _animationController + // resetting [_animationController] before start + ..reset() + // starting [_animationController] to start animation + ..forward(); + } + + @override + Widget build(BuildContext context) { + final curveRadius = (_mWidth + _mHeight) / 2; + if (widget.wrapper != null) return widget.wrapper!(_builder(), curveRadius); + return _builder(); + } + + Widget _builder() { + return GestureDetector( + onDoubleTap: widget.onDoubleTap, + onDoubleTapDown: (details) { + // getting tap [localPostion] of user + final lp = details.localPosition; + setState(() { + /// setting [Offset] of user tap to [_tapOffset] global variable + _tapOffset = Offset(lp.dx, lp.dy); + }); + + // getting [size] of child widget + final size = _globalKey.currentContext!.size!; + + // child widget [width] initialize to [_width] global variable + _mWidth = size.width; + + // child widget [height] initialize to [_height] global variable + _mHeight = size.height; + + // starting animation + _animate(); + }, + child: Container( + width: widget.width, + height: widget.height, + + // added globalKey for getting child widget size + key: _globalKey, + clipBehavior: Clip.antiAlias, + decoration: BoxDecoration( + // when color == null then color will be transpatent otherwise color will be backgroundColor + color: widget.backgroundColor ?? Colors.transparent, + + // boderRadius of container if user passed + borderRadius: widget.borderRadius, + ), + child: Stack( + children: [ + // added child widget of user + widget.child!, + Opacity( + opacity: 0.3, + child: CustomPaint( + // ripplePainter is CustomPainer for circular ripple draw + painter: RipplePainer( + offset: _tapOffset, + circleRadius: _animRadiusValue, + fillColor: widget.rippleColor, + ), + ), + ) + ], + ), + ), + ); + } +} + +class RipplePainer extends CustomPainter { + // user tap locations [Offset] + final Offset? offset; + + // radius of circle which will be ripple color size [circleRadius] + final double? circleRadius; + + // fill color of ripple [fillColor] + final Color? fillColor; + RipplePainer({this.offset, this.circleRadius, this.fillColor}); + + @override + void paint(Canvas canvas, Size size) { + // throw an [rippleColor == null error] if ripple color is null + final paint = Paint() + ..color = fillColor == null + ? throw Exception('rippleColor of TouchRippleEffect == null') + : fillColor! + ..isAntiAlias = true; + + // drawing canvas based on user click offset,radius and paint + canvas.drawCircle(offset!, circleRadius!, paint); + } + + @override + bool shouldRepaint(covariant CustomPainter oldDelegate) { + return true; + } +} diff --git a/example/packages/lib/src/widgets/double_tap_icon.dart b/example/packages/lib/src/widgets/double_tap_icon.dart new file mode 100644 index 00000000..1875e920 --- /dev/null +++ b/example/packages/lib/src/widgets/double_tap_icon.dart @@ -0,0 +1,186 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; + +import '../controllers/pod_getx_video_controller.dart'; +import 'doubble_tap_effect.dart'; + +class DoubleTapIcon extends StatefulWidget { + final void Function() onDoubleTap; + final String tag; + final bool iconOnly; + final bool isForward; + final double height; + final double? width; + + const DoubleTapIcon({ + required this.onDoubleTap, + required this.tag, + required this.isForward, + super.key, + this.iconOnly = false, + this.height = 50, + this.width, + }); + + @override + State createState() => _DoubleTapIconState(); +} + +class _DoubleTapIconState extends State + with SingleTickerProviderStateMixin { + late final AnimationController _animationController; + late final Animation opacityCtr; + + @override + void initState() { + super.initState(); + _animationController = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 200), + ); + opacityCtr = Tween(begin: 0, end: 1).animate( + CurvedAnimation( + parent: _animationController, + curve: Curves.easeInOut, + ), + ); + final podCtr = Get.find(tag: widget.tag); + if (widget.iconOnly && !widget.isForward) { + podCtr.addListenerId('double-tap-left', _onDoubleTap); + } + if (widget.iconOnly && widget.isForward) { + podCtr.addListenerId('double-tap-right', _onDoubleTap); + } + } + + @override + void dispose() { + final podCtr = Get.find(tag: widget.tag); + + if (widget.iconOnly && !widget.isForward) { + podCtr.removeListenerId('double-tap-left', _onDoubleTap); + } + if (widget.iconOnly && widget.isForward) { + podCtr.removeListenerId('double-tap-right', _onDoubleTap); + } + _animationController.dispose(); + super.dispose(); + } + + void _onDoubleTap() { + widget.onDoubleTap(); + _animationController.forward().then((_) { + _animationController.reverse(); + }); + } + + @override + Widget build(BuildContext context) { + if (widget.iconOnly) return iconWithText(); + return DoubleTapRippleEffect( + onDoubleTap: _onDoubleTap, + rippleColor: Colors.white, + wrapper: (parentWidget, curveRadius) { + final forwardRadius = + !widget.isForward ? Radius.zero : Radius.circular(curveRadius); + final backwardRadius = + widget.isForward ? Radius.zero : Radius.circular(curveRadius); + return ClipRRect( + borderRadius: BorderRadius.only( + bottomLeft: forwardRadius, + topLeft: forwardRadius, + bottomRight: backwardRadius, + topRight: backwardRadius, + ), + child: parentWidget, + ); + }, + child: iconWithText(), + ); + } + + SizedBox iconWithText() { + return SizedBox( + height: widget.height, + width: widget.width, + child: AnimatedBuilder( + animation: _animationController, + builder: (context, child) { + const icon = Icon( + Icons.play_arrow_sharp, + size: 32, + color: Colors.white, + ); + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + RotatedBox( + quarterTurns: widget.isForward ? 0 : 2, + child: Stack( + children: [ + AnimatedOpacity( + duration: const Duration(milliseconds: 200), + opacity: opacityCtr.value, + child: icon, + ), + Padding( + padding: const EdgeInsets.only(left: 20), + child: AnimatedOpacity( + duration: const Duration(milliseconds: 300), + opacity: opacityCtr.value, + child: icon, + ), + ), + Padding( + padding: const EdgeInsets.only(left: 40), + child: AnimatedOpacity( + duration: const Duration(milliseconds: 600), + opacity: opacityCtr.value, + child: icon, + ), + ), + ], + ), + ), + GetBuilder( + tag: widget.tag, + id: 'double-tap', + builder: (podCtr) { + if (widget.isForward && podCtr.isRightDbTapIconVisible) { + return AnimatedOpacity( + duration: const Duration(milliseconds: 300), + opacity: opacityCtr.value, + child: Text( + '${podCtr.isLeftDbTapIconVisible ? podCtr.leftDoubleTapduration : podCtr.rightDubleTapduration} Sec', + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.bold, + ), + ), + ); + } + if (!widget.isForward && podCtr.isLeftDbTapIconVisible) { + return AnimatedOpacity( + duration: const Duration(milliseconds: 300), + opacity: opacityCtr.value, + child: Text( + '${podCtr.isLeftDbTapIconVisible ? podCtr.leftDoubleTapduration : podCtr.rightDubleTapduration} Sec', + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.bold, + ), + ), + ); + } + return const SizedBox(); + }, + ), + ], + ), + ); + }, + ), + ); + } +} diff --git a/example/packages/lib/src/widgets/full_screen_view.dart b/example/packages/lib/src/widgets/full_screen_view.dart new file mode 100644 index 00000000..e26501ac --- /dev/null +++ b/example/packages/lib/src/widgets/full_screen_view.dart @@ -0,0 +1,83 @@ +part of 'package:pod_player/src/pod_player.dart'; + +class FullScreenView extends StatefulWidget { + final String tag; + const FullScreenView({ + required this.tag, + super.key, + }); + + @override + State createState() => _FullScreenViewState(); +} + +class _FullScreenViewState extends State + with TickerProviderStateMixin { + late PodGetXVideoController _podCtr; + @override + void initState() { + _podCtr = Get.find(tag: widget.tag); + _podCtr.fullScreenContext = context; + _podCtr.keyboardFocusWeb?.removeListener(_podCtr.keyboadListner); + + super.initState(); + } + + @override + void dispose() { + _podCtr.keyboardFocusWeb?.requestFocus(); + _podCtr.keyboardFocusWeb?.addListener(_podCtr.keyboadListner); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final loadingWidget = _podCtr.onLoading?.call(context) ?? + const CircularProgressIndicator( + backgroundColor: Colors.black87, + color: Colors.white, + strokeWidth: 2, + ); + + return WillPopScope( + onWillPop: () async { + if (kIsWeb) { + await _podCtr.disableFullScreen( + context, + widget.tag, + enablePop: false, + ); + } + if (!kIsWeb) await _podCtr.disableFullScreen(context, widget.tag); + return true; + }, + child: Scaffold( + backgroundColor: Colors.black, + body: GetBuilder( + tag: widget.tag, + builder: (podCtr) => Center( + child: ColoredBox( + color: Colors.black, + child: SizedBox( + height: MediaQuery.of(context).size.height, + width: MediaQuery.of(context).size.width, + child: Center( + child: podCtr.videoCtr == null + ? loadingWidget + : podCtr.videoCtr!.value.isInitialized + ? _PodCoreVideoPlayer( + tag: widget.tag, + videoPlayerCtr: podCtr.videoCtr!, + videoAspectRatio: + podCtr.videoCtr?.value.aspectRatio ?? 16 / 9, + ) + : loadingWidget, + ), + ), + ), + ), + ), + ), + ); + } +} diff --git a/example/packages/lib/src/widgets/material_icon_button.dart b/example/packages/lib/src/widgets/material_icon_button.dart new file mode 100644 index 00000000..6dccb2c1 --- /dev/null +++ b/example/packages/lib/src/widgets/material_icon_button.dart @@ -0,0 +1,46 @@ +import 'package:flutter/material.dart'; + +class MaterialIconButton extends StatelessWidget { + const MaterialIconButton({ + required this.child, + required this.toolTipMesg, + super.key, + this.color, + this.radius = 12, + this.onPressed, + this.onHover, + this.onTapDown, + }); + + final Color? color; + final Widget child; + final double radius; + final String toolTipMesg; + final void Function()? onPressed; + final void Function(bool)? onHover; + final void Function(TapDownDetails details)? onTapDown; + @override + Widget build(BuildContext context) { + return Material( + type: MaterialType.transparency, + shape: const CircleBorder(), + child: Tooltip( + message: toolTipMesg, + // textStyle: TextStyle(fontSize: 0.01), + child: InkWell( + borderRadius: BorderRadius.circular(radius * 4), + onHover: onHover, + onTap: onPressed, + onTapDown: onTapDown, + child: Padding( + padding: EdgeInsets.all(radius), + child: IconTheme( + data: IconThemeData(color: color, size: 24), + child: child, + ), + ), + ), + ), + ); + } +} diff --git a/example/packages/lib/src/widgets/pod_progress_bar.dart b/example/packages/lib/src/widgets/pod_progress_bar.dart new file mode 100644 index 00000000..855180cd --- /dev/null +++ b/example/packages/lib/src/widgets/pod_progress_bar.dart @@ -0,0 +1,258 @@ +import 'package:flutter/material.dart'; +import 'package:get/get_state_manager/get_state_manager.dart'; +import 'package:get/instance_manager.dart'; +import 'package:video_player/video_player.dart'; + +import '../controllers/pod_getx_video_controller.dart'; +import '../models/pod_progress_bar_config.dart'; + +/// Renders progress bar for the video using custom paint. +class PodProgressBar extends StatefulWidget { + const PodProgressBar({ + required this.tag, + super.key, + PodProgressBarConfig? podProgressBarConfig, + this.onDragStart, + this.onDragEnd, + this.onDragUpdate, + this.alignment = Alignment.center, + }) : podProgressBarConfig = + podProgressBarConfig ?? const PodProgressBarConfig(); + + final PodProgressBarConfig podProgressBarConfig; + final void Function()? onDragStart; + final void Function()? onDragEnd; + final void Function()? onDragUpdate; + final Alignment alignment; + final String tag; + + @override + State createState() => _PodProgressBarState(); +} + +class _PodProgressBarState extends State { + late final _podCtr = Get.find(tag: widget.tag); + late VideoPlayerValue? videoPlayerValue = _podCtr.videoCtr?.value; + bool _controllerWasPlaying = false; + + void seekToRelativePosition(Offset globalPosition) { + final box = context.findRenderObject() as RenderBox?; + if (box != null) { + final Offset tapPos = box.globalToLocal(globalPosition); + final double relative = tapPos.dx / box.size.width; + final Duration position = + (videoPlayerValue?.duration ?? Duration.zero) * relative; + _podCtr.seekTo(position); + } + } + + @override + Widget build(BuildContext context) { + if (videoPlayerValue == null) return const SizedBox(); + + return GetBuilder( + tag: widget.tag, + id: 'video-progress', + builder: (podCtr) { + videoPlayerValue = podCtr.videoCtr?.value; + return LayoutBuilder( + builder: (context, size) { + return GestureDetector( + behavior: HitTestBehavior.opaque, + child: _progressBar(size), + onHorizontalDragStart: (DragStartDetails details) { + if (!videoPlayerValue!.isInitialized) { + return; + } + _controllerWasPlaying = + podCtr.videoCtr?.value.isPlaying ?? false; + if (_controllerWasPlaying) { + podCtr.videoCtr?.pause(); + } + + if (widget.onDragStart != null) { + widget.onDragStart?.call(); + } + }, + onHorizontalDragUpdate: (DragUpdateDetails details) { + if (!videoPlayerValue!.isInitialized) { + return; + } + podCtr.isShowOverlay(true); + seekToRelativePosition(details.globalPosition); + + widget.onDragUpdate?.call(); + }, + onHorizontalDragEnd: (DragEndDetails details) { + if (_controllerWasPlaying) { + podCtr.videoCtr?.play(); + } + podCtr.toggleVideoOverlay(); + + if (widget.onDragEnd != null) { + widget.onDragEnd?.call(); + } + }, + onTapDown: (TapDownDetails details) { + if (!videoPlayerValue!.isInitialized) { + return; + } + seekToRelativePosition(details.globalPosition); + }, + ); + }, + ); + }, + ); + } + + MouseRegion _progressBar(BoxConstraints size) { + return MouseRegion( + cursor: SystemMouseCursors.click, + child: Padding( + padding: widget.podProgressBarConfig.padding, + child: SizedBox( + width: size.maxWidth, + height: widget.podProgressBarConfig.circleHandlerRadius, + child: Align( + alignment: widget.alignment, + child: GetBuilder( + tag: widget.tag, + id: 'overlay', + builder: (podCtr) => CustomPaint( + painter: _ProgressBarPainter( + videoPlayerValue!, + podProgressBarConfig: widget.podProgressBarConfig.copyWith( + circleHandlerRadius: podCtr.isOverlayVisible || + widget + .podProgressBarConfig.alwaysVisibleCircleHandler + ? widget.podProgressBarConfig.circleHandlerRadius + : 0, + ), + ), + size: Size( + double.maxFinite, + widget.podProgressBarConfig.height, + ), + ), + ), + ), + ), + ), + ); + } +} + +class _ProgressBarPainter extends CustomPainter { + _ProgressBarPainter(this.value, {this.podProgressBarConfig}); + + VideoPlayerValue value; + PodProgressBarConfig? podProgressBarConfig; + + @override + bool shouldRepaint(CustomPainter painter) { + return true; + } + + @override + void paint(Canvas canvas, Size size) { + final double height = podProgressBarConfig!.height; + final double width = size.width; + final double curveRadius = podProgressBarConfig!.curveRadius; + final double circleHandlerRadius = + podProgressBarConfig!.circleHandlerRadius; + final Paint backgroundPaint = + podProgressBarConfig!.getBackgroundPaint != null + ? podProgressBarConfig!.getBackgroundPaint!( + width: width, + height: height, + circleHandlerRadius: circleHandlerRadius, + ) + : Paint() + ..color = podProgressBarConfig!.backgroundColor; + + canvas.drawRRect( + RRect.fromRectAndRadius( + Rect.fromPoints( + Offset.zero, + Offset(width, height), + ), + Radius.circular(curveRadius), + ), + backgroundPaint, + ); + if (value.isInitialized == false) { + return; + } + + final double playedPartPercent = + value.position.inMilliseconds / value.duration.inMilliseconds; + final double playedPart = + playedPartPercent > 1 ? width : playedPartPercent * width; + + for (final DurationRange range in value.buffered) { + final double start = range.startFraction(value.duration) * width; + final double end = range.endFraction(value.duration) * width; + + final Paint bufferedPaint = podProgressBarConfig!.getBufferedPaint != null + ? podProgressBarConfig!.getBufferedPaint!( + width: width, + height: height, + playedPart: playedPart, + circleHandlerRadius: circleHandlerRadius, + bufferedStart: start, + bufferedEnd: end, + ) + : Paint() + ..color = podProgressBarConfig!.bufferedBarColor; + + canvas.drawRRect( + RRect.fromRectAndRadius( + Rect.fromPoints( + Offset(start, 0), + Offset(end, height), + ), + Radius.circular(curveRadius), + ), + bufferedPaint, + ); + } + + final Paint playedPaint = podProgressBarConfig!.getPlayedPaint != null + ? podProgressBarConfig!.getPlayedPaint!( + width: width, + height: height, + playedPart: playedPart, + circleHandlerRadius: circleHandlerRadius, + ) + : Paint() + ..color = podProgressBarConfig!.playingBarColor; + canvas.drawRRect( + RRect.fromRectAndRadius( + Rect.fromPoints( + Offset.zero, + Offset(playedPart, height), + ), + Radius.circular(curveRadius), + ), + playedPaint, + ); + + final Paint handlePaint = + podProgressBarConfig!.getCircleHandlerPaint != null + ? podProgressBarConfig!.getCircleHandlerPaint!( + width: width, + height: height, + playedPart: playedPart, + circleHandlerRadius: circleHandlerRadius, + ) + : Paint() + ..color = podProgressBarConfig!.circleHandlerColor; + + canvas.drawCircle( + Offset(playedPart, height / 2), + circleHandlerRadius, + handlePaint, + ); + } +} diff --git a/example/packages/pubspec.yaml b/example/packages/pubspec.yaml new file mode 100644 index 00000000..c04b2050 --- /dev/null +++ b/example/packages/pubspec.yaml @@ -0,0 +1,28 @@ +name: pod_player +description: Vimeo and youtube player for flutter, Pod player provides customizable video player controls that support android, ios and web. +version: 0.2.2 +homepage: https://github.com/newtaDev/pod_player + +environment: + sdk: ">=3.0.0 <4.0.0" + flutter: ">=1.17.0" + +dependencies: + flutter: + sdk: flutter + # services + video_player: ^2.8.5 + http: ^1.2.1 + get: ^4.6.6 + wakelock_plus: ^1.2.4 + universal_html: ^2.2.4 + youtube_explode_dart: ^2.2.0 + +dev_dependencies: + flutter_test: + sdk: flutter + very_good_analysis: ^5.0.0+1 + +screenshots: + - description: Pod video player logo + path: screenshots/logo.png diff --git a/example/pubspec.yaml b/example/pubspec.yaml index 687fec49..0cca0ed1 100644 --- a/example/pubspec.yaml +++ b/example/pubspec.yaml @@ -17,6 +17,7 @@ dependencies: cupertino_icons: ^1.0.5 visibility_detector: ^0.3.3 + http: ^1.1.0 dev_dependencies: flutter_test: