├── .github ├── movie_01.png ├── player_01.png ├── player_02.png ├── player_03.png ├── player_04.png ├── search_01.png ├── search_02.png ├── settings_01.png └── tv_01.png ├── .gitignore ├── .metadata ├── README.md ├── analysis_options.yaml ├── android ├── app │ └── src │ │ └── main │ │ └── java │ │ └── io │ │ └── flutter │ │ └── plugins │ │ └── GeneratedPluginRegistrant.java └── local.properties ├── images └── ep-no-thumb.jpg ├── lib ├── android_tv │ ├── main_view_android.dart │ └── search_view_android.dart ├── constants.dart ├── extractor │ ├── extractor.dart │ ├── extractors.dart │ └── extractors │ │ ├── doki_cloud_extractor.dart │ │ ├── dood_stream_extractor.dart │ │ ├── mix_drop_extractor.dart │ │ ├── rabbit_stream_extractor.dart │ │ ├── stream_hub_extractor.dart │ │ ├── stream_tape_extractor.dart │ │ ├── vid_src_extractor.dart │ │ └── vidoza_extractor.dart ├── main.dart ├── provider │ ├── provider.dart │ ├── providers.dart │ └── providers │ │ ├── allmoviesforyou.dart │ │ ├── aniflix.dart │ │ ├── anime_pahe.dart │ │ ├── dopebox.dart │ │ ├── goku.dart │ │ ├── hdtoday.dart │ │ ├── movies.dart │ │ ├── primewire.dart │ │ ├── sflix.dart │ │ ├── solarmovie.dart │ │ └── vidsrc.dart ├── util │ ├── capsules │ │ ├── fetch.dart │ │ ├── link.dart │ │ ├── media.dart │ │ ├── option_item.dart │ │ ├── search.dart │ │ └── subtitle.dart │ ├── custom_scroll_behaviour.dart │ ├── download │ │ ├── basic_downloader.dart │ │ ├── downloader.dart │ │ └── hls_downloader.dart │ ├── extensions │ │ ├── iterable_extension.dart │ │ ├── stream_extension.dart │ │ └── string_extension.dart │ ├── extraction │ │ ├── js_packer.dart │ │ └── sflix_util.dart │ ├── file │ │ └── file_util.dart │ ├── hls │ │ └── hls_util.dart │ ├── movie_provider │ │ └── the_movie_db.dart │ ├── network │ │ ├── cloud_flare_interceptor.dart │ │ └── plugins │ │ │ ├── custom_stealth_plugin.dart │ │ │ └── proxy_extension.dart │ ├── setting │ │ └── settings.dart │ ├── video_player_intents.dart │ └── watchable │ │ ├── season.dart │ │ ├── watchable.dart │ │ └── watchables.dart ├── views │ ├── main_view.dart │ ├── providers_view.dart │ ├── search_view.dart │ ├── settings_view.dart │ ├── video_player.dart │ └── watchable_view.dart └── widgets │ ├── cards │ ├── episode_card.dart │ ├── general_card.dart │ ├── search_response_card.dart │ └── watchable_card_widget.dart │ ├── half_page_image.dart │ ├── movie_widget.dart │ ├── player │ ├── animated_play_pause.dart │ ├── center_play_button.dart │ ├── option_dialog.dart │ ├── playback_speed_dialog.dart │ └── subtitle_widget.dart │ ├── settings │ ├── abstract_settings_tile.dart │ ├── base_settings_tile.dart │ ├── navigation_settings_tile.dart │ ├── selection_settings_tile.dart │ ├── settings_list.dart │ ├── settings_section.dart │ └── switch_settings_tile.dart │ ├── snackbars.dart │ ├── text_search_field_widget.dart │ ├── tv_widget.dart │ └── watchables_list_widget.dart ├── macos └── Flutter │ └── GeneratedPluginRegistrant.swift ├── pubspec.lock ├── pubspec.yaml ├── test ├── aniflix_test.dart ├── anime_pahe_test.dart ├── sflix_test.dart └── widget_test.dart └── windows ├── .gitignore ├── CMakeLists.txt └── flutter ├── CMakeLists.txt ├── generated_plugin_registrant.cc ├── generated_plugin_registrant.h └── generated_plugins.cmake /.github/movie_01.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/callisto-jovy/Viddroid/99f96d3fd213525421b6c98e17a7570139a00580/.github/movie_01.png -------------------------------------------------------------------------------- /.github/player_01.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/callisto-jovy/Viddroid/99f96d3fd213525421b6c98e17a7570139a00580/.github/player_01.png -------------------------------------------------------------------------------- /.github/player_02.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/callisto-jovy/Viddroid/99f96d3fd213525421b6c98e17a7570139a00580/.github/player_02.png -------------------------------------------------------------------------------- /.github/player_03.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/callisto-jovy/Viddroid/99f96d3fd213525421b6c98e17a7570139a00580/.github/player_03.png -------------------------------------------------------------------------------- /.github/player_04.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/callisto-jovy/Viddroid/99f96d3fd213525421b6c98e17a7570139a00580/.github/player_04.png -------------------------------------------------------------------------------- /.github/search_01.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/callisto-jovy/Viddroid/99f96d3fd213525421b6c98e17a7570139a00580/.github/search_01.png -------------------------------------------------------------------------------- /.github/search_02.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/callisto-jovy/Viddroid/99f96d3fd213525421b6c98e17a7570139a00580/.github/search_02.png -------------------------------------------------------------------------------- /.github/settings_01.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/callisto-jovy/Viddroid/99f96d3fd213525421b6c98e17a7570139a00580/.github/settings_01.png -------------------------------------------------------------------------------- /.github/tv_01.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/callisto-jovy/Viddroid/99f96d3fd213525421b6c98e17a7570139a00580/.github/tv_01.png -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Miscellaneous 2 | *.class 3 | *.log 4 | *.pyc 5 | *.swp 6 | .DS_Store 7 | .atom/ 8 | .buildlog/ 9 | .history 10 | .svn/ 11 | migrate_working_dir/ 12 | 13 | # IntelliJ related 14 | *.iml 15 | *.ipr 16 | *.iws 17 | .idea/ 18 | 19 | # The .vscode folder contains launch configuration and tasks you configure in 20 | # VS Code which you may wish to be included in version control, so this line 21 | # is commented out by default. 22 | #.vscode/ 23 | 24 | # Flutter/Dart/Pub related 25 | **/doc/api/ 26 | **/ios/Flutter/.last_build_id 27 | .dart_tool/ 28 | .flutter-plugins 29 | .flutter-plugins-dependencies 30 | .packages 31 | .pub-cache/ 32 | .pub/ 33 | /build/ 34 | 35 | # Symbolication related 36 | app.*.symbols 37 | 38 | # Obfuscation related 39 | app.*.map.json 40 | 41 | # Android Studio will place build artifacts here 42 | /android/app/debug 43 | /android/app/profile 44 | /android/app/release 45 | 46 | lib/api.dart 47 | /installers/ 48 | /.local-chromium/ 49 | -------------------------------------------------------------------------------- /.metadata: -------------------------------------------------------------------------------- 1 | # This file tracks properties of this Flutter project. 2 | # Used by Flutter tool to assess capabilities and perform upgrades etc. 3 | # 4 | # This file should be version controlled and should not be manually edited. 5 | 6 | version: 7 | revision: "2663184aa79047d0a33a14a3b607954f8fdd8730" 8 | channel: "stable" 9 | 10 | project_type: app 11 | 12 | # Tracks metadata for the flutter migrate command 13 | migration: 14 | platforms: 15 | - platform: root 16 | create_revision: 2663184aa79047d0a33a14a3b607954f8fdd8730 17 | base_revision: 2663184aa79047d0a33a14a3b607954f8fdd8730 18 | - platform: macos 19 | create_revision: 2663184aa79047d0a33a14a3b607954f8fdd8730 20 | base_revision: 2663184aa79047d0a33a14a3b607954f8fdd8730 21 | 22 | # User provided section 23 | 24 | # List of Local paths (relative to this file) that should be 25 | # ignored by the migrate tool. 26 | # 27 | # Files that are not part of the templates will be ignored by default. 28 | unmanaged_files: 29 | - 'lib/main.dart' 30 | - 'ios/Runner.xcodeproj/project.pbxproj' 31 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Viddroid 2 | 3 | Viddroid is a desktop application, which allows to stream, download and bookmark tv-shows, movies, and anime. 4 | 5 | By itself, Viddroid includes some streaming providers. However, it is planned to construct a custom scripting-language, 6 | which would enable users to include custom providers. But, before this enhancement, the app has to be in a stable and 7 | semi-finished state. 8 | 9 | As this project could get pretty big, help is always appreciated. 10 | 11 | ## Platforms 12 | 13 | Viddroid supports all major desktop operating systems, with hardware accelerated video playback, thanks 14 | to [media_kit](https://github.com/alexmercerind/media_kit)! Further, Viddroid supports AndroidTV and thereby Chromecast. 15 | 16 | | **Platform** | **Support** | 17 | |--------------|-------------| 18 | | Windows | Ready | 19 | | Linux | Ready | 20 | | macOS | Ready | 21 | | AndroidTV | Ready | 22 | 23 | ## Features 24 | - No advertisements, whatsoever. 25 | - Bookmark your favorite movies / shows. 26 | - Download and stream your favorite movies and shows. 27 | - Desktop support for all major operating systems (Windows, MacOS, Linux). 28 | - No tracking, no analytics. 29 | - Android TV support. 30 | 31 | ## Overview 32 | 33 | ![search idle](.github/search_01.png) 34 | ![search](.github/search_02.png) 35 | ![tv screen](.github/tv_01.png) 36 | ![movie screen](.github/movie_01.png) 37 | ![player idle](.github/player_01.png) 38 | ![player options](.github/player_02.png) 39 | ![player playing](.github/player_03.png) 40 | ![player subtitles](.github/player_04.png) 41 | ![settings snapshot](.github/settings_01.png) 42 | 43 | ## Roadmap 44 | 45 | - [x] Basic structure 46 | - [x] Providers and extractors 47 | - [x] Functional and beautiful enough UI 48 | - [x] Downloading media 49 | - [x] Setting's structure 50 | - [x] Custom proxies 51 | - [ ] Download progress indicators 52 | - [x] Subtitles 53 | - [ ] Interactive UI 54 | - [ ] Media bookmarking 55 | - [ ] Custom Providers (implemented through a custom scripting-language) / Detach the providers from the codebase 56 | and turn them into extensions (e.g. [Cloudstream](https://github.com/recloudstream/cloudstream)) 57 | - [ ] Translations 58 | -------------------------------------------------------------------------------- /analysis_options.yaml: -------------------------------------------------------------------------------- 1 | # This file configures the analyzer, which statically analyzes Dart code to 2 | # check for errors, warnings, and lints. 3 | # 4 | # The issues identified by the analyzer are surfaced in the UI of Dart-enabled 5 | # IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be 6 | # invoked from the command line by running `flutter analyze`. 7 | 8 | # The following line activates a set of recommended lints for Flutter apps, 9 | # packages, and plugins designed to encourage good coding practices. 10 | include: package:flutter_lints/flutter.yaml 11 | 12 | linter: 13 | # The lint rules applied to this project can be customized in the 14 | # section below to disable rules from the `package:flutter_lints/flutter.yaml` 15 | # included above or to enable additional rules. A list of all available lints 16 | # and their documentation is published at 17 | # https://dart-lang.github.io/linter/lints/index.html. 18 | # 19 | # Instead of disabling a lint rule for the entire project in the 20 | # section below, it can also be suppressed for a single line of code 21 | # or a specific dart file by using the `// ignore: name_of_lint` and 22 | # `// ignore_for_file: name_of_lint` syntax on the line or in the file 23 | # producing the lint. 24 | rules: 25 | # avoid_print: false # Uncomment to disable the `avoid_print` rule 26 | # prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule 27 | 28 | # Additional information about this file can be found at 29 | # https://dart.dev/guides/language/analysis-options 30 | -------------------------------------------------------------------------------- /android/app/src/main/java/io/flutter/plugins/GeneratedPluginRegistrant.java: -------------------------------------------------------------------------------- 1 | package io.flutter.plugins; 2 | 3 | import androidx.annotation.Keep; 4 | import androidx.annotation.NonNull; 5 | import io.flutter.Log; 6 | 7 | import io.flutter.embedding.engine.FlutterEngine; 8 | 9 | /** 10 | * Generated file. Do not edit. 11 | * This file is generated by the Flutter tool based on the 12 | * plugins that support the Android platform. 13 | */ 14 | @Keep 15 | public final class GeneratedPluginRegistrant { 16 | private static final String TAG = "GeneratedPluginRegistrant"; 17 | public static void registerWith(@NonNull FlutterEngine flutterEngine) { 18 | try { 19 | flutterEngine.getPlugins().add(new com.mr.flutter.plugin.filepicker.FilePickerPlugin()); 20 | } catch (Exception e) { 21 | Log.e(TAG, "Error registering plugin file_picker, com.mr.flutter.plugin.filepicker.FilePickerPlugin", e); 22 | } 23 | try { 24 | flutterEngine.getPlugins().add(new io.flutter.plugins.flutter_plugin_android_lifecycle.FlutterAndroidLifecyclePlugin()); 25 | } catch (Exception e) { 26 | Log.e(TAG, "Error registering plugin flutter_plugin_android_lifecycle, io.flutter.plugins.flutter_plugin_android_lifecycle.FlutterAndroidLifecyclePlugin", e); 27 | } 28 | try { 29 | flutterEngine.getPlugins().add(new com.alexmercerind.media_kit_libs_android_video.MediaKitLibsAndroidVideoPlugin()); 30 | } catch (Exception e) { 31 | Log.e(TAG, "Error registering plugin media_kit_libs_android_video, com.alexmercerind.media_kit_libs_android_video.MediaKitLibsAndroidVideoPlugin", e); 32 | } 33 | try { 34 | flutterEngine.getPlugins().add(new com.alexmercerind.media_kit_video.MediaKitVideoPlugin()); 35 | } catch (Exception e) { 36 | Log.e(TAG, "Error registering plugin media_kit_video, com.alexmercerind.media_kit_video.MediaKitVideoPlugin", e); 37 | } 38 | try { 39 | flutterEngine.getPlugins().add(new dev.fluttercommunity.plus.packageinfo.PackageInfoPlugin()); 40 | } catch (Exception e) { 41 | Log.e(TAG, "Error registering plugin package_info_plus, dev.fluttercommunity.plus.packageinfo.PackageInfoPlugin", e); 42 | } 43 | try { 44 | flutterEngine.getPlugins().add(new io.flutter.plugins.pathprovider.PathProviderPlugin()); 45 | } catch (Exception e) { 46 | Log.e(TAG, "Error registering plugin path_provider_android, io.flutter.plugins.pathprovider.PathProviderPlugin", e); 47 | } 48 | try { 49 | flutterEngine.getPlugins().add(new com.aaassseee.screen_brightness_android.ScreenBrightnessAndroidPlugin()); 50 | } catch (Exception e) { 51 | Log.e(TAG, "Error registering plugin screen_brightness_android, com.aaassseee.screen_brightness_android.ScreenBrightnessAndroidPlugin", e); 52 | } 53 | try { 54 | flutterEngine.getPlugins().add(new com.tekartik.sqflite.SqflitePlugin()); 55 | } catch (Exception e) { 56 | Log.e(TAG, "Error registering plugin sqflite_android, com.tekartik.sqflite.SqflitePlugin", e); 57 | } 58 | try { 59 | flutterEngine.getPlugins().add(new com.kurenai7968.volume_controller.VolumeControllerPlugin()); 60 | } catch (Exception e) { 61 | Log.e(TAG, "Error registering plugin volume_controller, com.kurenai7968.volume_controller.VolumeControllerPlugin", e); 62 | } 63 | try { 64 | flutterEngine.getPlugins().add(new dev.fluttercommunity.plus.wakelock.WakelockPlusPlugin()); 65 | } catch (Exception e) { 66 | Log.e(TAG, "Error registering plugin wakelock_plus, dev.fluttercommunity.plus.wakelock.WakelockPlusPlugin", e); 67 | } 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /android/local.properties: -------------------------------------------------------------------------------- 1 | flutter.sdk=D:\\Runtime\\flutter 2 | sdk.dir=D:/Runtime/android -------------------------------------------------------------------------------- /images/ep-no-thumb.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/callisto-jovy/Viddroid/99f96d3fd213525421b6c98e17a7570139a00580/images/ep-no-thumb.jpg -------------------------------------------------------------------------------- /lib/android_tv/main_view_android.dart: -------------------------------------------------------------------------------- 1 | import 'package:dpad_container/dpad_container.dart'; 2 | import 'package:flutter/material.dart'; 3 | import 'package:viddroid/android_tv/search_view_android.dart'; 4 | import 'package:viddroid/views/settings_view.dart'; 5 | import 'package:viddroid/widgets/watchables_list_widget.dart'; 6 | 7 | class AndroidMainView extends StatefulWidget { 8 | final String title; 9 | 10 | const AndroidMainView({super.key, required this.title}); 11 | 12 | @override 13 | State createState() => _AndroidMainViewState(); 14 | } 15 | 16 | class _AndroidMainViewState extends State { 17 | List _buildButtons() { 18 | return [ 19 | DpadContainer( 20 | onClick: () => _pushRoute(const AndroidSearchView()), 21 | onFocus: (isFocused) {}, 22 | child: IconButton( 23 | icon: const Icon(Icons.search), 24 | onPressed: () {}, 25 | tooltip: 'Search for media.', 26 | ), 27 | ), 28 | DpadContainer( 29 | onClick: () => _pushRoute(const SettingsView()), 30 | onFocus: (isFocused) {}, 31 | child: IconButton( 32 | icon: const Icon(Icons.settings), 33 | onPressed: () {}, 34 | tooltip: 'Settings.', 35 | ), 36 | ), 37 | ]; 38 | } 39 | 40 | void _pushRoute(final StatefulWidget route) { 41 | Navigator.push( 42 | context, 43 | MaterialPageRoute( 44 | builder: (context) => route, 45 | ), 46 | ); 47 | } 48 | 49 | @override 50 | Widget build(BuildContext context) { 51 | return Scaffold( 52 | appBar: AppBar( 53 | title: Text(widget.title), 54 | actions: _buildButtons(), 55 | ), 56 | body: WatchablesList(List.empty())); 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /lib/android_tv/search_view_android.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | 3 | import 'package:flutter/foundation.dart'; 4 | import 'package:flutter/material.dart'; 5 | import 'package:flutter/services.dart'; 6 | import 'package:flutter_sticky_header/flutter_sticky_header.dart'; 7 | import 'package:shimmer/shimmer.dart'; 8 | import 'package:viddroid/util/extensions/iterable_extension.dart'; 9 | import 'package:viddroid/widgets/cards/search_response_card.dart'; 10 | 11 | import '../provider/providers.dart'; 12 | import '../util/capsules/media.dart'; 13 | import '../util/capsules/search.dart'; 14 | import '../widgets/snackbars.dart'; 15 | import '../widgets/text_search_field_widget.dart'; 16 | 17 | class AndroidSearchView extends StatefulWidget { 18 | const AndroidSearchView({super.key}); 19 | 20 | @override 21 | State createState() => _AndroidSearchViewState(); 22 | } 23 | 24 | class _AndroidSearchViewState extends State { 25 | final TextEditingController _searchController = TextEditingController(); 26 | 27 | final List _currentSelectedValues = TvType.values; 28 | 29 | final StreamController> _searchResults = 30 | StreamController>(); 31 | 32 | final GlobalKey _formFieldKey = GlobalKey(); 33 | 34 | @override 35 | void dispose() { 36 | _searchController.dispose(); 37 | _searchResults.close(); 38 | super.dispose(); 39 | } 40 | 41 | Widget _buildSearchField() { 42 | return Focus( 43 | canRequestFocus: false, 44 | onKeyEvent: _handleKeyEvent, 45 | child: TextSearchField( 46 | controller: _searchController, 47 | onSubmitted: (text) { 48 | final List totalResponses = []; 49 | Providers().search(text, _currentSelectedValues).listen((event) { 50 | totalResponses.addAll(event); 51 | _searchResults.add(totalResponses); 52 | }).onError((error, stackTrace) { 53 | ScaffoldMessenger.of(context).showSnackBar(errorSnackbar(error.toString())); 54 | if (kDebugMode) { 55 | print(stackTrace); 56 | } 57 | }); 58 | }, 59 | formFieldKey: _formFieldKey, 60 | ), 61 | ); 62 | } 63 | 64 | KeyEventResult _handleKeyEvent(FocusNode node, KeyEvent event) { 65 | if (LogicalKeyboardKey.arrowLeft == event.logicalKey) { 66 | FocusManager.instance.primaryFocus!.focusInDirection(TraversalDirection.left); 67 | } else if (LogicalKeyboardKey.arrowRight == event.logicalKey) { 68 | FocusManager.instance.primaryFocus!.focusInDirection(TraversalDirection.right); 69 | } else if (LogicalKeyboardKey.arrowUp == event.logicalKey) { 70 | FocusManager.instance.primaryFocus!.focusInDirection(TraversalDirection.up); 71 | } else if (LogicalKeyboardKey.arrowDown == event.logicalKey) { 72 | FocusManager.instance.primaryFocus!.focusInDirection(TraversalDirection.down); 73 | } 74 | return KeyEventResult.handled; 75 | } 76 | 77 | Widget _buildSearchStreamBuilder() { 78 | return StreamBuilder( 79 | stream: _searchResults.stream, 80 | builder: (context, AsyncSnapshot> snapshot) { 81 | if (snapshot.hasData && snapshot.data!.isNotEmpty) { 82 | final List validProviders = snapshot.data!.map((e) => e.apiName).unique( 83 | (element) => element, 84 | ); 85 | 86 | return CustomScrollView( 87 | primary: false, 88 | shrinkWrap: true, 89 | scrollDirection: Axis.vertical, 90 | // controller: ScrollController(), 91 | slivers: validProviders.map((provider) { 92 | List resp = 93 | snapshot.data!.where((element) => element.apiName == provider).toList(); 94 | 95 | return SliverStickyHeader( 96 | header: Container( 97 | padding: const EdgeInsets.all(10), 98 | alignment: Alignment.centerLeft, 99 | margin: const EdgeInsets.only(left: 10), 100 | decoration: BoxDecoration( 101 | borderRadius: BorderRadius.circular(12), 102 | color: Theme.of(context).colorScheme.secondaryContainer), 103 | child: Text( 104 | provider, 105 | style: const TextStyle(fontSize: 20, fontWeight: FontWeight.w600), 106 | ), 107 | ), 108 | sliver: SliverPadding( 109 | padding: const EdgeInsets.all(20), 110 | sliver: SliverGrid( 111 | gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( 112 | crossAxisCount: 4, 113 | crossAxisSpacing: 30, 114 | mainAxisSpacing: 30, 115 | ), 116 | delegate: SliverChildBuilderDelegate( 117 | (context, index) { 118 | final SearchResponse searchResponse = resp[index]; 119 | return GridTile(child: SearchResponseCard(searchResponse)); 120 | }, 121 | childCount: resp.length, 122 | ), 123 | ), 124 | ), 125 | ); 126 | }).toList(), 127 | ); 128 | } else if (snapshot.hasError) { 129 | return Text('Something went wrong. ${snapshot.error!}'); 130 | } else { 131 | return _buildShimmerPlaceholder(); 132 | } 133 | }, 134 | ); 135 | } 136 | 137 | Widget _buildShimmerPlaceholder() { 138 | //Placeholder with shimmer 139 | return Shimmer.fromColors( 140 | baseColor: Colors.black12, 141 | highlightColor: Colors.grey.shade800, 142 | period: const Duration(milliseconds: 2500), 143 | child: GridView.builder( 144 | padding: const EdgeInsets.all(20), 145 | itemCount: 8, 146 | //It is not possible to show more 8 items 147 | physics: const NeverScrollableScrollPhysics(), 148 | itemBuilder: (context, i) { 149 | return const Card(); 150 | }, 151 | gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( 152 | crossAxisCount: 4, 153 | crossAxisSpacing: 10, 154 | mainAxisSpacing: 10, 155 | ), 156 | ), 157 | ); 158 | } 159 | 160 | @override 161 | Widget build(BuildContext context) { 162 | return Scaffold( 163 | appBar: AppBar( 164 | title: const Text('Search'), 165 | ), 166 | body: Column( 167 | mainAxisSize: MainAxisSize.max, 168 | children: [ 169 | _buildSearchField(), 170 | Expanded( 171 | flex: 4, 172 | child: _buildSearchStreamBuilder(), 173 | ), 174 | ], 175 | ), 176 | ); 177 | } 178 | } 179 | -------------------------------------------------------------------------------- /lib/constants.dart: -------------------------------------------------------------------------------- 1 | import 'dart:io' show Platform; 2 | 3 | import 'package:cookie_jar/cookie_jar.dart'; 4 | import 'package:dio/dio.dart'; 5 | import 'package:dio_cookie_manager/dio_cookie_manager.dart'; 6 | import 'package:flutter/foundation.dart' show kIsWeb; 7 | import 'package:logger/logger.dart'; 8 | import 'package:viddroid/util/network/plugins/proxy_extension.dart'; 9 | import 'package:viddroid/util/setting/settings.dart'; 10 | 11 | /// [RegExp] for matching proxies with the format IP:port 12 | final RegExp proxyRegex = RegExp(r'(([1-9][0-9]{2}|[1-9][0-9]|[1-9])\.([1-9][0-9]|[1-9][0-9]{2}|[0-9]))\.([0-9]|[1-9][0-9]|[1-9][0-9]{2})\.([0-9]|[1-9][0-9]|[1-9][0-9]{2}):([1-9][0-9]{4}|[1-9][0-9]{3}|[1-9][0-9]{2}|[1-9][0-9]|[1-9])'); 13 | 14 | /// Global [Logger], configured to use colors and a timestamp 15 | Logger logger = Logger( 16 | printer: PrettyPrinter( 17 | colors: true, 18 | printTime: true, 19 | printEmojis: true, 20 | )); 21 | 22 | /// Default user-agent used for all requests in the project 23 | const String userAgent = 24 | 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36'; 25 | 26 | /// Base Dio instance with a preconfigured user-agent header 27 | final Dio dio = Dio(BaseOptions( 28 | headers: {'user-agent': userAgent}, 29 | connectTimeout: const Duration(seconds: 60), //TODO: Make the timeouts adjustable 30 | receiveTimeout: const Duration(seconds: 60))) 31 | ..useProxy(Settings().get(Settings.proxy)) 32 | ..interceptors.add(CookieManager(cookieJar)); 33 | 34 | /// CookieJar instance for dio 35 | final CookieJar cookieJar = CookieJar(); 36 | 37 | /// 38 | /// [responseType] 39 | Future> simpleGet(final String url, 40 | {Map? headers, ResponseType responseType = ResponseType.json}) { 41 | return dio.get(url, 42 | options: Options( 43 | responseType: responseType, 44 | headers: headers, 45 | )); 46 | } 47 | 48 | /// "Advanced" get method, which creates a separate dio instance with its own interceptors. 49 | Future> advancedGet(final String url, 50 | {Map? headers, 51 | Interceptor? interceptor, 52 | ResponseType responseType = ResponseType.json}) { 53 | final Dio singleInstance = Dio(BaseOptions(headers: {'User-Agent': userAgent})) 54 | ..useProxy(Settings().get(Settings.proxy)) 55 | ..interceptors.add(CookieManager(cookieJar)); 56 | 57 | if (interceptor != null) { 58 | singleInstance.interceptors.add(interceptor); 59 | } 60 | 61 | return singleInstance.get(url, options: Options(headers: headers, responseType: responseType)); 62 | } 63 | 64 | /// Just a simple method to post to a given url, with optional headers 65 | Future simplePost(String url, Object? body, 66 | {Map? headers, ResponseType responseType = ResponseType.json}) => 67 | dio.post( 68 | url, 69 | options: Options(headers: {...?headers}, responseType: responseType), 70 | data: body, 71 | ); 72 | 73 | 74 | bool get isMobile { 75 | if (kIsWeb) { 76 | return false; 77 | } else { 78 | return Platform.isIOS || Platform.isAndroid; 79 | } 80 | } 81 | 82 | bool get isDesktop { 83 | if (kIsWeb) { 84 | return false; 85 | } else { 86 | return Platform.isLinux || Platform.isFuchsia || Platform.isWindows || Platform.isMacOS; 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /lib/extractor/extractor.dart: -------------------------------------------------------------------------------- 1 | import '../util/capsules/link.dart'; 2 | 3 | abstract class Extractor { 4 | final String name; 5 | final String mainUrl; 6 | final String url; 7 | final List? altUrls; 8 | 9 | Extractor(this.name, this.mainUrl, this.url, {this.altUrls}); 10 | 11 | Stream extract(final String url, {final Map? headers}); 12 | } 13 | -------------------------------------------------------------------------------- /lib/extractor/extractors.dart: -------------------------------------------------------------------------------- 1 | import 'package:viddroid/extractor/extractor.dart'; 2 | import 'package:viddroid/extractor/extractors/doki_cloud_extractor.dart'; 3 | import 'package:viddroid/extractor/extractors/mix_drop_extractor.dart'; 4 | import 'package:viddroid/extractor/extractors/rabbit_stream_extractor.dart'; 5 | import 'package:viddroid/extractor/extractors/stream_hub_extractor.dart'; 6 | import 'package:viddroid/extractor/extractors/stream_tape_extractor.dart'; 7 | import 'package:viddroid/extractor/extractors/vid_src_extractor.dart'; 8 | import 'package:viddroid/extractor/extractors/vidoza_extractor.dart'; 9 | 10 | import 'extractors/dood_stream_extractor.dart'; 11 | 12 | class Extractors { 13 | static final Extractors _instance = Extractors.inst(); 14 | 15 | factory Extractors() { 16 | return _instance; 17 | } 18 | 19 | Extractors.inst(); 20 | 21 | final List extractors = [ 22 | VidSrcExtractor(), 23 | DoodStreamExtractor(), 24 | DokiCloudExtractor(), 25 | StreamTapeExtractor(), 26 | MixDropExtractor(), 27 | RabbitStreamExtractor(), 28 | VidozaExtractor(), 29 | StreamHubExtractor() 30 | ]; 31 | 32 | Extractor? findExtractor(final String url) { 33 | for (final Extractor extr in extractors) { 34 | if (extr.mainUrl == url || (extr.altUrls != null && extr.altUrls!.contains(url))) { 35 | return extr; 36 | } 37 | } 38 | return null; 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /lib/extractor/extractors/doki_cloud_extractor.dart: -------------------------------------------------------------------------------- 1 | import 'dart:convert'; 2 | 3 | import 'package:dio/dio.dart'; 4 | import 'package:viddroid/constants.dart'; 5 | import 'package:viddroid/extractor/extractor.dart'; 6 | import 'package:viddroid/util/capsules/link.dart'; 7 | import 'package:viddroid/util/capsules/media.dart'; 8 | import 'package:viddroid/util/capsules/subtitle.dart'; 9 | import 'package:viddroid/util/extraction/sflix_util.dart'; 10 | 11 | ///Credit partly to: https://github.com/recloudstream/cloudstream-extensions/blob/master/SflixProvider/src/main/kotlin/com/lagradost/SflixProvider.kt 12 | class DokiCloudExtractor extends Extractor { 13 | DokiCloudExtractor() : super('DokiCloud', 'https://dokicloud.one', 'https://dokicloud.one'); 14 | 15 | @override 16 | Stream extract(String url, {Map? headers}) async* { 17 | //link: https://dokicloud.one/embed-4/ECvh21Qkfdo0?z= --> /embed-4 18 | final int lastSlash = url.lastIndexOf('/'); 19 | final String baseIframeUrl = url.substring(url.lastIndexOf('/', lastSlash - 1), lastSlash); 20 | final String baseIframeId = url.substring(url.lastIndexOf('/') + 1, url.lastIndexOf('?')); 21 | 22 | final String apiUrl = '$mainUrl/ajax$baseIframeUrl/getSources?id=$baseIframeId'; 23 | 24 | final Response response = await simpleGet(apiUrl, 25 | headers: { 26 | 'referer': url, 27 | 'X-Requested-With': 'XMLHttpRequest', 28 | 'Accept': '*/*', 29 | 'Accept-Language': 'en-US,en;q=0.5', 30 | 'Connection': 'keep-alive', 31 | 'TE': 'trailers' 32 | }, 33 | responseType: ResponseType.plain); 34 | 35 | final dynamic decodedJson = jsonDecode(response.data); 36 | final dynamic sources = decodedJson['sources']; 37 | if (sources == null) { 38 | return; 39 | } 40 | 41 | final List subtitles = decodedJson['tracks'] 42 | .map((t) => Subtitle(t['label'] ?? 'Unknown', t['label'] ?? 'Unknown', t['file'])) 43 | .toList(); 44 | 45 | if (sources is String) { 46 | final String decrypted = decrypt(sources, await _getKey()); 47 | final dynamic decryptedJson = jsonDecode(decrypted); 48 | 49 | for (int i = 0; i < decryptedJson.length; i++) { 50 | final dynamic entry = decryptedJson[i]; 51 | final String url = entry['file']; 52 | 53 | yield LinkResponse(url, mainUrl, '', MediaQuality.unknown, 54 | title: name, subtitles: subtitles); 55 | } 56 | } else { 57 | for (dynamic s in sources) { 58 | //TODO: Fetch from url 59 | if (s is Map) { 60 | print(s); 61 | yield LinkResponse(s['file'], mainUrl, '', MediaQuality.unknown, 62 | title: name, subtitles: subtitles); 63 | } else {} 64 | } 65 | } 66 | } 67 | 68 | Future _getKey() => 69 | simpleGet('https://raw.githubusercontent.com/enimax-anime/key/e4/key.txt', 70 | responseType: ResponseType.plain) 71 | .then((value) => value.data); 72 | } 73 | -------------------------------------------------------------------------------- /lib/extractor/extractors/dood_stream_extractor.dart: -------------------------------------------------------------------------------- 1 | import 'package:dio/dio.dart'; 2 | import 'package:viddroid/constants.dart'; 3 | import 'package:viddroid/util/capsules/link.dart'; 4 | import 'package:viddroid/util/capsules/media.dart'; 5 | import 'package:viddroid/util/network/cloud_flare_interceptor.dart'; 6 | 7 | import '../extractor.dart'; 8 | 9 | class DoodStreamExtractor extends Extractor { 10 | DoodStreamExtractor() 11 | : super('DoodStream', 'https://dood.la', 'https://dood.la', altUrls: [ 12 | 'https://dood.wf', 13 | 'https://dood.cx', 14 | 'https://dood.sh', 15 | 'https://dood.watch', 16 | 'https://dood.pm', 17 | 'https://dood.to', 18 | 'https://dood.si', 19 | 'https://dood.ws', 20 | 'https://dood.re', 21 | ]); 22 | 23 | @override 24 | Stream extract(String url, {Map? headers}) async* { 25 | final Response response = 26 | await advancedGet(url, interceptor: CloudFlareInterceptor(), headers: headers); 27 | 28 | final String body = response.data; 29 | 30 | final RegExp md5Regex = RegExp(r"/pass_md5/[^']*"); 31 | final String? md5 = md5Regex.stringMatch(body); 32 | 33 | if (md5 != null) { 34 | final Response md5Resp = await simpleGet('$mainUrl$md5', headers: {'referer': url}); 35 | final String mediaUrl = 36 | '${md5Resp.data}zUEJeL3mUN?token=${md5.substring(md5.lastIndexOf('/'))}'; 37 | //TODO: Media quality 38 | const MediaQuality mediaQuality = MediaQuality.unknown; 39 | 40 | yield LinkResponse(mediaUrl, mainUrl, '', mediaQuality, title: name); 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /lib/extractor/extractors/mix_drop_extractor.dart: -------------------------------------------------------------------------------- 1 | import 'package:dio/dio.dart'; 2 | import 'package:viddroid/constants.dart'; 3 | import 'package:viddroid/extractor/extractor.dart'; 4 | import 'package:viddroid/util/capsules/link.dart'; 5 | import 'package:viddroid/util/capsules/media.dart'; 6 | import 'package:viddroid/util/extraction/js_packer.dart'; 7 | 8 | class MixDropExtractor extends Extractor { 9 | MixDropExtractor() 10 | : super('MixDrop', 'https://mixdrop.co', 'https://mixdrop.co', 11 | altUrls: ['https://mixdrop.bz', 'https://mixdrop.ch', 'https://mixdrop.to']); 12 | 13 | final srcRegex = RegExp(r"""wurl.*?=.*?"(.*?)";"""); 14 | 15 | @override 16 | Stream extract(String url, {Map? headers}) async* { 17 | final Response response = await simpleGet(url, headers: headers); 18 | 19 | final String packedBody = response.data; 20 | final String? unpackedBody = JSPacker(packedBody).unpack(); 21 | if (unpackedBody == null) { 22 | return; 23 | } 24 | 25 | final String? stringMatch = srcRegex.firstMatch(unpackedBody)?.group(1); 26 | if (stringMatch == null) { 27 | return; 28 | } 29 | 30 | yield LinkResponse(stringMatch, url, '', MediaQuality.unknown, title: name); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /lib/extractor/extractors/rabbit_stream_extractor.dart: -------------------------------------------------------------------------------- 1 | import 'dart:convert'; 2 | 3 | import 'package:dio/dio.dart'; 4 | import 'package:viddroid/constants.dart'; 5 | import 'package:viddroid/extractor/extractor.dart'; 6 | import 'package:viddroid/util/capsules/link.dart'; 7 | import 'package:viddroid/util/capsules/media.dart'; 8 | import 'package:viddroid/util/extraction/sflix_util.dart'; 9 | 10 | import '../../util/capsules/subtitle.dart'; 11 | 12 | /// Works pretty much the same as doki_cloud 13 | class RabbitStreamExtractor extends Extractor { 14 | RabbitStreamExtractor() 15 | : super('RabbitStream', 'https://rabbitstream.net', 'https://rabbitstream.net'); 16 | 17 | @override 18 | Stream extract(String url, {Map? headers}) async* { 19 | //link: "https://rabbitstream.net/embed-4/o3kCy4CUD4Nr?z=" --> /embed-4 20 | final int lastSlash = url.lastIndexOf('/'); 21 | final String baseIframeUrl = url.substring(url.lastIndexOf('/', lastSlash - 1), lastSlash); 22 | final String baseIframeId = url.substring(url.lastIndexOf('/') + 1, url.lastIndexOf('?')); 23 | 24 | final String apiUrl = '$mainUrl/ajax$baseIframeUrl/getSources?id=$baseIframeId'; 25 | 26 | final Response response = await simpleGet(apiUrl, 27 | headers: { 28 | 'referer': url, 29 | 'X-Requested-With': 'XMLHttpRequest', 30 | 'Accept': '*/*', 31 | 'Accept-Language': 'en-US,en;q=0.5', 32 | 'Connection': 'keep-alive', 33 | 'TE': 'trailers' 34 | }, 35 | responseType: ResponseType.plain); 36 | 37 | final dynamic decodedJson = jsonDecode(response.data); 38 | final dynamic sources = decodedJson['sources']; 39 | if (sources == null) { 40 | return; 41 | } 42 | 43 | final List subtitles = decodedJson['tracks'] 44 | .map((t) => Subtitle(t['label'] ?? 'Unknown', t['label'] ?? 'Unknown', t['file'])) 45 | .toList(); 46 | 47 | if (sources is String) { 48 | final String decrypted = decrypt(sources, await _getKey()); 49 | final dynamic decryptedJson = jsonDecode(decrypted); 50 | 51 | for (int i = 0; i < decryptedJson.length; i++) { 52 | final dynamic entry = decryptedJson[i]; 53 | final String url = entry['file']; 54 | 55 | yield LinkResponse(url, mainUrl, '', MediaQuality.unknown, 56 | title: name, subtitles: subtitles); 57 | } 58 | } else { 59 | for (dynamic s in sources) { 60 | //TODO: Fetch from url 61 | if (s is Map) { 62 | print(s); 63 | yield LinkResponse(url, mainUrl, '', MediaQuality.unknown, 64 | title: name, subtitles: subtitles); 65 | } else {} 66 | } 67 | } 68 | } 69 | 70 | Future _getKey() => 71 | simpleGet('https://raw.githubusercontent.com/enimax-anime/key/e4/key.txt', 72 | responseType: ResponseType.plain) 73 | .then((value) => value.data); 74 | } 75 | -------------------------------------------------------------------------------- /lib/extractor/extractors/stream_hub_extractor.dart: -------------------------------------------------------------------------------- 1 | import 'package:dio/dio.dart'; 2 | import 'package:viddroid/constants.dart'; 3 | import 'package:viddroid/extractor/extractor.dart'; 4 | import 'package:viddroid/util/capsules/link.dart'; 5 | import 'package:viddroid/util/capsules/media.dart'; 6 | 7 | import '../../util/extraction/js_packer.dart'; 8 | 9 | class StreamHubExtractor extends Extractor { 10 | StreamHubExtractor() : super('StreamHub', 'https://streamhub.to', 'https://streamhub.to'); 11 | 12 | final RegExp evalRegex = RegExp(r"""eval((.|\n)*?)"""); 13 | final RegExp srcRegex = RegExp(r'sources:\[\{src:"(.*?)"'); 14 | 15 | @override 16 | Stream extract(String url, {Map? headers}) async* { 17 | final Response response = 18 | await simpleGet(url, headers: headers, responseType: ResponseType.plain); 19 | final RegExpMatch regExpMatch = evalRegex.allMatches(response.data).last; 20 | 21 | final String stringMatch = regExpMatch.group(0)!; 22 | 23 | final String? unpackedBody = JSPacker(stringMatch).unpack(); 24 | 25 | if (unpackedBody == null) { 26 | return; 27 | } 28 | 29 | final String? srcMatch = srcRegex.firstMatch(unpackedBody)?.group(1); 30 | 31 | if (srcMatch == null) { 32 | return; 33 | } 34 | yield LinkResponse(srcMatch, url, '', MediaQuality.unknown, title: name); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /lib/extractor/extractors/stream_tape_extractor.dart: -------------------------------------------------------------------------------- 1 | import 'package:dio/dio.dart'; 2 | import 'package:viddroid/extractor/extractor.dart'; 3 | import 'package:viddroid/util/capsules/link.dart'; 4 | import 'package:viddroid/util/capsules/media.dart'; 5 | 6 | import '../../constants.dart'; 7 | 8 | class StreamTapeExtractor extends Extractor { 9 | StreamTapeExtractor() 10 | : super('StreamTape', 'https://streamtape.com', 'https://streamtape.com', 11 | altUrls: ['https://streamtape.net']); 12 | 13 | final linkRegex = RegExp(r"""'robotlink'\)\.innerHTML = '(.+?)'\+ \('(.+?)'\)"""); 14 | 15 | @override 16 | Stream extract(String url, {Map? headers}) async* { 17 | final Response urlResponse = await simpleGet(url, headers: headers); 18 | 19 | final String responseBody = urlResponse.data; 20 | final RegExpMatch? regExpMatch = linkRegex.firstMatch(responseBody); 21 | 22 | if (regExpMatch == null) { 23 | return; 24 | } 25 | 26 | final String directUrl = 'https:${regExpMatch[1]! + regExpMatch[2]!.substring(3)}'; 27 | 28 | yield LinkResponse(directUrl, url, '', MediaQuality.unknown, title: name); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /lib/extractor/extractors/vid_src_extractor.dart: -------------------------------------------------------------------------------- 1 | import 'package:dio/dio.dart'; 2 | import 'package:html/dom.dart'; 3 | import 'package:html/parser.dart'; 4 | import 'package:viddroid/extractor/extractors.dart'; 5 | import 'package:viddroid/util/capsules/link.dart'; 6 | import 'package:viddroid/util/capsules/media.dart'; 7 | import 'package:viddroid/util/extensions/string_extension.dart'; 8 | 9 | import '../../constants.dart'; 10 | import '../extractor.dart'; 11 | 12 | class VidSrcExtractor extends Extractor { 13 | VidSrcExtractor() : super('VidSrc', 'https://v2.vidsrc.me', 'https://v2.vidsrc.me/embed'); 14 | 15 | @override 16 | Stream extract(String url, {Map? headers}) async* { 17 | final Response urlResponse = await simpleGet(url, headers: headers); 18 | final Document document0 = parse(urlResponse.data); 19 | 20 | final List servers = []; 21 | final String? playerUrl = document0.querySelector('#player_iframe')?.attributes['src']; 22 | if (playerUrl == null) { 23 | return; 24 | } 25 | 26 | for (final Element element in document0.querySelectorAll('.source')) { 27 | final String? dataHash = element.attributes['data-hash']; 28 | if (dataHash != null && dataHash.isNotEmpty) { 29 | try { 30 | final Response resp = await simpleGet('$mainUrl/srcrcp/$dataHash', 31 | headers: {'referer': 'https://rcp.vidsrc.me/'}); 32 | servers.add(resp.realUri.toString()); 33 | } catch (e) { 34 | logger.e(e); 35 | } 36 | } 37 | } 38 | 39 | for (final String server in servers) { 40 | final String fixedLink = server.replaceAll('https://vidsrc.xyz/', 'https://embedsito.com/'); 41 | 42 | if (fixedLink.contains('/prorcp')) { 43 | final Response srcResp = await simpleGet(server, headers: {'referer': mainUrl}); 44 | final String respBody = srcResp.data; 45 | 46 | final RegExp m3u8Regex = RegExp(r'((https:|http:)//.*\.m3u8)'); 47 | 48 | final String? srcm3u8 = m3u8Regex.stringMatch(respBody); 49 | 50 | final RegExp passRegex = RegExp(r"""['"](.*set_pass[^"']*)"""); 51 | 52 | final String? pass = 53 | passRegex.firstMatch(respBody)?.group(1)?.replaceAll("^//", 'https://'); 54 | 55 | if (pass != null && srcm3u8 != null) { 56 | yield LinkResponse(srcm3u8, 'https://vidsrc.stream/', pass, MediaQuality.unknown, 57 | title: name, header: {'TE': 'trailers'}); 58 | } 59 | } else { 60 | final Extractor? extractor = Extractors().findExtractor(server.extractMainUrl); 61 | if (extractor != null) { 62 | yield* extractor.extract(server); 63 | } 64 | } 65 | } 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /lib/extractor/extractors/vidoza_extractor.dart: -------------------------------------------------------------------------------- 1 | import 'package:dio/dio.dart'; 2 | import 'package:html/dom.dart'; 3 | import 'package:html/parser.dart'; 4 | import 'package:viddroid/constants.dart'; 5 | import 'package:viddroid/extractor/extractor.dart'; 6 | import 'package:viddroid/util/capsules/link.dart'; 7 | import 'package:viddroid/util/capsules/media.dart'; 8 | 9 | class VidozaExtractor extends Extractor { 10 | VidozaExtractor() : super('Vidoza', 'https://vidoza.net', 'https://vidoza.net'); 11 | 12 | @override 13 | Stream extract(String url, {Map? headers}) async* { 14 | final Response response = 15 | await simpleGet(url, headers: headers, responseType: ResponseType.plain); 16 | 17 | if (response.data == null) { 18 | return; 19 | } 20 | 21 | final Document document = parse(response.data); 22 | final String? source = document.querySelector('#player > source')?.attributes['src']; 23 | final RegExp qualityRegex = RegExp(r'window\.pData(?:.|\n)+(?<=height: ?")([^"]+)'); 24 | 25 | if (source != null) { 26 | yield LinkResponse(source, url, '', 27 | MediaQualityExtension.fromString(qualityRegex.firstMatch(response.data!)?[1])); 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /lib/main.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:local_notifier/local_notifier.dart'; 3 | import 'package:media_kit/media_kit.dart'; 4 | import 'package:viddroid/android_tv/main_view_android.dart'; 5 | import 'package:viddroid/util/custom_scroll_behaviour.dart'; 6 | import 'package:viddroid/util/setting/settings.dart'; 7 | import 'package:viddroid/util/watchable/watchables.dart'; 8 | import 'package:viddroid/views/main_view.dart'; 9 | 10 | import 'constants.dart'; 11 | 12 | void main() async { 13 | WidgetsFlutterBinding.ensureInitialized(); 14 | MediaKit.ensureInitialized(); 15 | await Settings().init(); 16 | await Watchables().init(); 17 | /// Local notifier does not work for the web, obviously 18 | await localNotifier.setup( 19 | appName: 'Viddroid', 20 | // The parameter shortcutPolicy only works on Windows 21 | shortcutPolicy: ShortcutPolicy.requireCreate, 22 | ); 23 | runApp(const MyApp()); 24 | } 25 | 26 | class MyApp extends StatelessWidget { 27 | const MyApp({super.key}); 28 | 29 | // This widget is the root of your application. 30 | @override 31 | Widget build(BuildContext context) { 32 | return MaterialApp( 33 | title: 'Viddroid', 34 | debugShowCheckedModeBanner: false, 35 | theme: ThemeData(colorSchemeSeed: Colors.blueGrey, useMaterial3: true), 36 | darkTheme: ThemeData( 37 | useMaterial3: true, 38 | colorSchemeSeed: Colors.blueGrey, 39 | brightness: Brightness.dark, 40 | ), 41 | themeMode: ThemeMode.system, 42 | scrollBehavior: CustomScrollBehaviour(), 43 | home: isMobile ? const AndroidMainView(title: 'Viddroid') : const MainView(title: 'Viddroid'), 44 | ); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /lib/provider/provider.dart: -------------------------------------------------------------------------------- 1 | import '../util/capsules/fetch.dart'; 2 | import '../util/capsules/link.dart'; 3 | import '../util/capsules/media.dart'; 4 | import '../util/capsules/search.dart'; 5 | 6 | abstract class SiteProvider { 7 | final String name; 8 | final String mainUrl; 9 | final List types; 10 | final String language; 11 | 12 | const SiteProvider(this.name, this.mainUrl, this.types, this.language); 13 | 14 | Future> search(final String query); 15 | 16 | Future fetch(final SearchResponse searchResponse); 17 | 18 | Stream load(final LoadRequest loadRequest); 19 | } 20 | -------------------------------------------------------------------------------- /lib/provider/providers.dart: -------------------------------------------------------------------------------- 1 | import 'package:dio/dio.dart'; 2 | import 'package:viddroid/constants.dart'; 3 | import 'package:viddroid/provider/provider.dart'; 4 | import 'package:viddroid/provider/providers/allmoviesforyou.dart'; 5 | import 'package:viddroid/provider/providers/anime_pahe.dart'; 6 | import 'package:viddroid/provider/providers/dopebox.dart'; 7 | import 'package:viddroid/provider/providers/goku.dart'; 8 | import 'package:viddroid/provider/providers/hdtoday.dart'; 9 | import 'package:viddroid/provider/providers/movies.dart'; 10 | import 'package:viddroid/provider/providers/primewire.dart'; 11 | import 'package:viddroid/provider/providers/sflix.dart'; 12 | import 'package:viddroid/provider/providers/solarmovie.dart'; 13 | import 'package:viddroid/provider/providers/vidsrc.dart'; 14 | import 'package:viddroid/util/capsules/fetch.dart'; 15 | import 'package:viddroid/util/capsules/search.dart'; 16 | 17 | import '../util/capsules/link.dart'; 18 | import '../util/capsules/media.dart'; 19 | import '../util/setting/settings.dart'; 20 | 21 | class Providers { 22 | static final Providers _instance = Providers.inst(); 23 | 24 | factory Providers() { 25 | return _instance; 26 | } 27 | 28 | Providers.inst(); 29 | 30 | final List siteProviders = [ 31 | Movies123(), 32 | Sflix(), 33 | VidSrc(), 34 | AllMoviesForYou(), 35 | AnimePahe(), 36 | DopeBox(), 37 | HdToday(), 38 | PrimeWire(), 39 | Goku(), 40 | SolarMovie(), 41 | ]; 42 | 43 | Future> providers() async => await Settings().getSelectedProviders(); 44 | 45 | SiteProvider provider(final String apiName) { 46 | return siteProviders.where((element) => element.name == apiName).first; 47 | } 48 | 49 | Stream> search(final String query, final List searchTypes) async* { 50 | final List pvds = await providers(); 51 | for (final SiteProvider provider in pvds) { 52 | if (searchTypes.any((element) => provider.types.contains(element))) { 53 | try { 54 | final List searchResponses = await provider.search(query); 55 | yield searchResponses; 56 | } catch (e, trace) { 57 | if (e is DioException) { 58 | logger.e('Error while searching the url: ${e.response?.realUri}.'); 59 | } 60 | logger.e('An error occurred while searching one of the providers.', error: e, stackTrace: trace); 61 | } 62 | } 63 | } 64 | } 65 | 66 | Future fetch(final SearchResponse searchResponse) async { 67 | return siteProviders.firstWhere((element) => element.name == searchResponse.apiName).fetch(searchResponse); 68 | } 69 | 70 | Stream load(final LoadRequest loadRequest) async* { 71 | await for (LinkResponse lr in siteProviders.firstWhere((element) => element.name == loadRequest.apiName).load(loadRequest)) { 72 | yield lr; 73 | } 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /lib/provider/providers/allmoviesforyou.dart: -------------------------------------------------------------------------------- 1 | import 'package:dio/dio.dart'; 2 | import 'package:html/dom.dart'; 3 | import 'package:html/parser.dart'; 4 | import 'package:viddroid/constants.dart'; 5 | import 'package:viddroid/extractor/extractor.dart'; 6 | import 'package:viddroid/extractor/extractors.dart'; 7 | import 'package:viddroid/provider/provider.dart'; 8 | import 'package:viddroid/util/capsules/fetch.dart'; 9 | import 'package:viddroid/util/capsules/link.dart'; 10 | import 'package:viddroid/util/capsules/media.dart'; 11 | import 'package:viddroid/util/capsules/search.dart'; 12 | import 'package:viddroid/util/extensions/string_extension.dart'; 13 | 14 | class AllMoviesForYou extends SiteProvider { 15 | AllMoviesForYou() 16 | : super('AllMoviesForYou', 'https://allmoviesforyou.net', [TvType.tv, TvType.movie], 'eng'); 17 | 18 | @override 19 | Future> search(String query) async { 20 | final Response response = 21 | await simpleGet('$mainUrl/?s=$query', responseType: ResponseType.plain); 22 | final Document document = parse(response.data); 23 | 24 | final List items = document.querySelectorAll('ul.MovieList > li > article > a'); 25 | 26 | return items.map((e) { 27 | final String href = e.attributes['href']!; 28 | final String title = e.querySelector('h2.Title')?.text ?? 'N/A'; 29 | final bool isMovie = href.contains('/movies/'); 30 | 31 | final String thumbnail = 'https:${(e.querySelector('img')?.attributes['data-src']) ?? ''}'; 32 | 33 | if (isMovie) { 34 | return MovieSearchResponse(title, href, name, thumbnail: thumbnail); 35 | } else { 36 | return TvSearchResponse(title, href, name, thumbnail: thumbnail); 37 | } 38 | }).toList(); 39 | } 40 | 41 | @override 42 | Future fetch(SearchResponse searchResponse) async { 43 | final String url = searchResponse.url; 44 | final Response response = await simpleGet(url, responseType: ResponseType.plain); 45 | final Document document = parse(response.data); 46 | 47 | final String title = document.querySelector('h1.Title')?.text ?? 'K/A'; 48 | final String? description = document.querySelector('div.Description > p')?.text; 49 | 50 | final String? year = document.querySelector('span.Date')?.text; 51 | 52 | final String? backgroundRelative = 53 | document.querySelector('div.Image > figure > img')?.attributes['src']; 54 | 55 | final String? backgroundImage = backgroundRelative != null ? 'http:$backgroundRelative' : null; 56 | 57 | final String? duration = document.querySelector('span.Time')?.text; 58 | 59 | if (searchResponse.type == TvType.tv) { 60 | final List episodes = []; 61 | final List seasons = 62 | document.querySelectorAll('main > section.SeasonBx > div > div.Title > a'); 63 | 64 | for (int i = 0; i < seasons.length; i++) { 65 | final Element seasonElement = seasons[i]; 66 | final String? href = seasonElement.attributes['href']; 67 | if (href == null) { 68 | continue; 69 | } 70 | 71 | final Response response = await simpleGet(href, responseType: ResponseType.plain); 72 | final Document document = parse(response.data); 73 | 74 | final List episodeElements = document.querySelectorAll('table > tbody > tr'); 75 | 76 | for (int j = 0; j < episodeElements.length; j++) { 77 | final Element episodeElement = episodeElements[j]; 78 | final String name = episodeElement.querySelector('.MvTbTtl > a')?.text ?? 'Episode $j'; 79 | final String? href = episodeElement.querySelector('.MvTbTtl > a')?.attributes['href']; 80 | 81 | if (href == null) { 82 | continue; 83 | } 84 | 85 | final String? thumbnailRelative = 86 | episodeElement.querySelector('.MvTbImg > img')?.attributes['src']; 87 | final String? thumbnail = thumbnailRelative != null ? 'http:$thumbnailRelative' : null; 88 | 89 | episodes.add(Episode(name, j, i, thumbnail, href)); 90 | } 91 | } 92 | 93 | return TvFetchResponse(title, url, name, searchResponse.type, url, 94 | episodes: episodes, 95 | seasons: seasons.length, 96 | backgroundImage: backgroundImage, 97 | thumbnail: searchResponse.thumbnail, 98 | year: year, 99 | duration: duration, 100 | description: description); 101 | } else { 102 | return MovieFetchResponse(title, url, name, searchResponse.type, url, 103 | thumbnail: searchResponse.thumbnail, 104 | backgroundImage: backgroundImage, 105 | year: year, 106 | duration: duration, 107 | description: description); 108 | } 109 | } 110 | 111 | @override 112 | Stream load(LoadRequest loadRequest) async* { 113 | final Response response = await simpleGet(loadRequest.data, responseType: ResponseType.plain); 114 | final Document document = parse(response.data); 115 | 116 | final List iframes = document.querySelectorAll('body iframe'); 117 | 118 | for (final Element iframe in iframes) { 119 | final String? src = iframe.attributes['src']; 120 | if (src == null) { 121 | continue; 122 | } 123 | 124 | if (src.contains('trembed')) { 125 | final Response apiResponse = await simpleGet(src, responseType: ResponseType.plain); 126 | final Document document = parse(apiResponse.data); 127 | 128 | final List iframes = document.querySelectorAll('body iframe'); 129 | for (Element element in iframes) { 130 | final String link = element.attributes['src']!; 131 | final Extractor? extractor = Extractors().findExtractor(link.extractMainUrl); 132 | if (extractor == null) { 133 | continue; 134 | } 135 | yield* extractor.extract(link, headers: {'referer': loadRequest.data}); 136 | } 137 | } else { 138 | final Extractor? extractor = Extractors().findExtractor(src.extractMainUrl); 139 | 140 | if (extractor == null) { 141 | continue; 142 | } 143 | yield* extractor.extract(src, headers: {'referer': loadRequest.data}); 144 | } 145 | } 146 | } 147 | } 148 | -------------------------------------------------------------------------------- /lib/provider/providers/aniflix.dart: -------------------------------------------------------------------------------- 1 | import 'dart:convert'; 2 | 3 | import 'package:dio/dio.dart'; 4 | import 'package:viddroid/constants.dart'; 5 | import 'package:viddroid/extractor/extractor.dart'; 6 | import 'package:viddroid/extractor/extractors.dart'; 7 | import 'package:viddroid/provider/provider.dart'; 8 | import 'package:viddroid/util/capsules/fetch.dart'; 9 | import 'package:viddroid/util/capsules/link.dart'; 10 | import 'package:viddroid/util/capsules/media.dart'; 11 | import 'package:viddroid/util/capsules/search.dart'; 12 | import 'package:viddroid/util/extensions/string_extension.dart'; 13 | 14 | class Aniflix extends SiteProvider { 15 | Aniflix() : super('Aniflix', 'https://www.aniflix.cc/', [TvType.anime], 'de'); 16 | 17 | @override 18 | Future> search(String query) async { 19 | //https://www.aniflix.cc/api/show/search (API) 20 | //Post api with param search = query 21 | 22 | final Response response = await simplePost('https://www.aniflix.cc/api/show/search', 23 | jsonEncode({'search': query}), //Dio does not add quotation marks 24 | headers: { 25 | 'content-type': 'application/json;charset=utf-8', 26 | }); 27 | 28 | final dynamic jsonList = response.data; 29 | final List list = []; 30 | 31 | for (dynamic jsonObject in jsonList) { 32 | final String title = jsonObject['name'] ?? 'N/A'; 33 | final String id = jsonObject['url']; 34 | final String? thumbnailRelative = jsonObject['cover_portrait']; 35 | //Description available 36 | 37 | final String thumbnail = 'https://www.aniflix.cc/storage/$thumbnailRelative'; 38 | final bool isMovie = title.contains('(Movie)'); 39 | 40 | if (isMovie) { 41 | list.add(MovieSearchResponse(title, id, name, thumbnail: thumbnail)); 42 | } else { 43 | list.add(TvSearchResponse(title, id, name, thumbnail: thumbnail)); 44 | } 45 | } 46 | return list; 47 | } 48 | 49 | @override 50 | Future fetch(final SearchResponse searchResponse) async { 51 | //https://www.aniflix.cc/api/show/made-in-abyss (API) 52 | final String url = 'https://www.aniflix.cc/api/show/${searchResponse.url}'; 53 | final Response response = await simpleGet(url); 54 | final dynamic jsonObject = response.data; 55 | 56 | final String title = jsonObject['name'] ?? 'N/A'; 57 | final String? description = jsonObject['description']; 58 | 59 | final String? backgroundRelative = jsonObject['cover_landscape']; 60 | final String background = 'https://www.aniflix.cc/storage/$backgroundRelative'; 61 | final String? thumbnailRelative = jsonObject['cover_portrait']; 62 | final String thumbnail = 'https://www.aniflix.cc/storage/$thumbnailRelative'; 63 | 64 | //There are only tv-shows with aniflix... 65 | final List episodes = []; 66 | final dynamic seasonsList = jsonObject['seasons']; 67 | 68 | for (int j = 0; j < seasonsList.length; j++) { 69 | final dynamic seasonObject = seasonsList[j]; 70 | final dynamic episodeList = seasonObject['episodes']; 71 | 72 | for (int i = 0; i < episodeList.length; i++) { 73 | final dynamic episodeObject = episodeList[i]; 74 | final String name = episodeObject['name'] ?? 'N/A'; 75 | 76 | //Episode data 77 | episodes.add(Episode(name, i, j, '', searchResponse.url)); 78 | } 79 | } 80 | return TvFetchResponse(title, url, name, TvType.tv, searchResponse.url, 81 | episodes: episodes, 82 | seasons: seasonsList.length, 83 | backgroundImage: background, 84 | thumbnail: thumbnail, 85 | description: description); 86 | } 87 | 88 | @override 89 | Stream load(LoadRequest loadRequest) async* { 90 | //https://www.aniflix.cc/api/episode/show/made-in-abyss/season/0/episode/1 91 | if (loadRequest is TvLoadRequest) { 92 | final String url = 93 | 'https://www.aniflix.cc/api/episode/show/${loadRequest.data}/season/${loadRequest.season + 1}/episode/${loadRequest.episode + 1}'; 94 | 95 | final Response response = await simpleGet(url); 96 | 97 | final dynamic streamList = response.data['streams']; 98 | 99 | for (final dynamic streamObject in streamList) { 100 | final String? streamUrl = streamObject['link']; 101 | if (streamUrl == null) { 102 | continue; 103 | } 104 | final Extractor? extractor = Extractors().findExtractor(streamUrl.extractMainUrl); 105 | if (extractor == null) { 106 | continue; 107 | } 108 | yield* extractor.extract(streamUrl, headers: {'referer': mainUrl}); 109 | } 110 | } 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /lib/provider/providers/anime_pahe.dart: -------------------------------------------------------------------------------- 1 | import 'package:dio/dio.dart'; 2 | import 'package:html/dom.dart'; 3 | import 'package:html/parser.dart'; 4 | import 'package:viddroid/constants.dart'; 5 | import 'package:viddroid/util/capsules/fetch.dart'; 6 | import 'package:viddroid/util/capsules/link.dart'; 7 | import 'package:viddroid/util/capsules/media.dart'; 8 | import 'package:viddroid/util/capsules/search.dart'; 9 | import 'package:viddroid/util/extraction/js_packer.dart'; 10 | 11 | import '../provider.dart'; 12 | 13 | class AnimePahe extends SiteProvider { 14 | AnimePahe() 15 | : super( 16 | 'Anime Pahe', 17 | 'https://animepahe.ru', 18 | [TvType.anime], 19 | 'eng', 20 | ); 21 | 22 | @override 23 | Future> search(String query) async { 24 | final Response response = await simpleGet('$mainUrl/api?m=search&q=$query', headers: { 25 | 'referer': mainUrl, 26 | }); 27 | 28 | final dynamic entries = response.data['data']; 29 | 30 | final List responses = []; 31 | 32 | for (dynamic entry in entries) { 33 | final String title = entry['title']; 34 | final String thumbnail = entry['poster']; 35 | 36 | final bool isTv = entry['type'] == 'TV'; 37 | 38 | if (isTv) { 39 | responses.add(TvSearchResponse(title, entry['session'], name, thumbnail: thumbnail)); 40 | } else { 41 | responses.add(MovieSearchResponse(title, entry['session'], name, thumbnail: thumbnail)); 42 | } 43 | } 44 | return responses; 45 | } 46 | 47 | @override 48 | Future fetch(SearchResponse searchResponse) async { 49 | //Important later on.. 50 | final String siteUrl = '$mainUrl/anime/${searchResponse.url}'; 51 | final Response response = await simpleGet(siteUrl); 52 | 53 | final Document document = parse(response.data); 54 | final String documentBody = response.data; 55 | 56 | final String title = document.querySelector('.title-wrapper > h2')?.text ?? 'N/A'; 57 | final String? year = 58 | RegExp(r'Aired:[^,]*, (\d+)').firstMatch(documentBody)?.group(1); 59 | 60 | final String? backgroundRelative = 61 | document.querySelector('.anime-cover')?.attributes['data-src']; 62 | final String? backgroundImage = backgroundRelative == null ? null : 'https:$backgroundRelative'; 63 | 64 | final String? thumbnail = document.querySelector('.anime-poster > a')?.attributes['href']; 65 | 66 | final String? description = document.querySelector('.anime-synopsis')?.text; 67 | final String? duration = RegExp(r'Duration:([^<]+)') 68 | .firstMatch(documentBody) 69 | ?.group(1) 70 | ?.substring(0, 3); 71 | 72 | final bool isMovie = 73 | document.querySelector('.col-sm-4.anime-info > p > strong > a')?.attributes['title'] == 74 | 'Movie'; 75 | 76 | //This api has basically become useless, as Animepahe now embeds their videos into their site, why soever? 77 | final Response episodeApiResponse = 78 | await simpleGet('$mainUrl/api?m=release&id=${searchResponse.url}&sort=episode_asc&page=1'); 79 | 80 | final dynamic responseJson = episodeApiResponse.data; 81 | 82 | if (isMovie) { 83 | final String referral = document.querySelector('.play')!.text; 84 | 85 | // final String sessionId = responseJson['data'][0]['session']; 86 | return MovieFetchResponse(title, referral, name, searchResponse.type, referral, 87 | backgroundImage: backgroundImage, 88 | thumbnail: thumbnail, 89 | description: description, 90 | year: year, 91 | duration: duration); 92 | } else { 93 | final List episodes = []; 94 | 95 | final int pages = responseJson['last_page']; 96 | //iterate through the pages 97 | 98 | for (int i = 1; i < pages + 1; i++) { 99 | final Response episodeApiResponse = await simpleGet( 100 | 'https://animepahe.ru/api?m=release&id=${searchResponse.url}&sort=episode_asc&page=$i'); 101 | final dynamic responseJson = episodeApiResponse.data; 102 | 103 | final dynamic episodesJson = responseJson['data']; 104 | 105 | for (int j = 0; j < episodesJson.length; j++) { 106 | final dynamic episode = episodesJson[j]; 107 | final String episodeUrl = '$mainUrl/play/${searchResponse.url}/${episode['session']}'; 108 | 109 | final String? thumbnail = episode['snapshot']; 110 | final String name = episode['title'] ?? 'Episode: ${episodes.length}'; 111 | 112 | episodes.add(Episode(name, episodes.length, 0, thumbnail, episodeUrl)); 113 | } 114 | } 115 | 116 | return TvFetchResponse(title, 'url', name, searchResponse.type, '', 117 | seasons: 1, 118 | episodes: episodes, 119 | backgroundImage: backgroundImage, 120 | thumbnail: thumbnail, 121 | description: description, 122 | year: year, 123 | duration: duration); 124 | } 125 | } 126 | 127 | @override 128 | Stream load(LoadRequest loadRequest) async* { 129 | //dio.Dio().get(loadRequest.data).then((value) => print(value)); 130 | final Response response = await simpleGet(loadRequest.data, headers: {'referer': mainUrl}); 131 | final Document document = parse(response.data); 132 | final List items = document.querySelectorAll('#resolutionMenu > .dropdown-item'); 133 | 134 | for (final Element element in items) { 135 | final String kwik = element.attributes['data-src']!; 136 | final String quality = element.attributes['data-resolution']!; 137 | 138 | //Extract from kwik 139 | 140 | final Response response = await simpleGet(kwik, headers: {'referer': mainUrl}); 141 | final String responseBody = response.data; 142 | 143 | final String? scriptMatch = RegExp(r'(eval(.|\n)*?)').firstMatch(responseBody)?[1]; 144 | 145 | if (scriptMatch != null) { 146 | final String? unpacked = JSPacker(scriptMatch).unpack(); 147 | 148 | //print(scriptMatch); 149 | 150 | if (unpacked != null) { 151 | final String? sourceMatch = RegExp(r"(?<=const source=')[^']+").stringMatch(unpacked); 152 | if (sourceMatch != null) { 153 | yield LinkResponse(sourceMatch, kwik, '', MediaQualityExtension.fromString(quality), 154 | title: 'kwick'); 155 | } 156 | } 157 | } 158 | } 159 | } 160 | } 161 | -------------------------------------------------------------------------------- /lib/provider/providers/dopebox.dart: -------------------------------------------------------------------------------- 1 | import 'package:viddroid/provider/providers/sflix.dart'; 2 | 3 | class DopeBox extends Sflix { 4 | @override 5 | String get mainUrl => 'https://dopebox.to'; 6 | 7 | @override 8 | String get name => 'Dopebox'; 9 | } 10 | -------------------------------------------------------------------------------- /lib/provider/providers/goku.dart: -------------------------------------------------------------------------------- 1 | import 'dart:convert'; 2 | 3 | import 'package:dio/dio.dart'; 4 | import 'package:html/dom.dart'; 5 | import 'package:html/parser.dart'; 6 | import 'package:viddroid/provider/provider.dart'; 7 | import 'package:viddroid/util/capsules/fetch.dart'; 8 | import 'package:viddroid/util/capsules/link.dart'; 9 | import 'package:viddroid/util/capsules/search.dart'; 10 | import 'package:viddroid/util/extensions/string_extension.dart'; 11 | 12 | import '../../constants.dart'; 13 | import '../../extractor/extractor.dart'; 14 | import '../../extractor/extractors.dart'; 15 | import '../../util/capsules/media.dart'; 16 | 17 | class Goku extends SiteProvider { 18 | Goku() : super('Goku', 'https://goku.to', [TvType.tv, TvType.movie], 'eng'); 19 | 20 | @override 21 | Future> search(final String query) async { 22 | final Response response = await simpleGet('$mainUrl/ajax/movie/search?keyword=$query'); 23 | final Document document = parse(response.data); 24 | 25 | final List items = document.querySelectorAll('.item'); 26 | return items.map((e) { 27 | final String href = e.querySelector('div.is-watch > a')?.attributes['href'] ?? 28 | 'null'; //TODO: Fix the missing attribute 29 | 30 | final String title = e.querySelector('h3.movie-name')?.text ?? 'N/A'; 31 | 32 | final bool isMovie = href.contains('/watch-movie/'); 33 | 34 | final String? thumbnail = e.querySelector('img')?.attributes['src']; 35 | 36 | if (isMovie) { 37 | return MovieSearchResponse(title, href, name, thumbnail: thumbnail); 38 | } else { 39 | return TvSearchResponse(title, href, name, thumbnail: thumbnail); 40 | } 41 | }).toList(); 42 | } 43 | 44 | @override 45 | Future fetch(SearchResponse searchResponse) async { 46 | final String url = searchResponse.url; 47 | 48 | final Response response = await simpleGet('$mainUrl/$url'); 49 | final Document document = parse(response.data); 50 | 51 | final String? backgroundUrl = document.querySelector('img.is-cover')?.attributes['src']; 52 | 53 | final Element? thumbnailElement = document.querySelector('.movie-thumbnail > img'); 54 | 55 | final RegExp dataIdRegex = RegExp(r"(?<=.+id: ')[^']+"); 56 | 57 | final String dataId = 58 | dataIdRegex.stringMatch(response.data) ?? url.substring(url.lastIndexOf('-') + 1); 59 | 60 | // Meta-data 61 | final String? thumbnail = thumbnailElement?.attributes['src']; 62 | final String title = thumbnailElement?.attributes['alt'] ?? 'N/A'; 63 | 64 | final String? duration = document.querySelector('.fs-item > .duration')?.text; 65 | final String? year = document.querySelector('elements .col-xl-5 .row-line')?.text; 66 | 67 | final String? description = 68 | document.querySelector('.dropdown-text > .dropdown-text')?.text.trim(); 69 | 70 | final bool isMovie = url.contains('movie'); 71 | 72 | if (isMovie) { 73 | return MovieFetchResponse(title, url, name, TvType.movie, dataId, 74 | thumbnail: thumbnail, 75 | duration: duration, 76 | year: year, 77 | backgroundImage: backgroundUrl, 78 | description: description); 79 | } else { 80 | final Response apiSeasons = await simpleGet('$mainUrl/ajax/movie/seasons/$dataId'); 81 | final Document seasonsDocument = parse(apiSeasons.data); 82 | 83 | List seasonElements = 84 | seasonsDocument.querySelectorAll('div.dropdown-menu.dropdown-primary > a'); 85 | 86 | if (seasonElements.isEmpty) { 87 | seasonElements = seasonsDocument.querySelectorAll('div.dropdown-menu > a.dropdown-item'); 88 | } 89 | 90 | final List episodes = []; 91 | 92 | for (int i = 0; i < seasonElements.length; i++) { 93 | final Element value = seasonElements[i]; 94 | final String? seasonId = value 95 | .attributes['data-id']; //Data-id has to be given. If not, the seasons would be invalid 96 | if (seasonId == null) { 97 | continue; 98 | } 99 | 100 | final Response apiEpisodes = 101 | await simpleGet('$mainUrl/ajax/movie/season/episodes/$seasonId'); 102 | final Document episodesDocument = parse(apiEpisodes.data); 103 | 104 | List episodeElements = episodesDocument.querySelectorAll('div.item > a'); 105 | 106 | episodeElements.asMap().forEach((index, value) { 107 | final String title = value.attributes['title'] ?? value.text; 108 | final String? episodeId = value.attributes['data-id']; 109 | if (episodeId == null) { 110 | return; 111 | } 112 | 113 | episodes.add(Episode(title, index, i, null, episodeId)); 114 | }); 115 | } 116 | 117 | return TvFetchResponse(title, url, name, TvType.tv, dataId, 118 | seasons: seasonElements.length, 119 | episodes: episodes, 120 | backgroundImage: backgroundUrl, 121 | thumbnail: thumbnail, 122 | duration: duration, 123 | description: description); 124 | } 125 | } 126 | 127 | @override 128 | Stream load(LoadRequest loadRequest) async* { 129 | final String url = loadRequest.type == TvType.movie 130 | ? '$mainUrl/ajax/movie/episodes/${loadRequest.data}' 131 | : '$mainUrl/ajax/movie/episode/servers/${loadRequest.data}'; 132 | 133 | final Response response = await simpleGet(url); 134 | final Document document = parse(response.data); 135 | 136 | final List ids = document 137 | .querySelectorAll('a') 138 | .where((element) => element.attributes['data-id'] != null) 139 | .map((e) { 140 | final String dataId = e.attributes['data-id']!; 141 | return dataId; 142 | }).toList(); 143 | 144 | for (String serverId in ids) { 145 | final Response response = await simpleGet( 146 | '$mainUrl/ajax/movie/episode/server/sources/$serverId', 147 | responseType: ResponseType.plain); 148 | 149 | final Map json = jsonDecode(response.data); 150 | final String? link = json['data']['link']; 151 | 152 | if (link != null) { 153 | final Extractor? extractor = Extractors().findExtractor(link.extractMainUrl); 154 | if (extractor != null) { 155 | yield* extractor.extract(link); 156 | } 157 | } 158 | } 159 | } 160 | } 161 | -------------------------------------------------------------------------------- /lib/provider/providers/hdtoday.dart: -------------------------------------------------------------------------------- 1 | import 'package:viddroid/provider/providers/sflix.dart'; 2 | 3 | class HdToday extends Sflix { 4 | @override 5 | String get mainUrl => 'https://hdtoday.cc'; 6 | 7 | @override 8 | String get name => 'HDToday'; 9 | } 10 | -------------------------------------------------------------------------------- /lib/provider/providers/primewire.dart: -------------------------------------------------------------------------------- 1 | import 'dart:convert'; 2 | 3 | import 'package:dio/dio.dart'; 4 | import 'package:html/dom.dart'; 5 | import 'package:html/parser.dart'; 6 | import 'package:viddroid/provider/provider.dart'; 7 | import 'package:viddroid/util/capsules/fetch.dart'; 8 | import 'package:viddroid/util/capsules/link.dart'; 9 | import 'package:viddroid/util/capsules/media.dart'; 10 | import 'package:viddroid/util/capsules/search.dart'; 11 | import 'package:viddroid/util/extensions/string_extension.dart'; 12 | 13 | import '../../constants.dart'; 14 | import '../../extractor/extractor.dart'; 15 | import '../../extractor/extractors.dart'; 16 | 17 | class PrimeWire extends SiteProvider { 18 | PrimeWire() 19 | : super( 20 | 'Primewire', 21 | 'https://primewire.mx', 22 | [ 23 | TvType.tv, 24 | TvType.movie, 25 | ], 26 | 'eng', 27 | ); 28 | 29 | @override 30 | Future> search(String query) async { 31 | //Similar to sflix, and others 32 | final Response response = await simpleGet('$mainUrl/search/${query.replaceAll(' ', '-')}'); 33 | final Document document = parse(response.data); 34 | 35 | final List items = document.querySelectorAll('.fbr-line.fbr-content'); 36 | return items.map((e) { 37 | final Element titleElement = e.querySelector('.film-name > a')!; 38 | 39 | final String href = titleElement.attributes['href']!; 40 | final String title = titleElement.text; 41 | final bool isMovie = href.contains('/movie/'); 42 | 43 | final String? thumbnail = e.querySelector('img')?.attributes['data-src']; 44 | 45 | if (isMovie) { 46 | return MovieSearchResponse(title, href, name, thumbnail: thumbnail); 47 | } else { 48 | return TvSearchResponse(title, href, name, thumbnail: thumbnail); 49 | } 50 | }).toList(); 51 | } 52 | 53 | @override 54 | Future fetch(SearchResponse searchResponse) async { 55 | final String url = searchResponse.url; 56 | 57 | final Response response = await simpleGet('$mainUrl/$url'); 58 | final Document document = parse(response.data); 59 | 60 | //Background url 61 | final RegExp backgroundUrlPattern = RegExp(r'(?<=url\()[^)]+'); 62 | final String? backgroundCss = document.querySelector('.dp-w-cover')?.attributes['style']; 63 | final String? backgroundUrl = backgroundCss == null ? null : backgroundUrlPattern.firstMatch(backgroundCss)?.group(0); 64 | final Element? thumbnailElement = document.querySelector('.film-poster-img'); 65 | 66 | final List detailsElements = document.querySelectorAll('.dp-elements'); 67 | 68 | final String dataId = document.querySelector('watching.detail_page-watch')?.attributes['data-id'] ?? url.substring(url.lastIndexOf('-') + 1); 69 | 70 | // Meta-data 71 | final String? thumbnail = thumbnailElement?.attributes['src']; 72 | final String title = thumbnailElement?.attributes['title'] ?? 'N/A'; 73 | 74 | final String? duration = detailsElements.length < 2 ? null : detailsElements[1].text; 75 | 76 | final String year = detailsElements[0].text; 77 | final String? description = document.querySelector('.description')?.text.trim(); 78 | 79 | final bool isMovie = url.contains('movie'); 80 | 81 | if (isMovie) { 82 | return MovieFetchResponse(title, url, name, TvType.movie, dataId, 83 | thumbnail: thumbnail, duration: duration, year: year, backgroundImage: backgroundUrl, description: description); 84 | } else { 85 | final Response apiSeasons = await simpleGet('$mainUrl/ajax/v2/tv/seasons/$dataId'); 86 | final Document seasonsDocument = parse(apiSeasons.data); 87 | 88 | List seasonElements = seasonsDocument.querySelectorAll('div.dropdown-menu.dropdown-menu-new > a'); 89 | 90 | if (seasonElements.isEmpty) { 91 | seasonElements = seasonsDocument.querySelectorAll('div.dropdown-menu > a.dropdown-item'); 92 | } 93 | 94 | final List episodes = []; 95 | 96 | for (int i = 0; i < seasonElements.length; i++) { 97 | final Element value = seasonElements[i]; 98 | final String? seasonId = value.attributes['data-id']; //Data-id has to be given. If not, the seasons would be invalid 99 | 100 | if (seasonId == null) { 101 | continue; 102 | } 103 | 104 | final Response apiEpisodes = await simpleGet('$mainUrl/ajax/v2/season/episodes/$seasonId'); 105 | final Document episodesDocument = parse(apiEpisodes.data); 106 | 107 | List episodeElements = episodesDocument.querySelectorAll('.nav-item > a'); 108 | 109 | // if (episodeElements.isEmpty) { 110 | // episodeElements = episodesDocument.querySelectorAll('ul > li > a'); 111 | // } 112 | 113 | episodeElements.asMap().forEach((index, value) { 114 | final String title = value.attributes['title'] ?? value.text; 115 | final String? episodeId = value.attributes['data-id']; 116 | if (episodeId == null) { 117 | return; 118 | } 119 | 120 | episodes.add(Episode(title, index, i, null, episodeId)); 121 | }); 122 | } 123 | 124 | return TvFetchResponse(title, url, name, TvType.tv, dataId, 125 | seasons: seasonElements.length, 126 | episodes: episodes, 127 | backgroundImage: backgroundUrl, 128 | thumbnail: thumbnail, 129 | duration: duration, 130 | description: description); 131 | } 132 | } 133 | 134 | @override 135 | Stream load(LoadRequest loadRequest) async* { 136 | final String url = 137 | loadRequest.type == TvType.movie ? '$mainUrl/ajax/movie/episodes/${loadRequest.data}' : '$mainUrl/ajax/v2/episode/servers/${loadRequest.data}'; 138 | 139 | final Response response = await simpleGet(url); 140 | final Document document = parse(response.data); 141 | 142 | final String attributeKey = loadRequest.type == TvType.movie ? 'data-linkid' : 'data-id'; //Whyyyyyy? 143 | 144 | final List ids = document.querySelectorAll('a').where((element) => element.attributes[attributeKey] != null).map((e) { 145 | final String dataId = e.attributes[attributeKey]!; 146 | return dataId; 147 | }).toList(); 148 | 149 | for (String serverId in ids) { 150 | final Response response = await simpleGet('$mainUrl/ajax/get_link/$serverId', responseType: ResponseType.plain); 151 | 152 | final Map json = jsonDecode(response.data); 153 | final String? link = json['link']; 154 | 155 | if (link != null) { 156 | final Extractor? extractor = Extractors().findExtractor(link.extractMainUrl); 157 | if (extractor != null) { 158 | yield* extractor.extract(link); 159 | } 160 | } 161 | } 162 | } 163 | } 164 | -------------------------------------------------------------------------------- /lib/provider/providers/sflix.dart: -------------------------------------------------------------------------------- 1 | import 'dart:convert'; 2 | 3 | import 'package:dio/dio.dart'; 4 | import 'package:html/dom.dart'; 5 | import 'package:html/parser.dart'; 6 | import 'package:viddroid/extractor/extractor.dart'; 7 | import 'package:viddroid/extractor/extractors.dart'; 8 | import 'package:viddroid/provider/provider.dart'; 9 | import 'package:viddroid/util/capsules/fetch.dart'; 10 | import 'package:viddroid/util/capsules/link.dart'; 11 | import 'package:viddroid/util/capsules/search.dart'; 12 | import 'package:viddroid/util/extensions/string_extension.dart'; 13 | 14 | import '../../constants.dart'; 15 | import '../../util/capsules/media.dart'; 16 | 17 | class Sflix extends SiteProvider { 18 | Sflix() : super('Sflix.to', 'https://sflix.to', [TvType.tv, TvType.movie], 'en'); 19 | 20 | @override 21 | Future> search(String query) async { 22 | final Response response = await simpleGet('$mainUrl/search/${query.replaceAll(' ', '-')}'); 23 | final Document document = parse(response.data); 24 | 25 | final List items = document.querySelectorAll('div.flw-item'); 26 | return items.map((e) { 27 | final String href = e.querySelector('a')!.attributes['href']!; 28 | final String title = e.querySelector('h2.film-name')?.text ?? 'N/A'; 29 | final bool isMovie = href.contains('/movie/'); 30 | 31 | final String? thumbnail = e.querySelector('img')?.attributes['data-src']; 32 | 33 | if (isMovie) { 34 | return MovieSearchResponse(title, href, name, thumbnail: thumbnail); 35 | } else { 36 | return TvSearchResponse(title, href, name, thumbnail: thumbnail); 37 | } 38 | }).toList(); 39 | } 40 | 41 | @override 42 | Future fetch(SearchResponse searchResponse) async { 43 | final String url = searchResponse.url; 44 | 45 | final Response response = await simpleGet('$mainUrl/$url'); 46 | final Document document = parse(response.data); 47 | 48 | //Background url 49 | final RegExp backgroundUrlPattern = RegExp(r'(?<=url\()[^)]+'); 50 | final String? backgroundCss = document.querySelector('.cover_follow')?.attributes['style']; 51 | final String? backgroundUrl = 52 | backgroundCss == null ? null : backgroundUrlPattern.firstMatch(backgroundCss)?.group(0); 53 | 54 | final Element detailsElement = document.querySelector('div.detail_page-watch')!; 55 | final Element? thumbnailElement = detailsElement.querySelector('img.film-poster-img'); 56 | 57 | final String dataId = 58 | detailsElement.attributes['data-id'] ?? url.substring(url.lastIndexOf('-') + 1); 59 | // Meta-data 60 | final String? thumbnail = thumbnailElement?.attributes['src']; 61 | final String title = thumbnailElement?.attributes['title'] ?? 'N/A'; 62 | final String? duration = document.querySelector('.fs-item > .duration')?.text; 63 | final String? year = document.querySelector('elements .col-xl-5 .row-line')?.text; 64 | final String? description = 65 | document.querySelector('.description')?.text.replaceAll('Overview:', '').trim(); 66 | 67 | final bool isMovie = url.contains('movie'); 68 | 69 | if (isMovie) { 70 | return MovieFetchResponse(title, url, name, TvType.movie, dataId, 71 | thumbnail: thumbnail, 72 | duration: duration, 73 | year: year, 74 | backgroundImage: backgroundUrl, 75 | description: description); 76 | } else { 77 | final Response apiSeasons = await simpleGet('$mainUrl/ajax/v2/tv/seasons/$dataId'); 78 | final Document seasonsDocument = parse(apiSeasons.data); 79 | 80 | List seasonElements = 81 | seasonsDocument.querySelectorAll('div.dropdown-menu.dropdown-menu-model > a'); 82 | 83 | if (seasonElements.isEmpty) { 84 | seasonElements = seasonsDocument.querySelectorAll('div.dropdown-menu > a.dropdown-item'); 85 | } 86 | 87 | final List episodes = []; 88 | 89 | for (int i = 0; i < seasonElements.length; i++) { 90 | final Element value = seasonElements[i]; 91 | final String? seasonId = value 92 | .attributes['data-id']; //Data-id has to be given. If not, the seasons would be invalid 93 | if (seasonId == null) { 94 | continue; 95 | } 96 | 97 | final Response apiEpisodes = await simpleGet('$mainUrl/ajax/v2/season/episodes/$seasonId'); 98 | final Document episodesDocument = parse(apiEpisodes.data); 99 | 100 | List episodeElements = episodesDocument 101 | .querySelectorAll('div.flw-item.film_single-item.episode-item.eps-item'); 102 | if (episodeElements.isEmpty) { 103 | episodeElements = episodesDocument.querySelectorAll('ul > li > a'); 104 | } 105 | 106 | episodeElements.asMap().forEach((index, value) { 107 | final Element? thumbnailElement = value.querySelector('img'); 108 | final String? thumbnail = thumbnailElement?.attributes['src']; 109 | final String title = thumbnailElement?.attributes['title'] ?? value.text; 110 | final String? episodeId = value.attributes['data-id']; 111 | if (episodeId == null) { 112 | return; 113 | } 114 | 115 | episodes.add(Episode(title, index, i, thumbnail, episodeId)); 116 | }); 117 | } 118 | 119 | return TvFetchResponse(title, url, name, TvType.tv, dataId, 120 | seasons: seasonElements.length, 121 | episodes: episodes, 122 | backgroundImage: backgroundUrl, 123 | thumbnail: thumbnail, 124 | duration: duration, 125 | description: description); 126 | } 127 | } 128 | 129 | @override 130 | Stream load(LoadRequest loadRequest) async* { 131 | final String url = loadRequest.type == TvType.movie 132 | ? '$mainUrl/ajax/movie/episodes/${loadRequest.data}' 133 | : '$mainUrl/ajax/v2/episode/servers/${loadRequest.data}'; 134 | 135 | final Response response = await simpleGet(url); 136 | final Document document = parse(response.data); 137 | 138 | final List ids = document 139 | .querySelectorAll('a') 140 | .where((element) => element.attributes['data-id'] != null) 141 | .map((e) { 142 | final String dataId = e.attributes['data-id']!; 143 | return dataId; 144 | }).toList(); 145 | 146 | for (String serverId in ids) { 147 | final Response response = 148 | await simpleGet('$mainUrl/ajax/get_link/$serverId', responseType: ResponseType.plain); 149 | 150 | // if (response.data.isEmpty) return; 151 | 152 | final Map json = jsonDecode(response.data); 153 | final String? link = json['link']; 154 | 155 | if (link != null) { 156 | final Extractor? extractor = Extractors().findExtractor(link.extractMainUrl); 157 | if (extractor != null) { 158 | yield* extractor.extract(link); 159 | } 160 | } 161 | } 162 | } 163 | } 164 | -------------------------------------------------------------------------------- /lib/provider/providers/solarmovie.dart: -------------------------------------------------------------------------------- 1 | import 'package:viddroid/provider/providers/sflix.dart'; 2 | 3 | class SolarMovie extends Sflix { 4 | @override 5 | String get mainUrl => 'https://solarmovie.pe'; 6 | 7 | @override 8 | String get name => 'SolarMovie'; 9 | } 10 | -------------------------------------------------------------------------------- /lib/provider/providers/vidsrc.dart: -------------------------------------------------------------------------------- 1 | import 'package:viddroid/extractor/extractors/vid_src_extractor.dart'; 2 | import 'package:viddroid/provider/provider.dart'; 3 | import 'package:viddroid/util/capsules/link.dart'; 4 | import 'package:viddroid/util/capsules/media.dart'; 5 | import 'package:viddroid/util/capsules/search.dart'; 6 | import 'package:viddroid/util/extensions/iterable_extension.dart'; 7 | 8 | import '../../util/capsules/fetch.dart'; 9 | import '../../util/movie_provider/the_movie_db.dart'; 10 | 11 | class VidSrc extends SiteProvider { 12 | VidSrc() : super('VidSrcMe', 'https://v2.vidsrc.me', [TvType.tv, TvType.movie], 'en'); 13 | 14 | @override 15 | Future> search(String query) async { 16 | //Search with themoviedb, as vidsrc is an api which only takes in imdb ids. 17 | final List responses = await TheMovieDbApi().search(query); 18 | 19 | for (var element in responses) { 20 | element.url = '$mainUrl/embed/${element.url}'; 21 | element.apiName = name; 22 | } 23 | 24 | return responses; 25 | } 26 | 27 | @override 28 | Future fetch(SearchResponse searchResponse) async { 29 | if (searchResponse.type == TvType.movie) { 30 | return MovieFetchResponse(searchResponse.title, searchResponse.url, searchResponse.apiName, 31 | TvType.movie, searchResponse.id.toString(), 32 | thumbnail: searchResponse.thumbnail, backgroundImage: searchResponse.thumbnail); 33 | } else { 34 | final List episodes = 35 | await TheMovieDbApi().getEpisodes(searchResponse.id.toString()); 36 | 37 | return TvFetchResponse(searchResponse.title, searchResponse.url, searchResponse.apiName, 38 | TvType.tv, searchResponse.id.toString(), 39 | thumbnail: searchResponse.thumbnail, 40 | backgroundImage: searchResponse.thumbnail, 41 | episodes: episodes, 42 | seasons: episodes 43 | .unique( 44 | (element) => element.season, 45 | ) 46 | .length); 47 | } 48 | } 49 | 50 | @override 51 | Stream load(LoadRequest loadRequest) async* { 52 | //https://vidsrc.me/embed/tt0944947/2-3/ 53 | if (loadRequest is TvLoadRequest) { 54 | //TODO: Handle special cases 55 | yield* VidSrcExtractor().extract( 56 | '$mainUrl/embed/${loadRequest.data}/${loadRequest.season + 1}-${loadRequest.episode + 1}', 57 | headers: loadRequest.headers); 58 | } else if (loadRequest is MovieLoadRequest) { 59 | yield* VidSrcExtractor() 60 | .extract('$mainUrl/embed/${loadRequest.data}/', headers: loadRequest.headers); 61 | } 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /lib/util/capsules/fetch.dart: -------------------------------------------------------------------------------- 1 | import 'package:viddroid/util/capsules/link.dart'; 2 | 3 | import 'media.dart'; 4 | 5 | abstract class FetchResponse { 6 | final String title; 7 | final String url; 8 | final String apiName; 9 | final String data; 10 | final TvType type; 11 | final String? thumbnail; 12 | final String? year; 13 | final String? duration; 14 | final String? description; 15 | 16 | final String? backgroundImage; 17 | 18 | final Map? thumbnailHeaders; 19 | 20 | const FetchResponse(this.title, this.url, this.apiName, this.type, this.data, 21 | {this.year, 22 | this.thumbnail, 23 | this.duration, 24 | this.thumbnailHeaders, 25 | this.backgroundImage, 26 | this.description}); 27 | 28 | @override 29 | String toString() { 30 | return 'FetchResponse{title: $title, url: $url, apiName: $apiName, data: $data, type: $type, thumbnail: $thumbnail, year: $year, duration: $duration, description: $description, backgroundImage: $backgroundImage, thumbnailHeaders: $thumbnailHeaders}'; 31 | } 32 | } 33 | 34 | class MovieFetchResponse extends FetchResponse { 35 | MovieFetchResponse(super.title, super.url, super.apiName, super.type, super.data, 36 | {super.year, 37 | super.thumbnail, 38 | super.duration, 39 | super.thumbnailHeaders, 40 | super.backgroundImage, 41 | super.description}); 42 | 43 | LoadRequest toLoadRequest() { 44 | return LoadRequest(data, type, apiName); 45 | } 46 | } 47 | 48 | class TvFetchResponse extends FetchResponse { 49 | final List episodes; 50 | final int seasons; 51 | 52 | TvFetchResponse(super.title, super.url, super.apiName, super.type, super.data, 53 | {required this.episodes, 54 | required this.seasons, 55 | super.year, 56 | super.thumbnail, 57 | super.duration, 58 | super.thumbnailHeaders, 59 | super.backgroundImage, 60 | super.description}); 61 | 62 | LoadRequest toLoadRequest(final int season, final int episode) { 63 | return TvLoadRequest(data, type, apiName, episode: episode, season: season); 64 | } 65 | } 66 | 67 | class Episode { 68 | final String _name; 69 | final int _index; 70 | final int _season; 71 | final String? _thumbnail; 72 | final String data; 73 | 74 | Episode(this._name, this._index, this._season, this._thumbnail, this.data); 75 | 76 | String? get thumbnail => _thumbnail; 77 | 78 | int get season => _season; 79 | 80 | int get index => _index; 81 | 82 | String get name => _name; 83 | 84 | @override 85 | String toString() { 86 | return 'Episode{_name: $_name, _index: $_index, _season: $_season, _thumbnail: $_thumbnail}'; 87 | } 88 | 89 | String? getSeasonPosterPath() => thumbnail!; 90 | 91 | LoadRequest toLoadRequest() { 92 | return TvLoadRequest(data, TvType.tv, name, episode: index, season: season); 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /lib/util/capsules/link.dart: -------------------------------------------------------------------------------- 1 | import 'package:viddroid/util/capsules/media.dart'; 2 | import 'package:viddroid/util/capsules/subtitle.dart'; 3 | 4 | class LinkResponse { 5 | final String? title; 6 | final String url; 7 | final String referer; 8 | final String source; 9 | final MediaQuality mediaQuality; 10 | final Map? header; 11 | final List? subtitles; 12 | 13 | LinkResponse(this.url, this.referer, this.source, this.mediaQuality, 14 | {this.title, this.header, this.subtitles}); 15 | 16 | @override 17 | String toString() { 18 | return 'LinkResponse{title: $title, url: $url, referer: $referer, source: $source, mediaQuality: $mediaQuality, header: $header}'; 19 | } 20 | } 21 | 22 | class LoadRequest { 23 | final String data; 24 | final TvType type; 25 | final Map? headers; 26 | final String apiName; 27 | 28 | LoadRequest(this.data, this.type, this.apiName, {this.headers}); 29 | 30 | @override 31 | String toString() { 32 | return 'LoadRequest{data: $data, type: $type, headers: $headers, apiName: $apiName}'; 33 | } 34 | } 35 | 36 | class TvLoadRequest extends LoadRequest { 37 | final int season; 38 | final int episode; 39 | 40 | TvLoadRequest(super.data, super.type, super.apiName, 41 | {required this.season, required this.episode, super.headers}); 42 | } 43 | 44 | class MovieLoadRequest extends LoadRequest { 45 | MovieLoadRequest(super.data, super.type, super.apiName, {super.headers}); 46 | } 47 | -------------------------------------------------------------------------------- /lib/util/capsules/media.dart: -------------------------------------------------------------------------------- 1 | enum TvType { 2 | movie, 3 | tv, 4 | anime, 5 | //TODO: Add more in the future. 6 | } 7 | 8 | enum MediaType { 9 | m3u8, 10 | video, 11 | } 12 | 13 | enum MediaQuality { 14 | unknown(400), 15 | p_144(144), // 144p 16 | p_240(240), // 240p 17 | p_360(360), // 360p 18 | p_480(480), // 480p 19 | p_720(720), // 720p 20 | p_1080(1080), // 1080p 21 | p_1440(1440), // 1440p 22 | p_2160(2160); //4k or 2160p 23 | 24 | final int quality; 25 | 26 | const MediaQuality(this.quality); 27 | } 28 | 29 | extension MediaQualityExtension on MediaQuality { 30 | static MediaQuality fromString(final String? string) { 31 | if (string == null) { 32 | return MediaQuality.unknown; 33 | } 34 | 35 | return MediaQuality.values.firstWhere((element) => element.quality.toString() == string, 36 | orElse: () => MediaQuality.unknown); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /lib/util/capsules/option_item.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/cupertino.dart'; 2 | 3 | /// Taken from: https://github.com/fluttercommunity/chewie/blob/master/lib/src/models/option_item.dart 4 | /// The chewie project is licensed unter the MIT license. 5 | /// All credit goes to the authors. 6 | /// 7 | class OptionItem { 8 | OptionItem({ 9 | required this.onTap, 10 | required this.iconData, 11 | required this.title, 12 | this.subtitle, 13 | }); 14 | 15 | Function()? onTap; 16 | IconData iconData; 17 | String title; 18 | String? subtitle; 19 | 20 | OptionItem copyWith({ 21 | Function()? onTap, 22 | IconData? iconData, 23 | String? title, 24 | String? subtitle, 25 | }) { 26 | return OptionItem( 27 | onTap: onTap ?? this.onTap, 28 | iconData: iconData ?? this.iconData, 29 | title: title ?? this.title, 30 | subtitle: subtitle ?? this.subtitle, 31 | ); 32 | } 33 | 34 | @override 35 | String toString() => 36 | 'OptionItem(onTap: $onTap, iconData: $iconData, title: $title, subtitle: $subtitle)'; 37 | 38 | @override 39 | bool operator ==(Object other) { 40 | if (identical(this, other)) return true; 41 | 42 | return other is OptionItem && 43 | other.onTap == onTap && 44 | other.iconData == iconData && 45 | other.title == title && 46 | other.subtitle == subtitle; 47 | } 48 | 49 | @override 50 | int get hashCode => onTap.hashCode ^ iconData.hashCode ^ title.hashCode ^ subtitle.hashCode; 51 | } 52 | -------------------------------------------------------------------------------- /lib/util/capsules/search.dart: -------------------------------------------------------------------------------- 1 | import 'media.dart'; 2 | 3 | abstract class SearchResponse { 4 | String title; 5 | String url; 6 | String apiName; 7 | TvType type; 8 | String? thumbnail; 9 | int? id; 10 | SearchQuality? searchQuality; 11 | Map? thumbnailHeaders; 12 | 13 | SearchResponse(this.title, this.url, this.apiName, 14 | {required this.type, this.thumbnail, this.id, this.searchQuality, this.thumbnailHeaders}); 15 | } 16 | 17 | class MovieSearchResponse extends SearchResponse { 18 | MovieSearchResponse(super.title, super.url, super.apiName, 19 | {super.thumbnail, super.id, super.searchQuality, super.thumbnailHeaders}) 20 | : super(type: TvType.movie); 21 | } 22 | 23 | class TvSearchResponse extends SearchResponse { 24 | TvSearchResponse(super.title, super.url, super.apiName, 25 | {super.thumbnail, super.id, super.searchQuality, super.thumbnailHeaders}) 26 | : super(type: TvType.tv); 27 | } 28 | 29 | enum SearchQuality { 30 | //TODO: Add more 31 | uhd, 32 | 33 | ///... 34 | } 35 | -------------------------------------------------------------------------------- /lib/util/capsules/subtitle.dart: -------------------------------------------------------------------------------- 1 | class Subtitle { 2 | final String language; 3 | final String name; 4 | final String url; 5 | 6 | Subtitle(this.language, this.name, this.url); 7 | } 8 | -------------------------------------------------------------------------------- /lib/util/custom_scroll_behaviour.dart: -------------------------------------------------------------------------------- 1 | import 'dart:ui'; 2 | 3 | import 'package:flutter/material.dart'; 4 | 5 | class CustomScrollBehaviour extends MaterialScrollBehavior { 6 | // Override behavior methods and getters like dragDevices 7 | @override 8 | Set get dragDevices => { 9 | PointerDeviceKind.touch, 10 | PointerDeviceKind.mouse, 11 | PointerDeviceKind.trackpad, 12 | }; 13 | } 14 | -------------------------------------------------------------------------------- /lib/util/download/basic_downloader.dart: -------------------------------------------------------------------------------- 1 | import 'package:dio/dio.dart'; 2 | import 'package:viddroid/util/download/downloader.dart'; 3 | 4 | class BasicDownloader extends Downloader { 5 | BasicDownloader(super.url, {required super.filePath}); 6 | 7 | @override 8 | Future download(Function(int) progressCallback) async { 9 | //Simply download the mpv file. 10 | Dio().download( 11 | url.url, 12 | '$filePath.mp4', 13 | options: Options( 14 | headers: url.header, 15 | ), 16 | onReceiveProgress: (count, total) { 17 | progressCallback(((count / total) * 100).toInt()); 18 | }, 19 | ); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /lib/util/download/downloader.dart: -------------------------------------------------------------------------------- 1 | import 'package:viddroid/util/capsules/link.dart'; 2 | import 'package:viddroid/util/download/basic_downloader.dart'; 3 | import 'package:viddroid/util/download/hls_downloader.dart'; 4 | 5 | abstract class Downloader { 6 | final LinkResponse url; 7 | final String filePath; 8 | 9 | Downloader(this.url, {required this.filePath}); 10 | 11 | Future download(Function(int) progressCallback); 12 | } 13 | 14 | class Downloaders { 15 | Downloader? getDownloader(final LinkResponse linkResponse, final String filePath) { 16 | //Figure out the appropriate downloader. (This is very basic for now). 17 | if (linkResponse.url.endsWith('.m3u8')) { 18 | return HLSDownloader(linkResponse, filePath: filePath); 19 | } else { 20 | return BasicDownloader(linkResponse, filePath: filePath); 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /lib/util/download/hls_downloader.dart: -------------------------------------------------------------------------------- 1 | import 'dart:io'; 2 | import 'dart:typed_data'; 3 | 4 | import 'package:dio/dio.dart'; 5 | import 'package:pointycastle/export.dart'; // Only for Cipher*Stream 6 | import 'package:viddroid/constants.dart'; 7 | import 'package:viddroid/util/download/downloader.dart'; 8 | import 'package:viddroid/util/extensions/string_extension.dart'; 9 | import 'package:viddroid/util/hls/hls_util.dart'; 10 | 11 | import '../file/file_util.dart'; 12 | 13 | class HLSDownloader extends Downloader { 14 | HLSDownloader(super.url, {required super.filePath}); 15 | 16 | @override 17 | Future download(Function(int) progressCallback) async { 18 | final Map headers = {...?url.header, 'referer': url.referer}; 19 | 20 | final HLSScanner scanner = HLSScanner(url.url, headers: headers); 21 | await scanner.scan(); 22 | 23 | final int totalSegments = scanner.segments.length; 24 | 25 | final bool isEncrypted = scanner.encMethod != null && scanner.encKeyUri != null; 26 | 27 | BlockCipher? aesCipher; 28 | Padding? padding; 29 | 30 | if (isEncrypted) { 31 | //TODO: Key not from url (not supported by any players, therefore low priority) 32 | 33 | final Uint8List key = await simpleGet(scanner.encKeyUri!, headers: headers, responseType: ResponseType.bytes).then((value) => value.data); 34 | 35 | final Uint8List iv = scanner.encIv?.hexToUint8List ?? Uint8List(16); 36 | 37 | if (key.isEmpty) { 38 | return Future.error('Could not get encryption key from url.'); 39 | } 40 | 41 | aesCipher = BlockCipher('AES/CBC')..init(false, ParametersWithIV(KeyParameter(key), iv)); 42 | padding = Padding('PKCS7')..init(); 43 | } 44 | 45 | final File outFile = File('$filePath.mp4'); 46 | final IOSink ioSink = outFile.openWrite(mode: FileMode.writeOnlyAppend); 47 | 48 | for (int i = 0; i < totalSegments; i++) { 49 | final String url = scanner.segments[i]; 50 | if (url.isEmpty) { 51 | continue; 52 | } 53 | try { 54 | if (isEncrypted) { 55 | await writeFromEncryptedStreamToStream( 56 | ioSink, 57 | url: url, 58 | blockCipher: aesCipher!, //The ciphers will be non-null 59 | padding: padding!, 60 | headers: headers, 61 | ); 62 | } else { 63 | await writeFromStreamToStream( 64 | ioSink, 65 | url: url, 66 | headers: headers, 67 | ); 68 | } 69 | } catch (e, s) { 70 | logger.e('An error occurred while downloading a HLS segment', error: e, stackTrace: s); 71 | continue; 72 | } 73 | progressCallback.call(((i / totalSegments) * 100).toInt()); 74 | } 75 | await ioSink.flush(); 76 | await ioSink.close(); 77 | progressCallback.call(100); 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /lib/util/extensions/iterable_extension.dart: -------------------------------------------------------------------------------- 1 | extension ReduceWhile on Iterable { 2 | T reduceWhile({ 3 | required T Function(T previous, T element) combine, 4 | required bool Function(T) combineWhile, 5 | T? initialValue, 6 | }) { 7 | T initial = initialValue ?? first; 8 | 9 | for (T element in this) { 10 | initial = combine(initial, element); 11 | if (!combineWhile(initial)) break; 12 | } 13 | 14 | return initial; 15 | } 16 | } 17 | 18 | //Taken from: https://stackoverflow.com/a/63277386 19 | extension Unique on Iterable { 20 | List unique([Id Function(E element)? id, bool inplace = true]) { 21 | final ids = {}; 22 | var list = inplace ? this : List.from(this); 23 | final List copy = List.from(list); 24 | copy.retainWhere((x) => ids.add(id != null ? id(x) : x as Id)); 25 | 26 | return copy; 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /lib/util/extensions/stream_extension.dart: -------------------------------------------------------------------------------- 1 | ///Taken from: Alex Ritt (https://stackoverflow.com/questions/73214483/how-to-extend-dart-sockets-broadcaststream-buffer-size-of-1024-bytes) 2 | ///(Thank you so much, this is the generic approach) 3 | /// This was created since the native [reduce] says: 4 | /// > When this stream is done, the returned future is completed with the value at that time. 5 | /// 6 | /// The problem is that socket connections does not emits the [done] event after 7 | /// each message but after the socket disconnection. 8 | /// 9 | /// So here is a implementation that combines [reduce] and [takeWhile]. 10 | extension ReduceWhile on Stream { 11 | Future reduceWhile({ 12 | required T Function(T previous, T element) combine, 13 | required bool Function(T) combineWhile, 14 | T? initialValue, 15 | }) async { 16 | T initial = initialValue ?? await first; 17 | 18 | await for (T element in this) { 19 | initial = combine(initial, element); 20 | if (!combineWhile(initial)) break; 21 | } 22 | 23 | return initial; 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /lib/util/extensions/string_extension.dart: -------------------------------------------------------------------------------- 1 | import 'dart:convert'; 2 | import 'dart:typed_data'; 3 | 4 | import 'package:crypto/crypto.dart'; 5 | 6 | extension StringExtension on String { 7 | String get extractMainUrl { 8 | final String origin = Uri.parse(this).origin; 9 | 10 | return origin; 11 | } 12 | 13 | String get getFileNameFromPath { 14 | return substring(lastIndexOf('\\') + 1, lastIndexOf('.')); 15 | } 16 | 17 | /// Very basic! 18 | String get cleanWindows { 19 | return replaceAll("[\\*/\\\\!\\|:?<>]", "_").replaceAll("(%22)", "_"); 20 | } 21 | 22 | String get toMD5 { 23 | return md5.convert(utf8.encode(this)).toString(); 24 | } 25 | 26 | bool get isNumeric { 27 | for (int i = 0; i < length; i++) { 28 | int codeUnit = codeUnitAt(i); 29 | if (codeUnit < 48 || codeUnit > 57) { 30 | return false; 31 | } 32 | } 33 | return true; 34 | } 35 | 36 | /// Taken from: https://pub.dev/documentation/eosdart/latest/eosdart/hexToUint8List.html and modified 37 | Uint8List get hexToUint8List { 38 | if (length % 2 != 0) { 39 | throw 'Odd number of hex digits'; 40 | } 41 | final int l = length ~/ 2; 42 | final Uint8List result = Uint8List(l); 43 | for (int i = 0; i < l; i++) { 44 | var x = int.parse(substring(i * 2, (2 * (i + 1))), radix: 16); 45 | if (x.isNaN) { 46 | throw 'Expected hex string'; 47 | } 48 | result[i] = x; 49 | } 50 | return result; 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /lib/util/extraction/js_packer.dart: -------------------------------------------------------------------------------- 1 | /// A JSPacker. 2 | /// Taken from: https://github.com/thitlwincoder/js_packer/blob/master/lib/js_packer.dart & edited by me, because it seems like the original creator had no idea, what he was doing. 3 | /// At any rate, I could've just written this myself if I looked at the implementation beforehand. The original creator just transcribed a Java unpacker (see: https://github.com/cylonu87/JsUnpacker/blob/master/JsUnpacker.java) 4 | /// As this library is very simple in its functionality, I decided to just take the corresponding code. 5 | /// The library has no future updates in sight, it therefore is not necessary, to list it as a dependency. 6 | class JSPacker { 7 | /// get js code 8 | final String packedJS; 9 | 10 | JSPacker(this.packedJS); 11 | 12 | /// detect code has match 13 | bool detect() { 14 | final js = packedJS.replaceAll(' ', ''); 15 | final exp = RegExp(r'eval\(function\(p,a,c,k,e,[rd]'); 16 | return exp.hasMatch(js); 17 | } 18 | 19 | /// change code to value 20 | String? unpack() { 21 | try { 22 | /// pattern 23 | var exp = RegExp( 24 | "\\}\\s*\\('(.*)',\\s*(.*?),\\s*(\\d+),\\s*'(.*?)'\\.split\\('\\|'\\)", 25 | dotAll: true, 26 | ); 27 | 28 | /// get value from elementAt 0 29 | RegExpMatch? match = exp.firstMatch(packedJS); 30 | 31 | /// if group count is 4 32 | if (match != null && match.groupCount == 4) { 33 | /// get value with group 34 | final String payload = match.group(1)!.replaceAll("\\'", "'"); 35 | final String radixStr = match.group(2)!; 36 | final String countStr = match.group(3)!; 37 | final sym = match.group(4)!.split('|'); 38 | 39 | /// initial value 40 | int radix = 36; 41 | int count = 0; 42 | 43 | /// set radix value 44 | try { 45 | radix = int.parse(radixStr); 46 | } catch (_) {} 47 | 48 | /// set count value 49 | try { 50 | count = int.parse(countStr); 51 | } catch (_) {} 52 | 53 | /// error condition 54 | if (sym.length != count) { 55 | throw Exception('Unknown p.a.c.k.e.r. encoding'); 56 | } 57 | 58 | /// call UnBase class 59 | final unBase = UnBase(radix); 60 | 61 | exp = RegExp(r'\b\w+\b'); 62 | 63 | int replaceOffset = 0; 64 | 65 | String decoded = payload; 66 | 67 | exp.allMatches(payload).forEach((element) { 68 | final String word = element.group(0)!; 69 | 70 | /// change code to value 71 | final x = unBase.unBase(word); 72 | 73 | var value = ''; 74 | 75 | /// set value 76 | if (x < sym.length) { 77 | value = sym[x]; 78 | } 79 | 80 | if (value.isNotEmpty) { 81 | decoded = decoded.replaceRange( 82 | element.start + replaceOffset, element.end + replaceOffset, value); 83 | replaceOffset += (value.length - word.length); 84 | } 85 | }); 86 | 87 | /// return result 88 | return decoded; 89 | } 90 | return null; 91 | } catch (_) { 92 | return null; 93 | } 94 | } 95 | } 96 | 97 | class UnBase { 98 | static const String alpha_62 = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'; 99 | static const String alpha_95 = 100 | " !\"#\$%&\\'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\\\]^_`abcdefghijklmnopqrstuvwxyz{|}~"; 101 | 102 | String? alphabet; 103 | final Map dictionary = {}; 104 | final int radix; 105 | 106 | UnBase(this.radix) { 107 | if (radix > 36) { 108 | if (radix < 62) { 109 | alphabet = alpha_62.substring(0, radix); 110 | } else if (radix > 62 && radix < 95) { 111 | alphabet = alpha_95.substring(0, radix); 112 | } else if (radix == 62) { 113 | alphabet = alpha_62; 114 | } else if (radix == 95) { 115 | alphabet = alpha_95; 116 | } 117 | 118 | for (var i = 0; i < alphabet!.length; i++) { 119 | dictionary[alphabet!.substring(i, i + 1)] = i; 120 | } 121 | } 122 | } 123 | 124 | /// The code had to be modified, as the original creator just transcribed java code (see: https://github.com/cylonu87/JsUnpacker/blob/master/JsUnpacker.java) 125 | /// However, Dart does not automatically use 64 bits for integers. the to int operation causes an integer overflow in certain cases. The original creator apparently had no idea, what he was doing... 126 | int unBase(String str) { 127 | BigInt ret = BigInt.zero; 128 | 129 | if (alphabet == null) { 130 | ret = BigInt.from(int.parse(str, radix: radix)); 131 | } else { 132 | //Reverse the runes (support utf-16, for future traps) 133 | final String tmp = String.fromCharCodes(str.runes.toList().reversed); 134 | for (var i = 0; i < tmp.length; i++) { 135 | ret += (BigInt.from(radix).pow(i) * BigInt.from(dictionary[tmp.substring(i, i + 1)]!)); 136 | } 137 | } 138 | return ret.toInt(); 139 | } 140 | } 141 | -------------------------------------------------------------------------------- /lib/util/extraction/sflix_util.dart: -------------------------------------------------------------------------------- 1 | import 'dart:convert'; 2 | import 'dart:typed_data'; 3 | 4 | import 'package:crypto/crypto.dart'; 5 | import 'package:encrypt/encrypt.dart'; 6 | 7 | String decrypt(final String input, final String key) { 8 | final Uint8List base64Input = base64Decode(input); 9 | final Uint8List keyBytes = _generateKey(base64Input.sublist(8, 16), utf8.encode(key)); 10 | 11 | final Encrypter encrypter = 12 | Encrypter(AES(Key(keyBytes.sublist(0, 32)), mode: AESMode.cbc, padding: 'PKCS7')); 13 | 14 | final Encrypted encrypted = Encrypted(base64Input.sublist(16)); 15 | final IV iv = IV(keyBytes.sublist(32)); 16 | 17 | return encrypter.decrypt(encrypted, iv: iv); 18 | } 19 | 20 | List _generateMd5(List input) { 21 | return md5.convert(input).bytes; 22 | } 23 | 24 | Uint8List _generateKey(List salt, List secret) { 25 | List key = _generateMd5(secret + salt); 26 | List currentKey = key; 27 | 28 | while (currentKey.length < 48) { 29 | key = _generateMd5(key + secret + salt); 30 | currentKey += key; 31 | } 32 | //Expensive operation. 33 | return Uint8List.fromList(currentKey); 34 | } 35 | -------------------------------------------------------------------------------- /lib/util/file/file_util.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | import 'dart:io'; 3 | 4 | import 'package:dio/dio.dart'; 5 | import 'package:jovial_misc_pc/io_utils.dart'; 6 | import 'package:jovial_misc/io_utils.dart'; 7 | import 'package:pointycastle/export.dart'; 8 | 9 | import '../../constants.dart'; // Only for Cipher*Stream 10 | 11 | Future writeFromEncryptedStream( 12 | final String filePath, 13 | final String fileSuffix, { 14 | final Directory? directory, 15 | required String url, 16 | required BlockCipher blockCipher, 17 | required Padding padding, 18 | final Map? headers, 19 | }) async { 20 | final Completer completer = Completer(); 21 | 22 | final Response response = 23 | await simpleGet(url, headers: headers, responseType: ResponseType.stream); 24 | 25 | if (response.data == null) { 26 | return Future.error('Could not request data-stream from url $url'); 27 | } 28 | 29 | final DecryptingStream dataInputStream = 30 | DecryptingStream(blockCipher, response.data!.stream, padding); 31 | 32 | final File file = File('$filePath.$fileSuffix'); 33 | final IOSink ioSink = file.openWrite(); 34 | final DataOutputSink out = DataOutputSink(ioSink); 35 | 36 | dataInputStream.listen( 37 | (value) { 38 | out.writeBytes(value); 39 | }, 40 | onDone: () async { 41 | await ioSink.flush(); 42 | await ioSink.close(); 43 | completer.complete(true); 44 | }, 45 | ); 46 | 47 | return completer.future; 48 | } 49 | 50 | Future writeFromEncryptedStreamToStream( 51 | final IOSink outputSink, { 52 | required String url, 53 | required BlockCipher blockCipher, 54 | required Padding padding, 55 | final Map? headers, 56 | }) async { 57 | final Completer completer = Completer(); 58 | 59 | final Response response = 60 | await simpleGet(url, headers: headers, responseType: ResponseType.stream); 61 | 62 | if (response.data == null) { 63 | return Future.error('Could not request data-stream from url $url'); 64 | } 65 | 66 | final DecryptingStream dataInputStream = 67 | DecryptingStream(blockCipher, response.data!.stream, padding); 68 | 69 | dataInputStream.listen( 70 | (value) { 71 | outputSink.add(value); 72 | }, 73 | onDone: () { 74 | completer.complete(true); 75 | }, 76 | ); 77 | return completer.future; 78 | } 79 | 80 | Future writeFromStreamToStream( 81 | final IOSink outputSink, { 82 | required String url, 83 | final Map? headers, 84 | }) async { 85 | final Completer completer = Completer(); 86 | 87 | final Response response = 88 | await simpleGet(url, headers: headers, responseType: ResponseType.stream); 89 | 90 | if (response.data == null) { 91 | return Future.error('Could not request data-stream from url $url'); 92 | } 93 | 94 | response.data!.stream.listen( 95 | (value) { 96 | outputSink.add(value); 97 | }, 98 | onDone: () { 99 | completer.complete(true); 100 | }, 101 | ); 102 | return completer.future; 103 | } 104 | -------------------------------------------------------------------------------- /lib/util/hls/hls_util.dart: -------------------------------------------------------------------------------- 1 | import 'package:dio/dio.dart'; 2 | import 'package:viddroid/constants.dart'; 3 | 4 | class HLSScanner { 5 | factory HLSScanner(String url, {Map? headers}) { 6 | return HLSScanner._internal(url, headers: headers); 7 | } 8 | 9 | final List segments = []; 10 | final String _mainUrl; 11 | final Map? _headers; 12 | final Map resolutions = {}; 13 | 14 | final Map values = 15 | {}; // Will be used in the near future to store values in a more versatile way. 16 | 17 | String? encKeyUri; 18 | String? encMethod; 19 | String? encIv; 20 | 21 | final RegExp keyValueExp = RegExp(r'([A-Z]+)="?([^",]+)'); 22 | 23 | HLSScanner._internal(this._mainUrl, {Map? headers}) : _headers = headers; 24 | 25 | Future scan() async { 26 | final List mainLines = await _getLines(_mainUrl); 27 | 28 | for (int i = 0; i < mainLines.length; i++) { 29 | final String line = mainLines[i]; 30 | 31 | final List split = line.split(':'); 32 | 33 | if (split.length < 2) { 34 | continue; 35 | } 36 | 37 | final String code = split[0]; 38 | final String value = split 39 | .getRange(1, split.length) 40 | .join(':'); //Join (needed for urs, as they are split too. Could fix this with a regex. 41 | 42 | //TODO: Substring prefix 43 | switch (code) { 44 | case '#EXT-X-KEY': 45 | //set key & method 46 | final Map keyValues = dissectValue(value); 47 | encMethod = keyValues['METHOD']; 48 | encKeyUri = keyValues['URI']; 49 | encIv = keyValues['IV']; 50 | break; 51 | case '#EXT-X-STREAM-INF': 52 | final Map keyValues = dissectValue(value); 53 | final String? resolution = keyValues['RESOLUTION']; 54 | if (resolution != null) { 55 | resolutions[resolution] = mainLines[i + 1]; 56 | } 57 | break; 58 | } 59 | } 60 | 61 | //if there are resolutions, choose the highest one. (TODO: maybe later on a fully-fledged selection) 62 | if (resolutions.isNotEmpty) { 63 | int maxResolution = 0; 64 | String maxRes = ''; 65 | for (final String key in resolutions.keys) { 66 | final int res = int.parse(key.replaceAll('x', '')); 67 | if (res > maxResolution) { 68 | maxResolution = res; 69 | maxRes = key; 70 | } 71 | } 72 | 73 | final String? url = resolutions[maxRes]; 74 | if (url != null) { 75 | final List mainLines = await _getLines(url); 76 | await _scanPlaylist(mainLines, url); 77 | } 78 | } else { 79 | await _scanPlaylist(mainLines, _mainUrl); 80 | } 81 | } 82 | 83 | Future _scanPlaylist(final lines, final String relativeUrl) async { 84 | final List contentLines = lines.where((element) => !element.startsWith('#')).toList(); 85 | 86 | for (int i = 0; i < contentLines.length; i++) { 87 | _scanLine(contentLines[i], relativeUrl); 88 | } 89 | } 90 | 91 | void _scanLine(final String line, final String relativeUrl) async { 92 | final LineType lineType = _determineLineType(line); 93 | if (line.isEmpty) { 94 | return; 95 | } 96 | 97 | if (lineType == LineType.ts) { 98 | //Figure out whether the linepath is relative 99 | if (!line.startsWith('https://') && line.substring(line.lastIndexOf('.')).isNotEmpty) { 100 | //Add the url path 101 | final String path = relativeUrl.substring(0, relativeUrl.lastIndexOf('/')); 102 | segments.add('$path/$line'); 103 | } else { 104 | segments.add(line); 105 | } 106 | } else { 107 | final List lines = await _getLines(line); 108 | _scanPlaylist(lines, relativeUrl); 109 | } 110 | } 111 | 112 | Future> _getLines(final String url) async { 113 | final Response response = 114 | await simpleGet(url, headers: _headers, responseType: ResponseType.plain); 115 | final List lines = response.data.split('\n'); 116 | return lines; 117 | } 118 | 119 | //Very basic for now.. 120 | Map dissectValue(final String value) { 121 | final Map map = {}; 122 | 123 | keyValueExp.allMatches(value).forEach((element) { 124 | map[element[1]!] = element[2]!; 125 | }); 126 | 127 | /* 128 | 129 | 130 | int last = 0; 131 | bool isEscaped = false; 132 | 133 | String currentKey = ''; 134 | String currentValue = ''; 135 | 136 | for (int i = 0; i < value.length; i++) { 137 | final String char = value[i]; 138 | if (char == '\\') { 139 | continue; 140 | } 141 | if (char == '"') { 142 | isEscaped = !isEscaped; 143 | } 144 | if(!isEscaped) { 145 | if (char == ',') { 146 | map[currentKey] = currentValue; 147 | } else if(char == '=') { 148 | currentKey = value.substring(last, i - 1); 149 | currentValue = value.substring(i + 1, value.indexOf('')) 150 | 151 | last = i; 152 | } 153 | } 154 | } 155 | */ 156 | return map; 157 | } 158 | 159 | LineType _determineLineType(final String line) { 160 | final String extension = line.substring(line.lastIndexOf('.') + 1); 161 | 162 | switch (extension) { 163 | case 'm3u8': 164 | return LineType.playlist; 165 | 166 | default: 167 | return LineType.ts; 168 | } 169 | } 170 | } 171 | 172 | enum LineType { ts, playlist, unknown } 173 | -------------------------------------------------------------------------------- /lib/util/movie_provider/the_movie_db.dart: -------------------------------------------------------------------------------- 1 | import 'package:dio/dio.dart'; 2 | import 'package:viddroid/constants.dart'; 3 | 4 | import '../../api.dart'; 5 | import '../capsules/fetch.dart'; 6 | import '../capsules/search.dart'; 7 | 8 | class TheMovieDBAPIEndpoints { 9 | final String endpoint; 10 | 11 | const TheMovieDBAPIEndpoints._internal(this.endpoint); 12 | 13 | @override 14 | String toString() => 'TheMovieDBApi-Endpoint $endpoint'; 15 | 16 | String getEndpoint() => endpoint; 17 | 18 | static const searchMovie = TheMovieDBAPIEndpoints._internal('/search/movie'); 19 | static const searchTV = TheMovieDBAPIEndpoints._internal('/search/tv'); 20 | static const tvDetails = TheMovieDBAPIEndpoints._internal('/tv'); 21 | static const movieDetails = TheMovieDBAPIEndpoints._internal('/movie'); 22 | static const searchMulti = TheMovieDBAPIEndpoints._internal('/search/multi'); 23 | } 24 | 25 | class TheMovieDBAPIImageWidth { 26 | final String dimensions; 27 | 28 | const TheMovieDBAPIImageWidth._internal(this.dimensions); 29 | 30 | @override 31 | String toString() => 'Image Dimension $dimensions'; 32 | 33 | String getDimension() => dimensions; 34 | 35 | static const width300 = TheMovieDBAPIImageWidth._internal('w300'); 36 | static const originalSize = TheMovieDBAPIImageWidth._internal('original'); 37 | static const width500 = TheMovieDBAPIImageWidth._internal('w500'); 38 | } 39 | 40 | const String apiv3Endpoint = 'https://api.themoviedb.org/3'; 41 | 42 | String formatEndpointSearchRequest(TheMovieDBAPIEndpoints dbapiEndpoint, String query) => 43 | '$apiv3Endpoint${dbapiEndpoint.getEndpoint()}?api_key=$apiKey&page=1&query=${Uri.encodeFull(query)}'; 44 | 45 | String formatRequest(TheMovieDBAPIEndpoints dbapiEndpoint, String query, {String appendToResponse = '', List appends = const []}) => 46 | '$apiv3Endpoint${dbapiEndpoint.getEndpoint()}/$query?api_key=$apiKey&append_to_response=${appendToResponse + appends.join(',')}'; 47 | 48 | String formatPosterPath(TheMovieDBAPIImageWidth imageWidth, final String posterPath) => 'https://image.tmdb.org/t/p/${imageWidth.getDimension()}$posterPath'; 49 | 50 | String formatSeasonsApi(final String tvId, final int seasonIndex) => '$apiv3Endpoint/tv/$tvId/season/$seasonIndex?api_key=$apiKey'; 51 | 52 | class TheMovieDbApi { 53 | static final TheMovieDbApi _instance = TheMovieDbApi.ctor(); 54 | 55 | TheMovieDbApi.ctor(); 56 | 57 | factory TheMovieDbApi() => _instance; 58 | 59 | Future> search(final String query) async { 60 | if (query.isEmpty) return List.empty(); 61 | 62 | final List responses = []; 63 | 64 | final dynamic results = await simpleGet( 65 | formatEndpointSearchRequest(TheMovieDBAPIEndpoints.searchMulti, query), 66 | ).then((value) => value.data['results']); 67 | //Look up the results 68 | 69 | for (dynamic result in results) { 70 | final String mediaType = result['media_type']; 71 | final int? id = result['id']; 72 | //Skip entry 73 | if (id == null || result['media_type'] == 'person') { 74 | continue; 75 | } 76 | 77 | //Ping the different apis based on the media-type 78 | final String requestUrl = formatRequest(mediaType == 'tv' ? TheMovieDBAPIEndpoints.tvDetails : TheMovieDBAPIEndpoints.movieDetails, id.toString()); 79 | 80 | final dynamic detailedResult = await simpleGet(requestUrl).then((value) => value.data); 81 | 82 | final String? thumbnail = 83 | detailedResult['poster_path'] != null ? formatPosterPath(TheMovieDBAPIImageWidth.originalSize, detailedResult['poster_path']!) : null; 84 | 85 | if (mediaType == 'tv') { 86 | responses.add(TvSearchResponse( 87 | detailedResult['name'] ?? 'N/A', 88 | '', 89 | '', 90 | id: id, 91 | thumbnail: thumbnail, 92 | )); 93 | } else { 94 | responses.add(MovieSearchResponse( 95 | detailedResult['title'] ?? 'N/A', 96 | '', 97 | '', 98 | id: id, 99 | thumbnail: thumbnail, 100 | )); 101 | } 102 | } 103 | return responses; 104 | } 105 | 106 | Future> getEpisodes(final String id) async { 107 | final String requestUrl = formatRequest(TheMovieDBAPIEndpoints.tvDetails, id); 108 | 109 | final Response response = await simpleGet(requestUrl); 110 | final dynamic seasonsArray = response.data['seasons']; 111 | final List episodes = []; 112 | 113 | for (int i = 0; i < seasonsArray.length; i++) { 114 | //Because tmdb does not send back episodes in one request, we have to ping the api again... 115 | final dynamic response = await simpleGet(formatSeasonsApi(id, i)).then((value) => value.data); 116 | final dynamic episodesArray = response['episodes']; 117 | 118 | final int? responseNumber = response['season_number']; 119 | 120 | if (responseNumber != null && responseNumber == 0) { 121 | //Skip extras (TODO: Add a special case for extras - find websites where they are supported) 122 | continue; 123 | } 124 | 125 | for (int j = 0; j < episodesArray.length; j++) { 126 | final dynamic episode = episodesArray[j]; 127 | 128 | final int? episodeId = episode['id']; 129 | if (episodeId == null) { 130 | continue; 131 | } 132 | 133 | episodes.add(Episode(episode['name'] ?? 'N/A', j, (i != 0 ? i - 1 : i), 134 | episode['still_path'] != null ? formatPosterPath(TheMovieDBAPIImageWidth.originalSize, episode['still_path']) : null, id)); 135 | } 136 | } 137 | 138 | return episodes; 139 | } 140 | } 141 | -------------------------------------------------------------------------------- /lib/util/network/cloud_flare_interceptor.dart: -------------------------------------------------------------------------------- 1 | import 'package:dio/dio.dart'; 2 | import 'package:dio/dio.dart' as dio; 3 | import 'package:puppeteer/protocol/network.dart'; 4 | import 'package:puppeteer/puppeteer.dart'; 5 | import 'package:viddroid/constants.dart'; 6 | import 'package:viddroid/util/network/plugins/custom_stealth_plugin.dart'; 7 | 8 | class CloudFlareInterceptor extends Interceptor { 9 | @override 10 | void onRequest(RequestOptions options, RequestInterceptorHandler handler) async { 11 | // Download the Chromium binaries, launch it and connect to the "DevTools" 12 | final Browser browser = 13 | await puppeteer.launch(headless: true, plugins: [CustomStealthPlugin()]); 14 | 15 | // Open a new tab 16 | final Page page = await browser.newPage(); 17 | await page.setJavaScriptEnabled(true); 18 | await page.setUserAgent(userAgent); 19 | await page 20 | .setExtraHTTPHeaders(options.headers.map((key, value) => MapEntry(key, value.toString()))); 21 | 22 | await page.goto(options.path); 23 | //await page.click('#os_player'); 24 | 25 | // await Future.delayed(const Duration(seconds: 5)); 26 | 27 | final List cookies = await page.cookies(); 28 | final String cookie = cookies.map((e) => '${e.name}=${e.value}').join(';'); 29 | 30 | final String? body = await page.content; 31 | 32 | await browser.close(); 33 | 34 | //Set request cookie 35 | options.headers['cookie'] = cookie; 36 | // print(cookie); 37 | options.followRedirects = true; 38 | 39 | handler.resolve( 40 | dio.Response(data: body, requestOptions: options, statusCode: 200), 41 | ); 42 | // super.onRequest(options, handler); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /lib/util/network/plugins/proxy_extension.dart: -------------------------------------------------------------------------------- 1 | import 'dart:io'; 2 | 3 | import 'package:dio/dio.dart'; 4 | import 'package:dio/io.dart'; 5 | 6 | /// Taken from & updated: https://github.com/netsells/dio-proxy-adapter/blob/main/lib/src/dio_proxy_adapter_base.dart 7 | /// Methods for managing proxies on [Dio] 8 | extension ProxyX on Dio { 9 | /// Use a proxy to connect to the internet. 10 | /// 11 | /// If [proxyUrl] is a non-empty, non-null String, connect to the proxy server. 12 | /// 13 | /// If [proxyUrl] is empty or `null`, does nothing. 14 | void useProxy(String? proxyUrl) { 15 | if (proxyUrl != null && proxyUrl.isNotEmpty) { 16 | (httpClientAdapter as IOHttpClientAdapter).onHttpClientCreate = (client) => client 17 | ..findProxy = (url) { 18 | return 'PROXY $proxyUrl'; 19 | } 20 | ..badCertificateCallback = (cert, host, post) => Platform.isAndroid; 21 | } else { 22 | httpClientAdapter = IOHttpClientAdapter(); 23 | } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /lib/util/setting/settings.dart: -------------------------------------------------------------------------------- 1 | import 'package:localstore/localstore.dart'; 2 | import 'package:viddroid/provider/provider.dart'; 3 | 4 | import '../../provider/providers.dart'; 5 | 6 | class Settings { 7 | static final Settings _instance = Settings._ctor(); 8 | 9 | Settings._ctor(); 10 | 11 | factory Settings() { 12 | return _instance; 13 | } 14 | 15 | /// Keys for all the settings 16 | 17 | static const String settingsKey = 'settings'; 18 | static const String selectedProviders = 'selected_providers'; 19 | static const String changeFullscreen = 'windows_fullscreen'; 20 | static const String keepPlayback = 'keep_playback'; 21 | static const String proxy = 'custom_proxy'; 22 | static const String seekSpeed = 'seek_duration'; 23 | static const String wakelock = 'wakelock'; 24 | 25 | late CollectionRef collectionRef; 26 | 27 | /// Map of all the settings. This map is actually written to disk. 28 | Map _settings = { 29 | selectedProviders: Providers().siteProviders, 30 | changeFullscreen: true, 31 | keepPlayback: true, 32 | seekSpeed: 5, 33 | wakelock: true 34 | }; 35 | 36 | /// Initializes all the values asynchronously 37 | Future init() async { 38 | final Localstore db = Localstore.instance; 39 | collectionRef = db.collection('viddroid_settings'); 40 | 41 | _settings = await collectionRef.doc(settingsKey).get() ?? _settings; 42 | } 43 | 44 | Future getFromDiskIfPossible(String key) => 45 | collectionRef.doc(settingsKey).get().then((value) => value?[key] ?? _settings[key]); 46 | 47 | dynamic get(String key, [dynamic defaultValue]) => _settings[key] ?? defaultValue; 48 | 49 | /// Updates the map and saves it to disk 50 | void saveSetting(String key, dynamic settingsValue) { 51 | _settings[key] = settingsValue; 52 | _put(settingsKey, _settings); 53 | } 54 | 55 | /// Writes the map to disk 56 | Future _put(String key, dynamic value) { 57 | return collectionRef.doc(key).set(value); 58 | } 59 | 60 | /// Special case for all selected providers: 61 | 62 | Future saveSelectedProviders(final List providers) async { 63 | saveSetting(selectedProviders, providers.map((e) => e.name).toList()); 64 | } 65 | 66 | Future> getSelectedProviders() async { 67 | final List list = (await get( 68 | selectedProviders, 69 | )) ?? 70 | Providers().siteProviders; 71 | 72 | if (list.isEmpty) { 73 | return List.empty(); 74 | } else { 75 | return Providers().siteProviders.where((element) => list.contains(element.name)).toList(); 76 | } 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /lib/util/video_player_intents.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | 3 | class SpaceIntent extends Intent { 4 | const SpaceIntent(); 5 | } 6 | 7 | class SkipForwardIntent extends Intent { 8 | const SkipForwardIntent(); 9 | } 10 | 11 | class SkipBackwardIntent extends Intent { 12 | const SkipBackwardIntent(); 13 | } 14 | -------------------------------------------------------------------------------- /lib/util/watchable/season.dart: -------------------------------------------------------------------------------- 1 | import '../capsules/fetch.dart'; 2 | 3 | class Season { 4 | int seasonIndex; 5 | List episodes = []; 6 | 7 | Season(this.seasonIndex); 8 | 9 | void addEpisode(Episode episode) { 10 | episodes.add(episode); 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /lib/util/watchable/watchable.dart: -------------------------------------------------------------------------------- 1 | import 'package:viddroid/util/watchable/season.dart'; 2 | 3 | abstract class Watchable { 4 | final int _id; 5 | final String? _title; 6 | final String? _description; 7 | final String _apiUrl; 8 | final String? _thumbnail; 9 | 10 | Watchable(this._id, this._title, this._description, this._apiUrl, this._thumbnail); 11 | 12 | int get id => _id; 13 | 14 | @override 15 | String toString() => 'Watchable "$_title" with id: $_id'; 16 | 17 | String get apiUrl => _apiUrl; 18 | 19 | //TODO: Poster-path default 20 | String? get thumbnail => _thumbnail!; 21 | 22 | String? get title => _title; 23 | 24 | String? get description => _description; 25 | } 26 | 27 | class TVShow extends Watchable { 28 | final List _seasons = []; 29 | 30 | TVShow(dynamic json) 31 | : super( 32 | json['id'], json['name'], json['overview'], json['backdrop_path'], json['poster_path']); 33 | 34 | void addSeason(int index, Season season) => 35 | index == -1 ? _seasons.add(season) : _seasons[index] = season; 36 | 37 | List get getSeasons => _seasons; 38 | } 39 | 40 | class Movie extends Watchable { 41 | Movie(dynamic json) 42 | : super(json['id'], json['title'], json['overview'], json['backdrop_path'], 43 | json['poster_path']); 44 | } 45 | -------------------------------------------------------------------------------- /lib/util/watchable/watchables.dart: -------------------------------------------------------------------------------- 1 | import 'dart:collection'; 2 | 3 | import 'package:localstore/localstore.dart'; 4 | 5 | class Watchables { 6 | static final Watchables _instance = Watchables._ctor(); 7 | 8 | Watchables._ctor(); 9 | 10 | factory Watchables() { 11 | return _instance; 12 | } 13 | 14 | static const String timestampsKey = 'timestamps'; 15 | 16 | /// Map of all the timestamps. This map is actually written to disk. 17 | Map timestamps = HashMap(); 18 | 19 | late CollectionRef collectionRef; 20 | 21 | /// Initializes all the values asynchronously 22 | Future init() async { 23 | final Localstore db = Localstore.instance; 24 | collectionRef = db.collection('watchables'); 25 | 26 | timestamps = (await collectionRef.doc(timestampsKey).get()) ?? HashMap(); 27 | 28 | //TODO: Saved watchables 29 | } 30 | 31 | void saveTimestamp(final String hash, final Duration duration) { 32 | timestamps[hash] = duration.inSeconds; 33 | // Save to disk 34 | collectionRef.doc(timestampsKey).set(timestamps); 35 | } 36 | 37 | Duration? getTimestamp(final String hash) { 38 | int? seconds = timestamps[hash]; 39 | if (seconds == null) { 40 | return null; 41 | } 42 | return Duration(seconds: seconds); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /lib/views/main_view.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:viddroid/views/search_view.dart'; 3 | import 'package:viddroid/views/settings_view.dart'; 4 | import 'package:viddroid/widgets/watchables_list_widget.dart'; 5 | 6 | class MainView extends StatefulWidget { 7 | final String title; 8 | 9 | const MainView({super.key, required this.title}); 10 | 11 | @override 12 | State createState() => _MainViewState(); 13 | } 14 | 15 | class _MainViewState extends State { 16 | List _buildButtons() { 17 | return [ 18 | IconButton( 19 | onPressed: () => Navigator.push( 20 | context, 21 | MaterialPageRoute( 22 | builder: (context) => const SearchView(), 23 | ), 24 | ), 25 | icon: const Icon(Icons.search_sharp), 26 | tooltip: 'Search for media.', 27 | ), 28 | IconButton( 29 | onPressed: () => Navigator.push( 30 | context, 31 | MaterialPageRoute( 32 | builder: (context) => const SettingsView(), 33 | ), 34 | ), 35 | icon: const Icon(Icons.settings), 36 | tooltip: 'Settings.', 37 | ) 38 | ]; 39 | } 40 | 41 | @override 42 | Widget build(BuildContext context) { 43 | return Scaffold( 44 | appBar: AppBar( 45 | title: Text(widget.title), 46 | actions: _buildButtons(), 47 | ), 48 | body: WatchablesList(List.empty())); 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /lib/views/providers_view.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:viddroid/provider/providers.dart'; 3 | import 'package:viddroid/util/setting/settings.dart'; 4 | 5 | import '../provider/provider.dart'; 6 | 7 | class ProviderSelectionView extends StatefulWidget { 8 | const ProviderSelectionView({super.key}); 9 | 10 | @override 11 | State createState() => _ProviderSelectionViewState(); 12 | } 13 | 14 | class _ProviderSelectionViewState extends State { 15 | final List _selectedProviders = []; 16 | 17 | @override 18 | void initState() { 19 | Future.microtask(() async { 20 | _selectedProviders.addAll(await Settings().getSelectedProviders()); 21 | setState(() {}); 22 | }); 23 | super.initState(); 24 | } 25 | 26 | @override 27 | void dispose() { 28 | Settings().saveSelectedProviders(_selectedProviders); 29 | super.dispose(); 30 | } 31 | 32 | @override 33 | Widget build(BuildContext context) { 34 | return Scaffold( 35 | appBar: AppBar( 36 | title: const Text('Providers'), 37 | ), 38 | body: ListView( 39 | shrinkWrap: true, 40 | padding: const EdgeInsets.all(30), 41 | children: Providers() 42 | .siteProviders 43 | .map((e) => InkWell( 44 | onTap: () { 45 | setState(() { 46 | if (_selectedProviders.contains(e)) { 47 | _selectedProviders.remove(e); 48 | } else { 49 | _selectedProviders.add(e); 50 | } 51 | }); 52 | }, 53 | child: Card( 54 | child: Container( 55 | padding: const EdgeInsets.all(10), 56 | child: Row( 57 | mainAxisAlignment: MainAxisAlignment.spaceBetween, 58 | children: [ 59 | Column( 60 | crossAxisAlignment: CrossAxisAlignment.start, 61 | children: [ 62 | Text(e.name, textScaleFactor: 1.2), 63 | Text(e.mainUrl), 64 | ], 65 | ), 66 | Checkbox(value: _selectedProviders.contains(e), onChanged: null) 67 | ], 68 | ), 69 | ), 70 | ), 71 | )) 72 | .toList(), 73 | ), 74 | ); 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /lib/views/search_view.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | 3 | import 'package:flutter/foundation.dart'; 4 | import 'package:flutter/material.dart'; 5 | import 'package:flutter_sticky_header/flutter_sticky_header.dart'; 6 | import 'package:shimmer/shimmer.dart'; 7 | import 'package:viddroid/util/extensions/iterable_extension.dart'; 8 | import 'package:viddroid/widgets/cards/search_response_card.dart'; 9 | 10 | import '../provider/providers.dart'; 11 | import '../util/capsules/media.dart'; 12 | import '../util/capsules/search.dart'; 13 | import '../widgets/snackbars.dart'; 14 | import '../widgets/text_search_field_widget.dart'; 15 | 16 | class SearchView extends StatefulWidget { 17 | const SearchView({super.key}); 18 | 19 | @override 20 | State createState() => _SearchViewState(); 21 | } 22 | 23 | class _SearchViewState extends State { 24 | final TextEditingController _searchController = TextEditingController(); 25 | final List _currentSelectedValues = [TvType.movie, TvType.tv]; 26 | 27 | final StreamController> _searchResults = StreamController>(); 28 | 29 | final GlobalKey _formFieldKey = GlobalKey(); 30 | 31 | @override 32 | void dispose() { 33 | _searchController.dispose(); 34 | _searchResults.close(); 35 | super.dispose(); 36 | } 37 | 38 | Widget _buildSearchField() { 39 | return TextSearchField( 40 | controller: _searchController, 41 | onSubmitted: (text) { 42 | final List totalResponses = []; 43 | Providers().search(text, _currentSelectedValues).listen((event) { 44 | totalResponses.addAll(event); 45 | _searchResults.add(totalResponses); 46 | }).onError((error, stackTrace) { 47 | if (!context.mounted) { 48 | return; 49 | } 50 | 51 | ScaffoldMessenger.of(context).showSnackBar(errorSnackbar(error.toString())); 52 | if (kDebugMode) { 53 | print(stackTrace); 54 | } 55 | }); 56 | }, 57 | formFieldKey: _formFieldKey, 58 | ); 59 | } 60 | 61 | Widget _buildSearchStreamBuilder() { 62 | return StreamBuilder( 63 | stream: _searchResults.stream, 64 | builder: (context, AsyncSnapshot> snapshot) { 65 | if (snapshot.hasData && snapshot.data!.isNotEmpty) { 66 | final List validProviders = snapshot.data!.map((e) => e.apiName).unique( 67 | (element) => element, 68 | ); 69 | 70 | return CustomScrollView( 71 | primary: false, 72 | shrinkWrap: true, 73 | scrollDirection: Axis.vertical, 74 | // controller: ScrollController(), 75 | slivers: validProviders.map((provider) { 76 | List resp = snapshot.data!.where((element) => element.apiName == provider).toList(); 77 | 78 | return SliverStickyHeader( 79 | header: Container( 80 | padding: const EdgeInsets.all(10), 81 | alignment: Alignment.centerLeft, 82 | margin: const EdgeInsets.only(left: 10), 83 | decoration: BoxDecoration(borderRadius: BorderRadius.circular(12), color: Theme.of(context).colorScheme.secondaryContainer), 84 | child: Text( 85 | provider, 86 | style: const TextStyle(fontSize: 20, fontWeight: FontWeight.w600), 87 | ), 88 | ), 89 | sliver: SliverPadding( 90 | padding: const EdgeInsets.all(20), 91 | sliver: SliverGrid( 92 | gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( 93 | crossAxisCount: 4, 94 | crossAxisSpacing: 30, 95 | mainAxisSpacing: 30, 96 | ), 97 | delegate: SliverChildBuilderDelegate( 98 | (context, index) { 99 | final SearchResponse searchResponse = resp[index]; 100 | return GridTile(child: SearchResponseCard(searchResponse)); 101 | }, 102 | childCount: resp.length, 103 | ), 104 | ), 105 | ), 106 | ); 107 | }).toList(), 108 | ); 109 | } else if (snapshot.hasError) { 110 | return Text('Something went wrong. ${snapshot.error!}'); 111 | } else { 112 | return _buildShimmerPlaceholder(); 113 | } 114 | }, 115 | ); 116 | } 117 | 118 | Widget _buildShimmerPlaceholder() { 119 | //Placeholder with shimmer 120 | return Shimmer.fromColors( 121 | baseColor: Colors.black12, 122 | highlightColor: Colors.grey.shade800, 123 | period: const Duration(milliseconds: 2500), 124 | child: GridView.builder( 125 | padding: const EdgeInsets.all(20), 126 | itemCount: 8, 127 | //It is not possible to show more 8 items 128 | physics: const NeverScrollableScrollPhysics(), 129 | itemBuilder: (context, i) { 130 | return const Card(); 131 | }, 132 | gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( 133 | crossAxisCount: 4, 134 | crossAxisSpacing: 10, 135 | mainAxisSpacing: 10, 136 | ), 137 | ), 138 | ); 139 | } 140 | 141 | List _buildTvTypeChips() { 142 | return TvType.values 143 | .map((e) => Flexible( 144 | child: Container( 145 | padding: const EdgeInsets.all(10), 146 | child: FilterChip( 147 | label: Text(e.name), 148 | selected: _currentSelectedValues.contains(e), 149 | onSelected: (value) => { 150 | setState(() { 151 | if (value) { 152 | _currentSelectedValues.add(e); 153 | } else { 154 | _currentSelectedValues.remove(e); 155 | } 156 | }) 157 | }), 158 | ), 159 | )) 160 | .toList(); 161 | } 162 | 163 | Widget _buildTopRow() { 164 | return Row( 165 | children: [ 166 | ..._buildTvTypeChips(), 167 | //TODO: More options 168 | ], 169 | ); 170 | } 171 | 172 | @override 173 | Widget build(BuildContext context) { 174 | return Scaffold( 175 | appBar: AppBar( 176 | title: const Text('Search'), 177 | ), 178 | body: Column( 179 | mainAxisSize: MainAxisSize.max, 180 | children: [ 181 | _buildSearchField(), 182 | _buildTopRow(), 183 | Expanded( 184 | flex: 4, 185 | child: _buildSearchStreamBuilder(), 186 | ), 187 | ], 188 | ), 189 | ); 190 | } 191 | } 192 | -------------------------------------------------------------------------------- /lib/views/watchable_view.dart: -------------------------------------------------------------------------------- 1 | import 'package:cached_network_image/cached_network_image.dart'; 2 | import 'package:flutter/material.dart'; 3 | import 'package:viddroid/util/capsules/fetch.dart'; 4 | 5 | import '../widgets/half_page_image.dart'; 6 | import '../widgets/movie_widget.dart'; 7 | import '../widgets/tv_widget.dart'; 8 | 9 | class WatchableView extends StatefulWidget { 10 | final FetchResponse _fetchResponse; 11 | 12 | const WatchableView(this._fetchResponse, {super.key}); 13 | 14 | @override 15 | State createState() => _WatchableViewState(); 16 | } 17 | 18 | class _WatchableViewState extends State { 19 | Widget _buildTitleText() { 20 | return Flexible( 21 | child: Text( 22 | widget._fetchResponse.title, 23 | softWrap: true, 24 | maxLines: 1, 25 | style: const TextStyle( 26 | fontSize: 40, 27 | fontWeight: FontWeight.bold, 28 | ), 29 | ), 30 | ); 31 | } 32 | 33 | Widget _buildThumbnail() { 34 | return widget._fetchResponse.thumbnail == null 35 | ? const Icon(Icons.error) 36 | : CachedNetworkImage( 37 | imageUrl: widget._fetchResponse.thumbnail!, 38 | progressIndicatorBuilder: (context, url, downloadProgress) => 39 | CircularProgressIndicator(value: downloadProgress.progress), 40 | errorWidget: (context, url, error) => const Icon(Icons.error), 41 | imageBuilder: (context, imageProvider) { 42 | return Container( 43 | decoration: BoxDecoration( 44 | boxShadow: [ 45 | BoxShadow( 46 | color: Colors.black12.withValues(alpha: 0.5), 47 | spreadRadius: 5, 48 | blurRadius: 7, 49 | offset: const Offset(0, 6), 50 | ) 51 | ], 52 | ), 53 | child: Image( 54 | image: imageProvider, 55 | )); 56 | }, 57 | fit: BoxFit.cover, 58 | filterQuality: FilterQuality.medium); 59 | } 60 | 61 | Widget _buildCenter() { 62 | return Row( 63 | mainAxisAlignment: MainAxisAlignment.center, 64 | crossAxisAlignment: CrossAxisAlignment.center, 65 | children: [ 66 | Flexible( 67 | child: Container(padding: const EdgeInsets.only(right: 60), child: _buildThumbnail())), 68 | Flexible( 69 | child: Column( 70 | mainAxisAlignment: MainAxisAlignment.center, 71 | crossAxisAlignment: CrossAxisAlignment.start, 72 | children: [ 73 | _buildTitleText(), 74 | _buildTopRow(), 75 | //TODO: Overflow prevention 76 | const Text( 77 | 'Overview', 78 | textAlign: TextAlign.justify, 79 | style: TextStyle( 80 | fontWeight: FontWeight.w600, 81 | ), 82 | ), 83 | Text( 84 | widget._fetchResponse.description ?? 'N/A', 85 | maxLines: 5, 86 | ), 87 | ], 88 | ), 89 | ), 90 | ], 91 | ); 92 | } 93 | 94 | Widget _buildTopRow() { 95 | return Flexible( 96 | child: Row( 97 | mainAxisAlignment: MainAxisAlignment.start, 98 | mainAxisSize: MainAxisSize.max, 99 | children: [ 100 | Container( 101 | padding: const EdgeInsets.all(4), 102 | decoration: BoxDecoration( 103 | border: Border.all( 104 | color: Colors.white, 105 | ), 106 | borderRadius: const BorderRadius.all(Radius.circular(5))), 107 | child: Text( 108 | widget._fetchResponse.type.name.toUpperCase(), 109 | style: const TextStyle(fontWeight: FontWeight.bold), 110 | ), 111 | ), 112 | const SizedBox( 113 | width: 25, 114 | ), 115 | widget._fetchResponse.duration != null 116 | ? Text( 117 | widget._fetchResponse.duration!, 118 | ) 119 | : Container(), 120 | ], 121 | ), 122 | ); 123 | } 124 | 125 | Widget _buildResponseWidget() { 126 | final FetchResponse fetchResponse = widget._fetchResponse; 127 | 128 | if (fetchResponse is MovieFetchResponse) { 129 | return MovieWidget(fetchResponse); 130 | } else if (fetchResponse is TvFetchResponse) { 131 | return TvWidget(fetchResponse); 132 | } else { 133 | return Container(); 134 | } 135 | } 136 | 137 | @override 138 | Widget build(BuildContext context) { 139 | final FetchResponse fetchResponse = widget._fetchResponse; 140 | return Scaffold( 141 | appBar: AppBar( 142 | //title: _buildTitleText(), 143 | backgroundColor: Colors.transparent, 144 | elevation: 0, 145 | ), 146 | extendBodyBehindAppBar: true, 147 | body: Stack(children: [ 148 | HalfPageImage( 149 | tag: fetchResponse.data, 150 | imageURL: fetchResponse.backgroundImage, 151 | headers: fetchResponse.thumbnailHeaders), 152 | Container( 153 | padding: const EdgeInsets.all(70), 154 | child: Column( 155 | mainAxisAlignment: MainAxisAlignment.center, 156 | crossAxisAlignment: CrossAxisAlignment.stretch, 157 | children: [ 158 | Expanded(child: _buildCenter()), 159 | Flexible(child: _buildResponseWidget()), 160 | ], 161 | ), 162 | ) 163 | ]), 164 | ); 165 | } 166 | } 167 | -------------------------------------------------------------------------------- /lib/widgets/cards/episode_card.dart: -------------------------------------------------------------------------------- 1 | import 'package:cached_network_image/cached_network_image.dart'; 2 | import 'package:flutter/material.dart'; 3 | 4 | import '../../util/capsules/fetch.dart'; 5 | 6 | class EpisodeCard extends StatelessWidget { 7 | final Episode _episode; 8 | 9 | const EpisodeCard(this._episode, {super.key}); 10 | 11 | Widget _buildErrorImage() { 12 | return const Image( 13 | image: AssetImage('images/ep-no-thumb.jpg'), 14 | alignment: Alignment.center, 15 | height: double.infinity, 16 | width: double.infinity, 17 | fit: BoxFit.cover, 18 | ); 19 | } 20 | 21 | @override 22 | Widget build(BuildContext context) { 23 | final Size size = MediaQuery.of(context).size; 24 | return SizedBox( 25 | width: size.width * 0.3, 26 | height: size.height * 0.2, 27 | child: Card( 28 | semanticContainer: true, 29 | child: Column( 30 | mainAxisAlignment: MainAxisAlignment.start, 31 | crossAxisAlignment: CrossAxisAlignment.start, 32 | children: [ 33 | Expanded( 34 | child: _episode.thumbnail == null || _episode.thumbnail!.isEmpty 35 | ? _buildErrorImage() 36 | : CachedNetworkImage( 37 | imageUrl: _episode.thumbnail!, 38 | progressIndicatorBuilder: (context, url, downloadProgress) => 39 | CircularProgressIndicator(value: downloadProgress.progress), 40 | errorWidget: (context, url, error) => _buildErrorImage(), 41 | imageBuilder: (context, imageProvider) => Image( 42 | image: imageProvider, 43 | alignment: Alignment.center, 44 | height: double.infinity, 45 | width: double.infinity, 46 | fit: BoxFit.cover), 47 | filterQuality: FilterQuality.medium), 48 | ), 49 | Padding( 50 | padding: const EdgeInsets.only(left: 5), 51 | child: Text('Episode ${_episode.index}', 52 | style: const TextStyle(fontWeight: FontWeight.w400))), 53 | Padding( 54 | padding: const EdgeInsets.all(5), 55 | child: Text(_episode.name, 56 | softWrap: true, style: const TextStyle(fontWeight: FontWeight.bold)), 57 | ), 58 | ], 59 | ), 60 | ), 61 | ); 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /lib/widgets/cards/general_card.dart: -------------------------------------------------------------------------------- 1 | import 'package:cached_network_image/cached_network_image.dart'; 2 | import 'package:flutter/material.dart'; 3 | 4 | class GeneralPurposeCard extends StatelessWidget { 5 | final String title; 6 | final String? lowerCaption; 7 | 8 | final String? thumbnail; 9 | 10 | final Function onTap; 11 | 12 | const GeneralPurposeCard( 13 | {super.key, required this.title, this.lowerCaption, this.thumbnail, required this.onTap}); 14 | 15 | @override 16 | Widget build(BuildContext context) { 17 | return Material( 18 | child: InkWell( 19 | onTap: () => onTap.call(), 20 | child: Card( 21 | semanticContainer: true, 22 | clipBehavior: Clip.antiAlias, 23 | child: Column( 24 | children: [ 25 | Expanded( 26 | child: CachedNetworkImage( 27 | imageUrl: thumbnail == null || thumbnail!.isEmpty ? 'null' : thumbnail!, 28 | progressIndicatorBuilder: (context, url, downloadProgress) => 29 | CircularProgressIndicator(value: downloadProgress.progress), 30 | errorWidget: (context, url, error) => const Icon(Icons.error), 31 | fit: BoxFit.scaleDown), 32 | ), 33 | Text( 34 | title, 35 | style: const TextStyle(fontWeight: FontWeight.bold), 36 | ) 37 | ], 38 | ), 39 | ), 40 | ), 41 | ); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /lib/widgets/cards/search_response_card.dart: -------------------------------------------------------------------------------- 1 | import 'package:cached_network_image/cached_network_image.dart'; 2 | import 'package:flutter/material.dart'; 3 | import 'package:viddroid/provider/providers.dart'; 4 | import 'package:viddroid/util/capsules/fetch.dart'; 5 | import 'package:viddroid/util/capsules/search.dart'; 6 | import 'package:viddroid/views/watchable_view.dart'; 7 | 8 | class SearchResponseCard extends StatelessWidget { 9 | final SearchResponse _searchResponse; 10 | 11 | const SearchResponseCard(this._searchResponse, {super.key}); 12 | 13 | @override 14 | Widget build(BuildContext context) { 15 | return Material( 16 | child: InkWell( 17 | onTap: () async { 18 | final FetchResponse response = await Providers().fetch(_searchResponse); 19 | 20 | if (!context.mounted) { 21 | return; 22 | } 23 | Navigator.push(context, MaterialPageRoute(builder: (context) => WatchableView(response))); 24 | }, 25 | child: Container( 26 | decoration: BoxDecoration(color: Theme.of(context).colorScheme.primaryContainer, borderRadius: BorderRadius.circular(10)), 27 | child: Column( 28 | children: [ 29 | _searchResponse.thumbnail == null 30 | ? const Icon(Icons.question_mark_rounded) 31 | : Expanded( 32 | child: CachedNetworkImage( 33 | imageUrl: _searchResponse.thumbnail!, 34 | progressIndicatorBuilder: (context, url, downloadProgress) => CircularProgressIndicator(value: downloadProgress.progress), 35 | imageBuilder: (context, imageProvider) { 36 | return Container( 37 | decoration: BoxDecoration( 38 | boxShadow: [ 39 | BoxShadow( 40 | color: Colors.black12.withValues(alpha: 0.5), 41 | spreadRadius: 2, 42 | blurRadius: 7, 43 | offset: const Offset(0, 6), 44 | ) 45 | ], 46 | ), 47 | child: Image( 48 | image: imageProvider, 49 | filterQuality: FilterQuality.medium, 50 | fit: BoxFit.scaleDown, 51 | )); 52 | }, 53 | errorWidget: (context, url, error) => const Icon(Icons.error), 54 | ), 55 | ), 56 | Text( 57 | _searchResponse.title, 58 | style: const TextStyle(fontSize: 15, fontWeight: FontWeight.bold), 59 | ), 60 | Text(_searchResponse.type.name.toUpperCase()), 61 | ], 62 | ), 63 | ), 64 | ), 65 | ); 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /lib/widgets/cards/watchable_card_widget.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:viddroid/widgets/cards/general_card.dart'; 3 | 4 | import '../../util/watchable/watchable.dart'; 5 | 6 | class WatchableCard extends StatelessWidget { 7 | final Watchable _watchable; 8 | 9 | const WatchableCard(this._watchable, {super.key}); 10 | 11 | @override 12 | Widget build(BuildContext context) { 13 | return GeneralPurposeCard(title: _watchable.title!, onTap: () => null); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /lib/widgets/half_page_image.dart: -------------------------------------------------------------------------------- 1 | import 'dart:ui'; 2 | 3 | import 'package:cached_network_image/cached_network_image.dart'; 4 | import 'package:flutter/material.dart'; 5 | 6 | class HalfPageImage extends StatelessWidget { 7 | final String tag; 8 | final String? imageURL; 9 | final Map? headers; 10 | 11 | const HalfPageImage({super.key, required this.tag, required this.imageURL, this.headers}); 12 | 13 | Widget _buildImage(BuildContext context) { 14 | return imageURL == null 15 | ? Container(color: Theme.of(context).colorScheme.surface) 16 | : CachedNetworkImage( 17 | imageUrl: imageURL!, 18 | httpHeaders: headers, 19 | errorWidget: (context, url, error) => 20 | Container(color: Theme.of(context).colorScheme.surface), 21 | progressIndicatorBuilder: (context, url, downloadProgress) => 22 | CircularProgressIndicator(value: downloadProgress.progress), 23 | imageBuilder: (context, imageProvider) => _cachedImageBuilder(imageProvider), 24 | ); 25 | } 26 | 27 | Widget _cachedImageBuilder(ImageProvider imageProvider) { 28 | return ClipRRect( 29 | child: ImageFiltered( 30 | imageFilter: ImageFilter.blur(sigmaX: 5, sigmaY: 5, tileMode: TileMode.mirror), 31 | child: ColorFiltered( 32 | colorFilter: ColorFilter.mode(Colors.black.withValues(alpha: 0.2), BlendMode.dstATop), 33 | child: Image( 34 | image: imageProvider, 35 | fit: BoxFit.cover, 36 | ), 37 | ), 38 | ), 39 | ); 40 | } 41 | 42 | @override 43 | Widget build(BuildContext context) { 44 | final Size size = MediaQuery.of(context).size; 45 | return Hero( 46 | tag: tag, 47 | child: SizedBox( 48 | width: size.width, 49 | height: size.height, 50 | child: _buildImage(context), 51 | ), 52 | ); 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /lib/widgets/movie_widget.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:viddroid/util/capsules/fetch.dart'; 3 | import 'package:viddroid/util/capsules/link.dart'; 4 | import 'package:viddroid/util/extensions/string_extension.dart'; 5 | import 'package:viddroid/views/video_player.dart'; 6 | 7 | import '../provider/providers.dart'; 8 | 9 | class MovieWidget extends StatefulWidget { 10 | final MovieFetchResponse _fetchResponse; 11 | 12 | const MovieWidget(this._fetchResponse, {super.key}); 13 | 14 | @override 15 | State createState() => _MovieWidgetState(); 16 | } 17 | 18 | class _MovieWidgetState extends State { 19 | @override 20 | void dispose() { 21 | super.dispose(); 22 | } 23 | 24 | @override 25 | void initState() { 26 | super.initState(); 27 | } 28 | 29 | void _displayVideoPlayer(final LoadRequest loadRequest) { 30 | // Hash calculated with The provider, the movie title 31 | final String movieHash = '${widget._fetchResponse.apiName}${widget._fetchResponse.title}'.toMD5; 32 | 33 | final Route route = MaterialPageRoute( 34 | builder: (context) => VideoPlayer( 35 | hash: movieHash, 36 | title: widget._fetchResponse.title, 37 | stream: Providers().provider(widget._fetchResponse.apiName).load(loadRequest))); 38 | 39 | Navigator.pushReplacement(context, route); 40 | } 41 | 42 | @override 43 | Widget build(BuildContext context) { 44 | return SizedBox( 45 | child: ElevatedButton.icon( 46 | onPressed: () => _displayVideoPlayer(widget._fetchResponse.toLoadRequest()), 47 | icon: const Icon(Icons.play_arrow), 48 | label: const Text('Play'), 49 | ), 50 | ); 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /lib/widgets/player/animated_play_pause.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | 3 | ///Taken from: https://github.com/fluttercommunity/chewie/blob/master/lib/src/animated_play_pause.dart 4 | /// The chewie project is licensed unter the MIT license. 5 | /// All credit goes to the authors. 6 | /// A widget that animates implicitly between a play and a pause icon. 7 | class AnimatedPlayPause extends StatefulWidget { 8 | const AnimatedPlayPause({ 9 | super.key, 10 | required this.playing, 11 | this.size, 12 | this.color, 13 | }); 14 | 15 | final double? size; 16 | final bool playing; 17 | final Color? color; 18 | 19 | @override 20 | State createState() => AnimatedPlayPauseState(); 21 | } 22 | 23 | class AnimatedPlayPauseState extends State with SingleTickerProviderStateMixin { 24 | late final animationController = AnimationController( 25 | vsync: this, 26 | value: widget.playing ? 1 : 0, 27 | duration: const Duration(milliseconds: 400), 28 | ); 29 | 30 | @override 31 | void didUpdateWidget(AnimatedPlayPause oldWidget) { 32 | super.didUpdateWidget(oldWidget); 33 | if (widget.playing != oldWidget.playing) { 34 | if (widget.playing) { 35 | animationController.forward(); 36 | } else { 37 | animationController.reverse(); 38 | } 39 | } 40 | } 41 | 42 | @override 43 | void dispose() { 44 | animationController.dispose(); 45 | super.dispose(); 46 | } 47 | 48 | @override 49 | Widget build(BuildContext context) { 50 | return Center( 51 | child: AnimatedIcon( 52 | color: widget.color, 53 | size: widget.size, 54 | icon: AnimatedIcons.play_pause, 55 | progress: animationController, 56 | ), 57 | ); 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /lib/widgets/player/center_play_button.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | 3 | import 'animated_play_pause.dart'; 4 | 5 | ///Taken from: https://github.com/fluttercommunity/chewie/blob/master/lib/src/center_play_button.dart 6 | class CenterPlayButton extends StatelessWidget { 7 | const CenterPlayButton({ 8 | super.key, 9 | required this.backgroundColor, 10 | this.iconColor, 11 | required this.show, 12 | required this.isPlaying, 13 | required this.isFinished, 14 | this.onPressed, 15 | }); 16 | 17 | final Color backgroundColor; 18 | final Color? iconColor; 19 | final bool show; 20 | final bool isPlaying; 21 | final bool isFinished; 22 | final VoidCallback? onPressed; 23 | 24 | @override 25 | Widget build(BuildContext context) { 26 | return ColoredBox( 27 | color: Colors.transparent, 28 | child: Center( 29 | child: UnconstrainedBox( 30 | child: AnimatedOpacity( 31 | opacity: show ? 1.0 : 0.0, 32 | duration: const Duration(milliseconds: 300), 33 | child: DecoratedBox( 34 | decoration: BoxDecoration( 35 | color: backgroundColor, 36 | shape: BoxShape.circle, 37 | ), 38 | // Always set the iconSize on the IconButton, not on the Icon itself: 39 | // https://github.com/flutter/flutter/issues/52980 40 | child: IconButton( 41 | iconSize: 32, 42 | padding: const EdgeInsets.all(12.0), 43 | icon: isFinished 44 | ? Icon(Icons.replay, color: iconColor) 45 | : AnimatedPlayPause( 46 | color: iconColor, 47 | playing: isPlaying, 48 | ), 49 | onPressed: onPressed, 50 | ), 51 | ), 52 | ), 53 | ), 54 | ), 55 | ); 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /lib/widgets/player/option_dialog.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | 3 | import '../../util/capsules/option_item.dart'; 4 | 5 | /// Taken from: https://github.com/fluttercommunity/chewie/blob/master/lib/src/material/widgets/options_dialog.dart 6 | /// The chewie project is licensed unter the MIT license. 7 | /// All credit goes to the authors. 8 | class OptionsDialog extends StatefulWidget { 9 | const OptionsDialog({ 10 | super.key, 11 | required this.options, 12 | this.cancelButtonText, 13 | }); 14 | 15 | final List options; 16 | final String? cancelButtonText; 17 | 18 | @override 19 | // ignore: library_private_types_in_public_api 20 | _OptionsDialogState createState() => _OptionsDialogState(); 21 | } 22 | 23 | class _OptionsDialogState extends State { 24 | @override 25 | Widget build(BuildContext context) { 26 | return SafeArea( 27 | child: Column( 28 | mainAxisSize: MainAxisSize.min, 29 | children: [ 30 | Flexible( 31 | child: ListView.builder( 32 | shrinkWrap: true, 33 | itemCount: widget.options.length, 34 | itemBuilder: (context, i) { 35 | return ListTile( 36 | onTap: widget.options[i].onTap, 37 | leading: Icon(widget.options[i].iconData), 38 | title: Text(widget.options[i].title), 39 | subtitle: 40 | widget.options[i].subtitle != null ? Text(widget.options[i].subtitle!) : null, 41 | ); 42 | }, 43 | ), 44 | ), 45 | const Padding( 46 | padding: EdgeInsets.symmetric(horizontal: 16), 47 | child: Divider( 48 | thickness: 1.0, 49 | ), 50 | ), 51 | ListTile( 52 | onTap: () => Navigator.pop(context), 53 | leading: const Icon(Icons.close), 54 | title: Text( 55 | widget.cancelButtonText ?? 'Cancel', 56 | ), 57 | ), 58 | ], 59 | ), 60 | ); 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /lib/widgets/player/playback_speed_dialog.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | 3 | /// Taken from: https://github.com/fluttercommunity/chewie/blob/master/lib/src/material/widgets/playback_speed_dialog.dart 4 | /// The chewie project is licensed unter the MIT license. 5 | /// All credit goes to the authors. 6 | class PlaybackSpeedDialog extends StatelessWidget { 7 | const PlaybackSpeedDialog({ 8 | super.key, 9 | required List speeds, 10 | required double selected, 11 | }) : _speeds = speeds, 12 | _selected = selected; 13 | 14 | final List _speeds; 15 | final double _selected; 16 | 17 | @override 18 | Widget build(BuildContext context) { 19 | final Color selectedColor = Theme.of(context).primaryColor; 20 | 21 | return ListView.builder( 22 | shrinkWrap: true, 23 | physics: const ScrollPhysics(), 24 | itemBuilder: (context, index) { 25 | final speed = _speeds[index]; 26 | return ListTile( 27 | dense: true, 28 | title: Row( 29 | children: [ 30 | if (speed == _selected) 31 | Icon( 32 | Icons.check, 33 | size: 20.0, 34 | color: selectedColor, 35 | ) 36 | else 37 | Container(width: 20.0), 38 | const SizedBox(width: 16.0), 39 | Text(speed.toString()), 40 | ], 41 | ), 42 | selected: speed == _selected, 43 | onTap: () { 44 | Navigator.of(context).pop(speed); 45 | }, 46 | ); 47 | }, 48 | itemCount: _speeds.length, 49 | ); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /lib/widgets/player/subtitle_widget.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | 3 | import 'package:flutter/material.dart'; 4 | import 'package:media_kit/media_kit.dart'; 5 | import 'package:subtitle/subtitle.dart'; 6 | 7 | class SubtitleWidget extends StatefulWidget { 8 | final Player player; 9 | 10 | final StreamController subtitleStream; 11 | 12 | const SubtitleWidget({super.key, required this.player, required this.subtitleStream}); 13 | 14 | @override 15 | State createState() => _SubtitleWidgetState(); 16 | } 17 | 18 | class _SubtitleWidgetState extends State { 19 | Duration position = Duration.zero; 20 | SubtitleController? subtitleController; 21 | Subtitle? subtitle; 22 | 23 | List subscriptions = []; 24 | 25 | @override 26 | void initState() { 27 | super.initState(); 28 | position = widget.player.state.position; 29 | 30 | subscriptions.addAll( 31 | [ 32 | widget.subtitleStream.stream.listen((event) { 33 | setState(() { 34 | subtitleController = event; 35 | subtitle = subtitleController?.durationSearch(position); 36 | }); 37 | }), 38 | //TODO: Just increment; this is inefficient for now. 39 | widget.player.stream.position.listen((event) { 40 | setState(() { 41 | position = event; 42 | subtitle = subtitleController?.durationSearch(position); 43 | }); 44 | }), 45 | ], 46 | ); 47 | } 48 | 49 | @override 50 | void dispose() { 51 | super.dispose(); 52 | for (final s in subscriptions) { 53 | s.cancel(); 54 | } 55 | } 56 | 57 | @override 58 | Widget build(BuildContext context) { 59 | return Column( 60 | mainAxisAlignment: MainAxisAlignment.end, 61 | children: [ 62 | Container( 63 | alignment: Alignment.center, 64 | padding: const EdgeInsets.all(30), 65 | child: Text( 66 | softWrap: true, 67 | subtitle?.data ?? '', 68 | style: 69 | const TextStyle(backgroundColor: Colors.black54, fontSize: 25, color: Colors.white), 70 | ), 71 | ), 72 | ], 73 | ); 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /lib/widgets/settings/abstract_settings_tile.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | 3 | mixin SettingsTile { 4 | Widget buildSetting(final BuildContext context, 5 | {required SettingsTileAdditionalInfo additionalInfo, 6 | required bool enabled, 7 | Widget? description, 8 | required Widget titleContent}) { 9 | return IgnorePointer( 10 | ignoring: !enabled, 11 | child: Column( 12 | children: [ 13 | _buildTitle(context: context, additionalInfo: additionalInfo, titleContent: titleContent), 14 | if (description != null) 15 | _buildDescription( 16 | context: context, additionalInfo: additionalInfo, description: description), 17 | ], 18 | ), 19 | ); 20 | } 21 | 22 | Widget _buildDescription( 23 | {required BuildContext context, 24 | required SettingsTileAdditionalInfo additionalInfo, 25 | required Widget description}) { 26 | final scaleFactor = MediaQuery.of(context).textScaleFactor; 27 | 28 | return Container( 29 | width: MediaQuery.of(context).size.width, 30 | padding: EdgeInsets.only( 31 | left: 18, 32 | right: 18, 33 | top: 8 * scaleFactor, 34 | bottom: additionalInfo.needToShowDivider ? 24 : 8 * scaleFactor, 35 | ), 36 | decoration: const BoxDecoration(), 37 | child: description, 38 | ); 39 | } 40 | 41 | Widget _buildTitle( 42 | {required BuildContext context, 43 | required SettingsTileAdditionalInfo additionalInfo, 44 | required Widget titleContent}) { 45 | return ClipRRect( 46 | borderRadius: BorderRadius.vertical( 47 | top: additionalInfo.enableTopBorderRadius ? const Radius.circular(12) : Radius.zero, 48 | bottom: additionalInfo.enableBottomBorderRadius ? const Radius.circular(12) : Radius.zero, 49 | ), 50 | child: Material( 51 | color: Colors.transparent, 52 | child: titleContent, 53 | ), 54 | ); 55 | } 56 | } 57 | 58 | class SettingsTileAdditionalInfo extends InheritedWidget { 59 | final bool needToShowDivider; 60 | final bool enableTopBorderRadius; 61 | final bool enableBottomBorderRadius; 62 | 63 | const SettingsTileAdditionalInfo({ 64 | super.key, 65 | required this.needToShowDivider, 66 | required this.enableTopBorderRadius, 67 | required this.enableBottomBorderRadius, 68 | required super.child, 69 | }); 70 | 71 | @override 72 | bool updateShouldNotify(SettingsTileAdditionalInfo oldWidget) => true; 73 | 74 | static SettingsTileAdditionalInfo of(BuildContext context) { 75 | final SettingsTileAdditionalInfo? result = 76 | context.dependOnInheritedWidgetOfExactType(); 77 | 78 | return result ?? 79 | const SettingsTileAdditionalInfo( 80 | needToShowDivider: true, 81 | enableBottomBorderRadius: true, 82 | enableTopBorderRadius: true, 83 | child: SizedBox(), 84 | ); 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /lib/widgets/settings/base_settings_tile.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:viddroid/util/capsules/option_item.dart'; 3 | import 'package:viddroid/widgets/settings/abstract_settings_tile.dart'; 4 | import 'package:viddroid/widgets/settings/navigation_settings_tile.dart'; 5 | import 'package:viddroid/widgets/settings/selection_settings_tile.dart'; 6 | import 'package:viddroid/widgets/settings/switch_settings_tile.dart'; 7 | 8 | enum SettingsTileType { simpleTile, switchTile, navigationTile, selectionTile, inputTile } 9 | 10 | class SimpleSettingsTile extends StatefulWidget { 11 | const SimpleSettingsTile({ 12 | this.leading, 13 | required this.title, 14 | this.description, 15 | this.onPressed, 16 | this.onToggle, 17 | this.value, 18 | this.toggled, 19 | this.enabled = true, 20 | this.trailing, 21 | this.optionItems, 22 | this.onSubmitted, 23 | this.formFieldHint, 24 | this.initialValue, 25 | required this.tileType, 26 | super.key, 27 | }); 28 | 29 | final Widget title; 30 | final Widget? description; 31 | final Widget? leading; 32 | final Widget? trailing; 33 | final Widget? value; 34 | 35 | final String? formFieldHint; 36 | final String? initialValue; 37 | 38 | final List? optionItems; 39 | 40 | final Function(BuildContext context)? onPressed; 41 | final Function(bool value)? onToggle; 42 | final Function(String? value)? onSubmitted; 43 | 44 | final bool? toggled; 45 | final bool enabled; 46 | final SettingsTileType tileType; 47 | 48 | @override 49 | State createState() => _SimpleSettingsTileState(); 50 | } 51 | 52 | class _SimpleSettingsTileState extends State with SettingsTile { 53 | bool isPressed = false; 54 | 55 | @override 56 | Widget build(BuildContext context) { 57 | final additionalInfo = SettingsTileAdditionalInfo.of(context); 58 | if (widget.tileType == SettingsTileType.selectionTile) { 59 | return SelectionSettingsTile( 60 | items: widget.optionItems ?? List.empty(), 61 | onTap: widget.onPressed != null ? widget.onPressed!(context) : null, 62 | title: widget.title, 63 | trailing: widget.trailing, 64 | description: widget.description, 65 | leading: widget.leading, 66 | ); 67 | } else if (widget.tileType == SettingsTileType.inputTile) { 68 | //TODO: Add validator & move the description to the bottom 69 | return Column( 70 | children: [ 71 | buildSetting(context, 72 | additionalInfo: additionalInfo, 73 | enabled: widget.enabled, 74 | description: widget.description, 75 | titleContent: _buildTileContent(context, additionalInfo)), 76 | TextFormField( 77 | enabled: widget.enabled, 78 | initialValue: widget.initialValue, 79 | onFieldSubmitted: widget.onSubmitted, 80 | decoration: InputDecoration( 81 | border: InputBorder.none, 82 | focusedBorder: InputBorder.none, 83 | enabledBorder: InputBorder.none, 84 | errorBorder: InputBorder.none, 85 | disabledBorder: InputBorder.none, 86 | hintText: widget.formFieldHint, 87 | contentPadding: const EdgeInsets.only(left: 18, bottom: 11, top: 11, right: 15)), 88 | ), 89 | ], 90 | ); 91 | } else { 92 | return buildSetting(context, 93 | additionalInfo: additionalInfo, 94 | enabled: widget.enabled, 95 | description: widget.description, 96 | titleContent: _buildTileContent(context, additionalInfo)); 97 | } 98 | } 99 | 100 | Widget _buildTrailing({ 101 | required BuildContext context, 102 | }) { 103 | return Row( 104 | children: [ 105 | if (widget.trailing != null) widget.trailing!, 106 | if (widget.tileType == SettingsTileType.switchTile) 107 | SwitchSettingsTile(toggled: widget.toggled, onToggle: widget.onToggle), 108 | if (widget.tileType == SettingsTileType.navigationTile) 109 | NavigationSettingsTile( 110 | value: widget.value, 111 | ), 112 | ], 113 | ); 114 | } 115 | 116 | void _changePressState({bool isPressed = false}) { 117 | if (mounted) { 118 | setState(() { 119 | this.isPressed = isPressed; 120 | }); 121 | } 122 | } 123 | 124 | Widget _buildTileContent( 125 | final BuildContext context, 126 | final SettingsTileAdditionalInfo additionalInfo, 127 | ) { 128 | final scaleFactor = MediaQuery.of(context).textScaleFactor; 129 | 130 | return InkWell( 131 | onTap: widget.onPressed == null 132 | ? null 133 | : () { 134 | _changePressState(isPressed: true); 135 | 136 | widget.onPressed!.call(context); 137 | 138 | Future.delayed( 139 | const Duration(milliseconds: 100), 140 | () => _changePressState(isPressed: false), 141 | ); 142 | }, 143 | child: Container( 144 | padding: const EdgeInsetsDirectional.only(start: 18), 145 | child: Row( 146 | children: [ 147 | if (widget.leading != null) 148 | Padding( 149 | padding: const EdgeInsetsDirectional.only(end: 12.0), 150 | child: IconTheme.merge( 151 | data: const IconThemeData(), 152 | child: widget.leading!, 153 | ), 154 | ), 155 | Expanded( 156 | child: Column( 157 | mainAxisSize: MainAxisSize.min, 158 | mainAxisAlignment: MainAxisAlignment.center, 159 | children: [ 160 | Padding( 161 | padding: const EdgeInsetsDirectional.only(end: 16), 162 | child: Row( 163 | children: [ 164 | Expanded( 165 | child: Padding( 166 | padding: EdgeInsetsDirectional.only( 167 | top: 12.5 * scaleFactor, 168 | bottom: 12.5 * scaleFactor, 169 | ), 170 | child: widget.title), 171 | ), 172 | _buildTrailing(context: context), 173 | ], 174 | ), 175 | ), 176 | if (widget.description == null && additionalInfo.needToShowDivider) 177 | const Divider( 178 | height: 0, 179 | thickness: 0.7, 180 | ), 181 | ], 182 | ), 183 | ), 184 | ], 185 | ), 186 | ), 187 | ); 188 | } 189 | } 190 | -------------------------------------------------------------------------------- /lib/widgets/settings/navigation_settings_tile.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/cupertino.dart'; 2 | 3 | class NavigationSettingsTile extends StatelessWidget { 4 | final Widget? value; 5 | 6 | const NavigationSettingsTile({super.key, this.value}); 7 | 8 | @override 9 | Widget build(BuildContext context) { 10 | final scaleFactor = MediaQuery.of(context).textScaleFactor; 11 | 12 | return Row( 13 | children: [ 14 | if (value != null) 15 | DefaultTextStyle( 16 | style: const TextStyle( 17 | fontSize: 17, 18 | ), 19 | child: value!, 20 | ), 21 | Padding( 22 | padding: const EdgeInsetsDirectional.only(start: 6, end: 2), 23 | child: Icon( 24 | CupertinoIcons.chevron_forward, 25 | size: 18 * scaleFactor, 26 | ), 27 | ) 28 | ], 29 | ); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /lib/widgets/settings/selection_settings_tile.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | 3 | import '../../util/capsules/option_item.dart'; 4 | 5 | class SelectionSettingsTile extends StatefulWidget { 6 | final Widget title; 7 | final Widget? description; 8 | final Widget? leading; 9 | final Widget? trailing; 10 | 11 | final List items; 12 | final Function()? onTap; 13 | 14 | const SelectionSettingsTile( 15 | {super.key, 16 | required this.items, 17 | this.onTap, 18 | required this.title, 19 | this.description, 20 | this.leading, 21 | this.trailing}); 22 | 23 | @override 24 | State createState() => _SelectionSettingsTileState(); 25 | } 26 | 27 | class _SelectionSettingsTileState extends State { 28 | @override 29 | Widget build(BuildContext context) { 30 | return Theme( 31 | data: Theme.of(context).copyWith(dividerColor: Colors.transparent), 32 | child: ExpansionTile( 33 | //TODO: Shape & custom expansion tile 34 | leading: widget.leading, 35 | trailing: widget.trailing, 36 | title: const Text('null'), 37 | subtitle: widget.description, 38 | 39 | children: widget.items 40 | .map((e) => InkWell( 41 | onTap: e.onTap, 42 | child: ListTile( 43 | title: Text(e.title), 44 | leading: Icon(e.iconData), 45 | ), 46 | )) 47 | .toList(), 48 | ), 49 | ); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /lib/widgets/settings/settings_list.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:viddroid/widgets/settings/settings_section.dart'; 3 | 4 | class SettingsList extends StatefulWidget { 5 | const SettingsList({ 6 | required this.sections, 7 | this.physics, 8 | this.contentPadding, 9 | super.key, 10 | }); 11 | 12 | final ScrollPhysics? physics; 13 | final EdgeInsetsGeometry? contentPadding; 14 | final List sections; 15 | 16 | @override 17 | State createState() => _SettingsListState(); 18 | } 19 | 20 | class _SettingsListState extends State { 21 | int _selectedIndex = 0; 22 | 23 | @override 24 | Widget build(BuildContext context) { 25 | return Row( 26 | mainAxisAlignment: MainAxisAlignment.center, 27 | crossAxisAlignment: CrossAxisAlignment.start, 28 | children: [ 29 | SingleChildScrollView( 30 | physics: widget.physics, 31 | child: IntrinsicHeight( 32 | child: NavigationRail( 33 | labelType: NavigationRailLabelType.all, 34 | useIndicator: true, 35 | selectedIndex: _selectedIndex, 36 | onDestinationSelected: (int index) { 37 | setState(() { 38 | _selectedIndex = index; 39 | }); 40 | }, 41 | destinations: widget.sections 42 | .map((e) => NavigationRailDestination(icon: e.icon, label: e.title)) 43 | .toList(), 44 | ), 45 | )), 46 | Expanded(child: widget.sections[_selectedIndex]) 47 | ], 48 | ); 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /lib/widgets/settings/settings_section.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:viddroid/widgets/settings/base_settings_tile.dart'; 3 | 4 | import 'abstract_settings_tile.dart'; 5 | 6 | class SettingsSection extends StatelessWidget { 7 | const SettingsSection({ 8 | required this.tiles, 9 | this.margin, 10 | required this.title, 11 | required this.icon, 12 | super.key, 13 | }); 14 | 15 | final List tiles; 16 | final EdgeInsetsDirectional? margin; 17 | final Widget icon; 18 | final Widget title; 19 | 20 | @override 21 | Widget build(BuildContext context) { 22 | final bool isLastNonDescriptive = (tiles.last).description == null; 23 | final double scaleFactor = MediaQuery.of(context).textScaleFactor; 24 | 25 | return Padding( 26 | padding: margin ?? 27 | EdgeInsets.only( 28 | top: 14.0 * scaleFactor, 29 | bottom: isLastNonDescriptive ? 27 * scaleFactor : 10 * scaleFactor, 30 | left: 16, 31 | right: 16, 32 | ), 33 | child: Column( 34 | children: [ 35 | Padding( 36 | padding: EdgeInsetsDirectional.only( 37 | start: 18, 38 | bottom: 5 * scaleFactor, 39 | ), 40 | child: title), 41 | // TODO: Remove scrolling in the card itself, make the card expand and scrollable 42 | Flexible( 43 | child: Card( 44 | shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), 45 | elevation: 2, 46 | child: buildTileList(), 47 | ), 48 | ), 49 | ], 50 | ), 51 | ); 52 | } 53 | 54 | Widget buildTileList() { 55 | return ListView.builder( 56 | shrinkWrap: true, 57 | itemCount: tiles.length, 58 | padding: EdgeInsets.zero, 59 | itemBuilder: (BuildContext context, int index) { 60 | final tile = tiles[index]; 61 | 62 | var enableTop = false; 63 | 64 | if (index == 0 || (index > 0 && (tiles[index - 1]).description != null)) { 65 | enableTop = true; 66 | } 67 | 68 | var enableBottom = false; 69 | 70 | if (index == tiles.length - 1 || (index < tiles.length && (tile).description != null)) { 71 | enableBottom = true; 72 | } 73 | return SettingsTileAdditionalInfo( 74 | enableTopBorderRadius: enableTop, 75 | enableBottomBorderRadius: enableBottom, 76 | needToShowDivider: index != tiles.length - 1, 77 | child: tile, 78 | ); 79 | }, 80 | ); 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /lib/widgets/settings/switch_settings_tile.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | 3 | class SwitchSettingsTile extends StatelessWidget { 4 | final Function(bool value)? onToggle; 5 | final bool? toggled; 6 | 7 | final WidgetStateProperty thumbIcon = WidgetStateProperty.resolveWith( 8 | (Set states) { 9 | // Thumb icon when the switch is selected. 10 | if (states.contains(WidgetState.selected)) { 11 | return const Icon(Icons.check); 12 | } 13 | return const Icon(Icons.close); 14 | }, 15 | ); 16 | 17 | SwitchSettingsTile({super.key, this.onToggle, this.toggled}); 18 | 19 | @override 20 | Widget build(BuildContext context) { 21 | return Switch( 22 | thumbIcon: thumbIcon, 23 | value: toggled ?? true, 24 | onChanged: onToggle, 25 | ); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /lib/widgets/snackbars.dart: -------------------------------------------------------------------------------- 1 | import 'package:awesome_snackbar_content/awesome_snackbar_content.dart'; 2 | import 'package:flutter/material.dart'; 3 | 4 | SnackBar _defaultBar(Widget content) => SnackBar( 5 | elevation: 0, 6 | behavior: SnackBarBehavior.floating, 7 | backgroundColor: Colors.transparent, 8 | content: content); 9 | 10 | SnackBar successSnackbar(String message) => _defaultBar( 11 | AwesomeSnackbarContent( 12 | title: 'Success!', 13 | message: message, 14 | contentType: ContentType.success, 15 | ), 16 | ); 17 | 18 | SnackBar infoSnackbar(String message) => _defaultBar( 19 | AwesomeSnackbarContent(title: 'Hey!', message: message, contentType: ContentType.help)); 20 | 21 | SnackBar errorSnackbar(String message) => _defaultBar( 22 | AwesomeSnackbarContent( 23 | title: 'Oh Snap!', 24 | message: message, 25 | 26 | /// change contentType to ContentType.success, ContentType.warning or ContentType.help for variants 27 | contentType: ContentType.failure, 28 | ), 29 | ); 30 | -------------------------------------------------------------------------------- /lib/widgets/text_search_field_widget.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | 3 | class TextSearchField extends StatelessWidget { 4 | final Function(String) onSubmitted; 5 | final TextEditingController? controller; 6 | 7 | final GlobalKey formFieldKey; 8 | 9 | const TextSearchField( 10 | {super.key, this.controller, required this.onSubmitted, required this.formFieldKey}); 11 | 12 | @override 13 | Widget build(BuildContext context) { 14 | return Flexible( 15 | child: Container( 16 | padding: const EdgeInsets.all(10), 17 | child: TextFormField( 18 | key: formFieldKey, 19 | controller: controller, 20 | autofocus: true, 21 | decoration: const InputDecoration( 22 | prefixIcon: Icon(Icons.search), 23 | border: OutlineInputBorder(borderRadius: BorderRadius.all(Radius.circular(15.0)))), 24 | validator: (String? val) => 25 | (val == null || val.isEmpty) ? 'Please enter a search term first.' : null, 26 | onFieldSubmitted: (value) { 27 | if (formFieldKey.currentState != null && formFieldKey.currentState!.validate()) { 28 | onSubmitted.call(value); 29 | } 30 | }), 31 | ), 32 | ); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /lib/widgets/tv_widget.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:viddroid/util/capsules/fetch.dart'; 3 | import 'package:viddroid/util/extensions/string_extension.dart'; 4 | import 'package:viddroid/views/video_player.dart'; 5 | import 'package:viddroid/widgets/cards/episode_card.dart'; 6 | 7 | import '../provider/providers.dart'; 8 | 9 | class TvWidget extends StatefulWidget { 10 | final TvFetchResponse _fetchResponse; 11 | 12 | late final List> _seasons; 13 | 14 | TvWidget(this._fetchResponse, {super.key}) { 15 | _seasons = List.generate(_fetchResponse.seasons, (index) => index) 16 | .map((index) => DropdownMenuItem(value: index, child: Text('Season $index'))) 17 | .toList(); 18 | } 19 | 20 | @override 21 | State createState() => _TvWidgetState(); 22 | } 23 | 24 | class _TvWidgetState extends State { 25 | int dropdownValue = 0; 26 | List episodes = []; 27 | 28 | final ScrollController _scrollController = ScrollController(); 29 | 30 | @override 31 | void dispose() { 32 | _scrollController.dispose(); 33 | super.dispose(); 34 | } 35 | 36 | @override 37 | void initState() { 38 | super.initState(); 39 | _loadEpisodesForSeason(); 40 | } 41 | 42 | void _loadEpisodesForSeason() { 43 | episodes = 44 | widget._fetchResponse.episodes.where((element) => element.season == dropdownValue).toList(); 45 | } 46 | 47 | void _displayVideoPlayer(final Episode episode) { 48 | // Hash calculated with The provider, the tv title, the season and the index. 49 | final String episodeHash = 50 | '${widget._fetchResponse.apiName}${widget._fetchResponse.title}${episode.season}${episode.index}' 51 | .toMD5; 52 | 53 | final Route route = MaterialPageRoute( 54 | builder: (context) => VideoPlayer( 55 | hash: episodeHash, 56 | stream: 57 | Providers().provider(widget._fetchResponse.apiName).load(episode.toLoadRequest()), 58 | title: widget._fetchResponse.title)); 59 | Navigator.pushReplacement(context, route); 60 | } 61 | 62 | @override 63 | Widget build(BuildContext context) { 64 | return Column( 65 | crossAxisAlignment: CrossAxisAlignment.start, 66 | mainAxisAlignment: MainAxisAlignment.start, 67 | children: [ 68 | DropdownButton( 69 | icon: const Icon(Icons.menu), 70 | items: widget._seasons, 71 | onChanged: (int? value) => setState(() { 72 | dropdownValue = value ?? 0; 73 | _loadEpisodesForSeason(); 74 | }), 75 | value: dropdownValue, 76 | ), 77 | Expanded( 78 | child: Scrollbar( 79 | controller: _scrollController, 80 | child: ListView.builder( 81 | shrinkWrap: true, 82 | controller: _scrollController, 83 | itemCount: episodes.length, 84 | scrollDirection: Axis.horizontal, 85 | itemBuilder: (context, index) { 86 | return Container( 87 | padding: const EdgeInsets.all(10), 88 | child: InkWell( 89 | onTap: () => _displayVideoPlayer(episodes[index]), 90 | child: EpisodeCard(episodes[index]))); 91 | }, 92 | ), 93 | ), 94 | ) 95 | ], 96 | ); 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /lib/widgets/watchables_list_widget.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | 3 | import '../util/watchable/watchable.dart'; 4 | 5 | class WatchablesList extends StatelessWidget { 6 | final List _list; 7 | 8 | const WatchablesList(this._list, {super.key}); 9 | 10 | @override 11 | Widget build(BuildContext context) { 12 | final Size size = MediaQuery.of(context).size; 13 | return SizedBox( 14 | height: size.height / 1.2, 15 | width: size.width, 16 | child: GridView.count( 17 | primary: false, 18 | padding: const EdgeInsets.all(20), 19 | crossAxisSpacing: 10, 20 | mainAxisSpacing: 10, 21 | crossAxisCount: 4, 22 | children: [ 23 | Container( 24 | padding: const EdgeInsets.all(8), 25 | color: Colors.teal[400], 26 | child: const Text('This main page is work in progress. Bookmarked media will be displayed here.'), 27 | ), 28 | ], 29 | ), 30 | ); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /macos/Flutter/GeneratedPluginRegistrant.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Generated file. Do not edit. 3 | // 4 | 5 | import FlutterMacOS 6 | import Foundation 7 | 8 | import file_picker 9 | import local_notifier 10 | import media_kit_libs_macos_video 11 | import media_kit_video 12 | import package_info_plus 13 | import path_provider_foundation 14 | import screen_brightness_macos 15 | import screen_retriever_macos 16 | import sqflite_darwin 17 | import wakelock_plus 18 | import window_manager 19 | 20 | func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { 21 | FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin")) 22 | LocalNotifierPlugin.register(with: registry.registrar(forPlugin: "LocalNotifierPlugin")) 23 | MediaKitLibsMacosVideoPlugin.register(with: registry.registrar(forPlugin: "MediaKitLibsMacosVideoPlugin")) 24 | MediaKitVideoPlugin.register(with: registry.registrar(forPlugin: "MediaKitVideoPlugin")) 25 | FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin")) 26 | PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) 27 | ScreenBrightnessMacosPlugin.register(with: registry.registrar(forPlugin: "ScreenBrightnessMacosPlugin")) 28 | ScreenRetrieverMacosPlugin.register(with: registry.registrar(forPlugin: "ScreenRetrieverMacosPlugin")) 29 | SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin")) 30 | WakelockPlusMacosPlugin.register(with: registry.registrar(forPlugin: "WakelockPlusMacosPlugin")) 31 | WindowManagerPlugin.register(with: registry.registrar(forPlugin: "WindowManagerPlugin")) 32 | } 33 | -------------------------------------------------------------------------------- /pubspec.yaml: -------------------------------------------------------------------------------- 1 | name: viddroid 2 | description: A desktop application to watch and download media such as tv-series and movies. 3 | 4 | # The following line prevents the package from being accidentally published to 5 | # pub.dev using `flutter pub publish`. This is preferred for private packages. 6 | publish_to: 'none' # Remove this line if you wish to publish to pub.dev 7 | 8 | # The following defines the version and build number for your application. 9 | # A version number is three numbers separated by dots, like 1.2.43 10 | # followed by an optional build number separated by a +. 11 | # Both the version and the builder number may be overridden in flutter 12 | # build by specifying --build-name and --build-number, respectively. 13 | # In Android, build-name is used as versionName while build-number used as versionCode. 14 | # Read more about Android versioning at https://developer.android.com/studio/publish/versioning 15 | # In iOS, build-name is used as CFBundleShortVersionString while build-number is used as CFBundleVersion. 16 | # Read more about iOS versioning at 17 | # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html 18 | # In Windows, build-name is used as the major, minor, and patch parts 19 | # of the product and file versions while build-number is used as the build suffix. 20 | version: 1.0.0+1 21 | 22 | environment: 23 | sdk: ">=2.17.0 <4.0.0" 24 | flutter: ">=3.7.0" 25 | 26 | # Dependencies specify other packages that your package needs in order to work. 27 | # To automatically upgrade your package dependencies to the latest versions 28 | # consider running `flutter pub upgrade --major-versions`. Alternatively, 29 | # dependencies can be manually updated by changing the version numbers below to 30 | # the latest version available on pub.dev. To see which dependencies have newer 31 | # versions available, run `flutter pub outdated`. 32 | dependencies: 33 | flutter: 34 | sdk: flutter 35 | 36 | cupertino_icons: ^1.0.2 37 | #uuid: ^3.0.7 # Not used atm 38 | awesome_snackbar_content: ^0.1.0 # UI enhancement 39 | file_picker: ^9.0.2 # Needed for desktop file-system-dialoges 40 | html: ^0.15.1 # HTML parser, similar to Jsoup 41 | cached_network_image: ^3.2.3 # Cached network images, very nice. 42 | loading_animation_widget: ^1.2.0+4 # Not used atm, could be useful in the future. 43 | shimmer: ^3.0.0 # UI enhancement, provides a shimmer with gradients 44 | sticky_headers: ^0.3.0+2 # Enhancement for the gridview 45 | puppeteer: ^3.14.0 # Not really used atm; will be needed for headless browsers later on 46 | encrypt: ^5.0.1 # As dart does not offer an easy-to-use api & I'm too lazy to actually figure it out. 47 | dio: ^5.0.0 # Replacement for the request package, used to handle http 48 | 49 | media_kit: ^1.1.4 # Primary package. 50 | media_kit_video: ^1.1.5 # For video rendering. 51 | media_kit_native_event_loop: ^1.0.7 # Support for higher number of concurrent instances & better performance. 52 | media_kit_libs_android_video: ^1.3.2 # Android package for video native libraries. 53 | media_kit_libs_ios_video: ^1.1.3 # iOS package for video native libraries. 54 | media_kit_libs_macos_video: ^1.1.3 # macOS package for video native libraries. 55 | media_kit_libs_windows_video: ^1.0.7 # Windows package for video native libraries. 56 | media_kit_libs_linux: ^1.1.1 # GNU/Linux dependency package. 57 | 58 | window_manager: ^0.4.2 # Used to resize the window, links into the native bindings. 59 | 60 | dio_cookie_manager: ^3.1.1 # Manages the cookies for dio, as requests is no longer used 61 | jovial_misc: ^0.9.2 # Library which provides a crypto input stream to download encrypted hls 62 | local_notifier: ^0.1.5 # Used to display local (desktop) notifications, as this binds to native dependencies 63 | localstore: ^1.3.5 # Used to read and write data 64 | logger: ^2.4.0 # Logging package for easy logging and removal of print statements. 65 | subtitle: ^0.1.0-beta.3 # Used to decode the most used subtitle formats. 66 | wakelock_plus: ^1.1.0 # Links to platform dependencies and stops the display from going to sleep. Used for the player. 67 | crypto: ^3.0.2 # Direct dependency for crypto. 68 | flutter_sticky_header: ^0.7.0 69 | dpad_container: ^2.0.3 70 | pointycastle: ^3.9.1 71 | jovial_misc_pc: ^0.9.1 72 | 73 | 74 | dev_dependencies: 75 | flutter_test: 76 | sdk: flutter 77 | 78 | # The "flutter_lints" package below contains a set of recommended lints to 79 | # encourage good coding practices. The lint set provided by the package is 80 | # activated in the `analysis_options.yaml` file located at the root of your 81 | # package. See that file for information about deactivating specific lint 82 | # rules and activating additional ones. 83 | flutter_lints: ^5.0.0 84 | build_runner: ^2.3.3 85 | 86 | 87 | # For information on the generic Dart part of this file, see the 88 | # following page: https://dart.dev/tools/pub/pubspec 89 | 90 | # The following section is specific to Flutter packages. 91 | flutter: 92 | 93 | # The following line ensures that the Material Icons font is 94 | # included with your application, so that you can use the icons in 95 | # the material Icons class. 96 | uses-material-design: true 97 | 98 | assets: 99 | - images/ep-no-thumb.jpg 100 | 101 | # fonts: 102 | # - family: Bebas Neue 103 | # fonts: 104 | # - asset: assets/BebasNeue-Regular.ttf 105 | # weight: 400 106 | -------------------------------------------------------------------------------- /test/aniflix_test.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter_test/flutter_test.dart'; 2 | import 'package:viddroid/provider/providers/aniflix.dart'; 3 | import 'package:viddroid/util/capsules/fetch.dart'; 4 | import 'package:viddroid/util/capsules/link.dart'; 5 | import 'package:viddroid/util/capsules/search.dart'; 6 | 7 | Future main() async { 8 | final List searchResults = await Aniflix().search('Overlord'); 9 | final SearchResponse searchResponse = searchResults[0]; 10 | 11 | test('Search Result test', () async { 12 | expect(searchResults.isNotEmpty, true); 13 | }); 14 | 15 | // Fetch from service 16 | final FetchResponse fetchResponse = await Aniflix().fetch(searchResponse); 17 | 18 | test('Fetch details test', () async { 19 | expect(fetchResponse is TvFetchResponse, true); 20 | expect((fetchResponse as TvFetchResponse).episodes.isNotEmpty, true); 21 | }); 22 | 23 | test('Fetch episode test', () async { 24 | final List responses = await Aniflix() 25 | .load((fetchResponse as TvFetchResponse).episodes[0].toLoadRequest()) 26 | .toList(); 27 | 28 | expect(responses.isNotEmpty, true); 29 | }); 30 | } 31 | -------------------------------------------------------------------------------- /test/anime_pahe_test.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter_test/flutter_test.dart'; 2 | import 'package:viddroid/provider/providers/anime_pahe.dart'; 3 | import 'package:viddroid/util/capsules/fetch.dart'; 4 | import 'package:viddroid/util/capsules/link.dart'; 5 | import 'package:viddroid/util/capsules/search.dart'; 6 | 7 | Future main() async { 8 | final List searchResults = await AnimePahe().search('Darling in the franxx'); 9 | final SearchResponse searchResponse = searchResults[0]; 10 | 11 | test('Search Result test', () async { 12 | expect(searchResults.isNotEmpty, true); 13 | }); 14 | 15 | // Fetch from service 16 | final FetchResponse fetchResponse = await AnimePahe().fetch(searchResponse); 17 | 18 | test('Fetch details test', () async { 19 | expect(fetchResponse is TvFetchResponse, true); 20 | expect((fetchResponse as TvFetchResponse).episodes.isNotEmpty, true); 21 | }); 22 | 23 | test('Fetch episode test', () async { 24 | final List responses = await AnimePahe() 25 | .load((fetchResponse as TvFetchResponse).episodes[0].toLoadRequest()) 26 | .toList(); 27 | 28 | expect(responses.isNotEmpty, true); 29 | }); 30 | } 31 | -------------------------------------------------------------------------------- /test/sflix_test.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter_test/flutter_test.dart'; 2 | import 'package:viddroid/provider/providers/sflix.dart'; 3 | import 'package:viddroid/util/capsules/fetch.dart'; 4 | import 'package:viddroid/util/capsules/link.dart'; 5 | import 'package:viddroid/util/capsules/search.dart'; 6 | 7 | Future main() async { 8 | final List searchResults = await Sflix().search('American Dad'); 9 | final SearchResponse searchResponse = searchResults[0]; 10 | 11 | test('Search Result test', () async { 12 | expect(searchResults.isNotEmpty, true); 13 | }); 14 | 15 | // Fetch from service 16 | final FetchResponse fetchResponse = await Sflix().fetch(searchResponse); 17 | 18 | test('Fetch details tv test', () async { 19 | expect(fetchResponse is TvFetchResponse, true); 20 | expect((fetchResponse as TvFetchResponse).episodes.isNotEmpty, true); 21 | }); 22 | 23 | test('Fetch episode tv test', () async { 24 | final List responses = 25 | await Sflix().load((fetchResponse as TvFetchResponse).episodes[0].toLoadRequest()).toList(); 26 | 27 | expect(responses.isNotEmpty, true); 28 | }); 29 | } 30 | -------------------------------------------------------------------------------- /test/widget_test.dart: -------------------------------------------------------------------------------- 1 | // This is a basic Flutter widget test. 2 | // 3 | // To perform an interaction with a widget in your test, use the WidgetTester 4 | // utility in the flutter_test package. For example, you can send tap and scroll 5 | // gestures. You can also use WidgetTester to find child widgets in the widget 6 | // tree, read text, and verify that the values of widget properties are correct. 7 | 8 | import 'package:flutter/material.dart'; 9 | import 'package:flutter_test/flutter_test.dart'; 10 | 11 | import 'package:viddroid/main.dart'; 12 | 13 | void main() { 14 | testWidgets('Counter increments smoke test', (WidgetTester tester) async { 15 | // Build our app and trigger a frame. 16 | await tester.pumpWidget(const MyApp()); 17 | 18 | // Verify that our counter starts at 0. 19 | expect(find.text('0'), findsOneWidget); 20 | expect(find.text('1'), findsNothing); 21 | 22 | // Tap the '+' icon and trigger a frame. 23 | await tester.tap(find.byIcon(Icons.add)); 24 | await tester.pump(); 25 | 26 | // Verify that our counter has incremented. 27 | expect(find.text('0'), findsNothing); 28 | expect(find.text('1'), findsOneWidget); 29 | }); 30 | } 31 | -------------------------------------------------------------------------------- /windows/.gitignore: -------------------------------------------------------------------------------- 1 | flutter/ephemeral/ 2 | 3 | # Visual Studio user-specific files. 4 | *.suo 5 | *.user 6 | *.userosscache 7 | *.sln.docstates 8 | 9 | # Visual Studio build-related files. 10 | x64/ 11 | x86/ 12 | 13 | # Visual Studio cache files 14 | # files ending in .cache can be ignored 15 | *.[Cc]ache 16 | # but keep track of directories ending in .cache 17 | !*.[Cc]ache/ 18 | -------------------------------------------------------------------------------- /windows/CMakeLists.txt: -------------------------------------------------------------------------------- 1 | # Project-level configuration. 2 | cmake_minimum_required(VERSION 3.14) 3 | project(viddroid_flutter_desktop LANGUAGES CXX) 4 | 5 | # The name of the executable created for the application. Change this to change 6 | # the on-disk name of your application. 7 | set(BINARY_NAME "viddroid_flutter_desktop") 8 | 9 | # Explicitly opt in to modern CMake behaviors to avoid warnings with recent 10 | # versions of CMake. 11 | cmake_policy(SET CMP0063 NEW) 12 | 13 | # Define build configuration option. 14 | get_property(IS_MULTICONFIG GLOBAL PROPERTY GENERATOR_IS_MULTI_CONFIG) 15 | if(IS_MULTICONFIG) 16 | set(CMAKE_CONFIGURATION_TYPES "Debug;Profile;Release" 17 | CACHE STRING "" FORCE) 18 | else() 19 | if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES) 20 | set(CMAKE_BUILD_TYPE "Debug" CACHE 21 | STRING "Flutter build mode" FORCE) 22 | set_property(CACHE CMAKE_BUILD_TYPE PROPERTY STRINGS 23 | "Debug" "Profile" "Release") 24 | endif() 25 | endif() 26 | # Define settings for the Profile build mode. 27 | set(CMAKE_EXE_LINKER_FLAGS_PROFILE "${CMAKE_EXE_LINKER_FLAGS_RELEASE}") 28 | set(CMAKE_SHARED_LINKER_FLAGS_PROFILE "${CMAKE_SHARED_LINKER_FLAGS_RELEASE}") 29 | set(CMAKE_C_FLAGS_PROFILE "${CMAKE_C_FLAGS_RELEASE}") 30 | set(CMAKE_CXX_FLAGS_PROFILE "${CMAKE_CXX_FLAGS_RELEASE}") 31 | 32 | # Use Unicode for all projects. 33 | add_definitions(-DUNICODE -D_UNICODE) 34 | 35 | # Compilation settings that should be applied to most targets. 36 | # 37 | # Be cautious about adding new options here, as plugins use this function by 38 | # default. In most cases, you should add new options to specific targets instead 39 | # of modifying this function. 40 | function(APPLY_STANDARD_SETTINGS TARGET) 41 | target_compile_features(${TARGET} PUBLIC cxx_std_17) 42 | target_compile_options(${TARGET} PRIVATE /W4 /WX /wd"4100") 43 | target_compile_options(${TARGET} PRIVATE /EHsc) 44 | target_compile_definitions(${TARGET} PRIVATE "_HAS_EXCEPTIONS=0") 45 | target_compile_definitions(${TARGET} PRIVATE "$<$:_DEBUG>") 46 | endfunction() 47 | 48 | # Flutter library and tool build rules. 49 | set(FLUTTER_MANAGED_DIR "${CMAKE_CURRENT_SOURCE_DIR}/flutter") 50 | add_subdirectory(${FLUTTER_MANAGED_DIR}) 51 | 52 | # Application build; see runner/CMakeLists.txt. 53 | add_subdirectory("runner") 54 | 55 | # Generated plugin build rules, which manage building the plugins and adding 56 | # them to the application. 57 | include(flutter/generated_plugins.cmake) 58 | 59 | 60 | # === Installation === 61 | # Support files are copied into place next to the executable, so that it can 62 | # run in place. This is done instead of making a separate bundle (as on Linux) 63 | # so that building and running from within Visual Studio will work. 64 | set(BUILD_BUNDLE_DIR "$") 65 | # Make the "install" step default, as it's required to run. 66 | set(CMAKE_VS_INCLUDE_INSTALL_TO_DEFAULT_BUILD 1) 67 | if(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT) 68 | set(CMAKE_INSTALL_PREFIX "${BUILD_BUNDLE_DIR}" CACHE PATH "..." FORCE) 69 | endif() 70 | 71 | set(INSTALL_BUNDLE_DATA_DIR "${CMAKE_INSTALL_PREFIX}/data") 72 | set(INSTALL_BUNDLE_LIB_DIR "${CMAKE_INSTALL_PREFIX}") 73 | 74 | install(TARGETS ${BINARY_NAME} RUNTIME DESTINATION "${CMAKE_INSTALL_PREFIX}" 75 | COMPONENT Runtime) 76 | 77 | install(FILES "${FLUTTER_ICU_DATA_FILE}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" 78 | COMPONENT Runtime) 79 | 80 | install(FILES "${FLUTTER_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" 81 | COMPONENT Runtime) 82 | 83 | if(PLUGIN_BUNDLED_LIBRARIES) 84 | install(FILES "${PLUGIN_BUNDLED_LIBRARIES}" 85 | DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" 86 | COMPONENT Runtime) 87 | endif() 88 | 89 | # Fully re-copy the assets directory on each build to avoid having stale files 90 | # from a previous install. 91 | set(FLUTTER_ASSET_DIR_NAME "flutter_assets") 92 | install(CODE " 93 | file(REMOVE_RECURSE \"${INSTALL_BUNDLE_DATA_DIR}/${FLUTTER_ASSET_DIR_NAME}\") 94 | " COMPONENT Runtime) 95 | install(DIRECTORY "${PROJECT_BUILD_DIR}/${FLUTTER_ASSET_DIR_NAME}" 96 | DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" COMPONENT Runtime) 97 | 98 | # Install the AOT library on non-Debug builds only. 99 | install(FILES "${AOT_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" 100 | CONFIGURATIONS Profile;Release 101 | COMPONENT Runtime) 102 | -------------------------------------------------------------------------------- /windows/flutter/CMakeLists.txt: -------------------------------------------------------------------------------- 1 | # This file controls Flutter-level build steps. It should not be edited. 2 | cmake_minimum_required(VERSION 3.14) 3 | 4 | set(EPHEMERAL_DIR "${CMAKE_CURRENT_SOURCE_DIR}/ephemeral") 5 | 6 | # Configuration provided via flutter tool. 7 | include(${EPHEMERAL_DIR}/generated_config.cmake) 8 | 9 | # TODO: Move the rest of this into files in ephemeral. See 10 | # https://github.com/flutter/flutter/issues/57146. 11 | set(WRAPPER_ROOT "${EPHEMERAL_DIR}/cpp_client_wrapper") 12 | 13 | # Set fallback configurations for older versions of the flutter tool. 14 | if (NOT DEFINED FLUTTER_TARGET_PLATFORM) 15 | set(FLUTTER_TARGET_PLATFORM "windows-x64") 16 | endif() 17 | 18 | # === Flutter Library === 19 | set(FLUTTER_LIBRARY "${EPHEMERAL_DIR}/flutter_windows.dll") 20 | 21 | # Published to parent scope for install step. 22 | set(FLUTTER_LIBRARY ${FLUTTER_LIBRARY} PARENT_SCOPE) 23 | set(FLUTTER_ICU_DATA_FILE "${EPHEMERAL_DIR}/icudtl.dat" PARENT_SCOPE) 24 | set(PROJECT_BUILD_DIR "${PROJECT_DIR}/build/" PARENT_SCOPE) 25 | set(AOT_LIBRARY "${PROJECT_DIR}/build/windows/app.so" PARENT_SCOPE) 26 | 27 | list(APPEND FLUTTER_LIBRARY_HEADERS 28 | "flutter_export.h" 29 | "flutter_windows.h" 30 | "flutter_messenger.h" 31 | "flutter_plugin_registrar.h" 32 | "flutter_texture_registrar.h" 33 | ) 34 | list(TRANSFORM FLUTTER_LIBRARY_HEADERS PREPEND "${EPHEMERAL_DIR}/") 35 | add_library(flutter INTERFACE) 36 | target_include_directories(flutter INTERFACE 37 | "${EPHEMERAL_DIR}" 38 | ) 39 | target_link_libraries(flutter INTERFACE "${FLUTTER_LIBRARY}.lib") 40 | add_dependencies(flutter flutter_assemble) 41 | 42 | # === Wrapper === 43 | list(APPEND CPP_WRAPPER_SOURCES_CORE 44 | "core_implementations.cc" 45 | "standard_codec.cc" 46 | ) 47 | list(TRANSFORM CPP_WRAPPER_SOURCES_CORE PREPEND "${WRAPPER_ROOT}/") 48 | list(APPEND CPP_WRAPPER_SOURCES_PLUGIN 49 | "plugin_registrar.cc" 50 | ) 51 | list(TRANSFORM CPP_WRAPPER_SOURCES_PLUGIN PREPEND "${WRAPPER_ROOT}/") 52 | list(APPEND CPP_WRAPPER_SOURCES_APP 53 | "flutter_engine.cc" 54 | "flutter_view_controller.cc" 55 | ) 56 | list(TRANSFORM CPP_WRAPPER_SOURCES_APP PREPEND "${WRAPPER_ROOT}/") 57 | 58 | # Wrapper sources needed for a plugin. 59 | add_library(flutter_wrapper_plugin STATIC 60 | ${CPP_WRAPPER_SOURCES_CORE} 61 | ${CPP_WRAPPER_SOURCES_PLUGIN} 62 | ) 63 | apply_standard_settings(flutter_wrapper_plugin) 64 | set_target_properties(flutter_wrapper_plugin PROPERTIES 65 | POSITION_INDEPENDENT_CODE ON) 66 | set_target_properties(flutter_wrapper_plugin PROPERTIES 67 | CXX_VISIBILITY_PRESET hidden) 68 | target_link_libraries(flutter_wrapper_plugin PUBLIC flutter) 69 | target_include_directories(flutter_wrapper_plugin PUBLIC 70 | "${WRAPPER_ROOT}/include" 71 | ) 72 | add_dependencies(flutter_wrapper_plugin flutter_assemble) 73 | 74 | # Wrapper sources needed for the runner. 75 | add_library(flutter_wrapper_app STATIC 76 | ${CPP_WRAPPER_SOURCES_CORE} 77 | ${CPP_WRAPPER_SOURCES_APP} 78 | ) 79 | apply_standard_settings(flutter_wrapper_app) 80 | target_link_libraries(flutter_wrapper_app PUBLIC flutter) 81 | target_include_directories(flutter_wrapper_app PUBLIC 82 | "${WRAPPER_ROOT}/include" 83 | ) 84 | add_dependencies(flutter_wrapper_app flutter_assemble) 85 | 86 | # === Flutter tool backend === 87 | # _phony_ is a non-existent file to force this command to run every time, 88 | # since currently there's no way to get a full input/output list from the 89 | # flutter tool. 90 | set(PHONY_OUTPUT "${CMAKE_CURRENT_BINARY_DIR}/_phony_") 91 | set_source_files_properties("${PHONY_OUTPUT}" PROPERTIES SYMBOLIC TRUE) 92 | add_custom_command( 93 | OUTPUT ${FLUTTER_LIBRARY} ${FLUTTER_LIBRARY_HEADERS} 94 | ${CPP_WRAPPER_SOURCES_CORE} ${CPP_WRAPPER_SOURCES_PLUGIN} 95 | ${CPP_WRAPPER_SOURCES_APP} 96 | ${PHONY_OUTPUT} 97 | COMMAND ${CMAKE_COMMAND} -E env 98 | ${FLUTTER_TOOL_ENVIRONMENT} 99 | "${FLUTTER_ROOT}/packages/flutter_tools/bin/tool_backend.bat" 100 | ${FLUTTER_TARGET_PLATFORM} $ 101 | VERBATIM 102 | ) 103 | add_custom_target(flutter_assemble DEPENDS 104 | "${FLUTTER_LIBRARY}" 105 | ${FLUTTER_LIBRARY_HEADERS} 106 | ${CPP_WRAPPER_SOURCES_CORE} 107 | ${CPP_WRAPPER_SOURCES_PLUGIN} 108 | ${CPP_WRAPPER_SOURCES_APP} 109 | ) 110 | -------------------------------------------------------------------------------- /windows/flutter/generated_plugin_registrant.cc: -------------------------------------------------------------------------------- 1 | // 2 | // Generated file. Do not edit. 3 | // 4 | 5 | // clang-format off 6 | 7 | #include "generated_plugin_registrant.h" 8 | 9 | #include 10 | #include 11 | #include 12 | #include 13 | #include 14 | #include 15 | 16 | void RegisterPlugins(flutter::PluginRegistry* registry) { 17 | LocalNotifierPluginRegisterWithRegistrar( 18 | registry->GetRegistrarForPlugin("LocalNotifierPlugin")); 19 | MediaKitLibsWindowsVideoPluginCApiRegisterWithRegistrar( 20 | registry->GetRegistrarForPlugin("MediaKitLibsWindowsVideoPluginCApi")); 21 | MediaKitVideoPluginCApiRegisterWithRegistrar( 22 | registry->GetRegistrarForPlugin("MediaKitVideoPluginCApi")); 23 | ScreenBrightnessWindowsPluginRegisterWithRegistrar( 24 | registry->GetRegistrarForPlugin("ScreenBrightnessWindowsPlugin")); 25 | ScreenRetrieverWindowsPluginCApiRegisterWithRegistrar( 26 | registry->GetRegistrarForPlugin("ScreenRetrieverWindowsPluginCApi")); 27 | WindowManagerPluginRegisterWithRegistrar( 28 | registry->GetRegistrarForPlugin("WindowManagerPlugin")); 29 | } 30 | -------------------------------------------------------------------------------- /windows/flutter/generated_plugin_registrant.h: -------------------------------------------------------------------------------- 1 | // 2 | // Generated file. Do not edit. 3 | // 4 | 5 | // clang-format off 6 | 7 | #ifndef GENERATED_PLUGIN_REGISTRANT_ 8 | #define GENERATED_PLUGIN_REGISTRANT_ 9 | 10 | #include 11 | 12 | // Registers Flutter plugins. 13 | void RegisterPlugins(flutter::PluginRegistry* registry); 14 | 15 | #endif // GENERATED_PLUGIN_REGISTRANT_ 16 | -------------------------------------------------------------------------------- /windows/flutter/generated_plugins.cmake: -------------------------------------------------------------------------------- 1 | # 2 | # Generated file, do not edit. 3 | # 4 | 5 | list(APPEND FLUTTER_PLUGIN_LIST 6 | local_notifier 7 | media_kit_libs_windows_video 8 | media_kit_video 9 | screen_brightness_windows 10 | screen_retriever_windows 11 | window_manager 12 | ) 13 | 14 | list(APPEND FLUTTER_FFI_PLUGIN_LIST 15 | media_kit_native_event_loop 16 | ) 17 | 18 | set(PLUGIN_BUNDLED_LIBRARIES) 19 | 20 | foreach(plugin ${FLUTTER_PLUGIN_LIST}) 21 | add_subdirectory(flutter/ephemeral/.plugin_symlinks/${plugin}/windows plugins/${plugin}) 22 | target_link_libraries(${BINARY_NAME} PRIVATE ${plugin}_plugin) 23 | list(APPEND PLUGIN_BUNDLED_LIBRARIES $) 24 | list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${plugin}_bundled_libraries}) 25 | endforeach(plugin) 26 | 27 | foreach(ffi_plugin ${FLUTTER_FFI_PLUGIN_LIST}) 28 | add_subdirectory(flutter/ephemeral/.plugin_symlinks/${ffi_plugin}/windows plugins/${ffi_plugin}) 29 | list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${ffi_plugin}_bundled_libraries}) 30 | endforeach(ffi_plugin) 31 | --------------------------------------------------------------------------------