diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index d25f143..50f1703 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -5,6 +5,13 @@ + + + + + + + + + diff --git a/android/app/src/main/res/values-night/styles.xml b/android/app/src/main/res/values-night/styles.xml index c6e7031..08c56f0 100644 --- a/android/app/src/main/res/values-night/styles.xml +++ b/android/app/src/main/res/values-night/styles.xml @@ -2,8 +2,10 @@ diff --git a/android/app/src/main/res/values/styles.xml b/android/app/src/main/res/values/styles.xml index ff81bae..2a55471 100644 --- a/android/app/src/main/res/values/styles.xml +++ b/android/app/src/main/res/values/styles.xml @@ -2,8 +2,10 @@ diff --git a/lib/features/connections/service/stremio_connection_service.dart b/lib/features/connections/service/stremio_connection_service.dart index d9a34b2..78c9e29 100644 --- a/lib/features/connections/service/stremio_connection_service.dart +++ b/lib/features/connections/service/stremio_connection_service.dart @@ -28,7 +28,19 @@ class StremioConnectionService extends BaseConnectionService { for (final addon in config.addons) { final manifest = await _getManifest(addon); - if (manifest.resources?.contains("meta") != true) { + if (manifest.resources == null) { + continue; + } + + bool isMeta = false; + for (final item in manifest.resources!) { + if (item.name == "meta") { + isMeta = true; + break; + } + } + + if (isMeta == false) { continue; } @@ -187,8 +199,9 @@ class StremioConnectionService extends BaseConnectionService { continue; } - final hasIdPrefix = - (idPrefixes ?? []).where((item) => meta.id.startsWith(item)); + final hasIdPrefix = (idPrefixes ?? []).where( + (item) => meta.id.startsWith(item), + ); if (hasIdPrefix.isEmpty) { continue; diff --git a/lib/features/connections/widget/base/render_stream_list.dart b/lib/features/connections/widget/base/render_stream_list.dart index 647ebf4..b18d3fc 100644 --- a/lib/features/connections/widget/base/render_stream_list.dart +++ b/lib/features/connections/widget/base/render_stream_list.dart @@ -1,11 +1,14 @@ import 'dart:async'; +import 'dart:io'; import 'package:background_downloader/background_downloader.dart'; import 'package:flutter/material.dart'; import 'package:madari_client/features/connection/types/stremio.dart'; import 'package:madari_client/features/connections/service/base_connection_service.dart'; import 'package:madari_client/features/doc_viewer/container/doc_viewer.dart'; +import 'package:madari_client/utils/external_player.dart'; +import '../../../../utils/load_language.dart'; import '../../../doc_viewer/types/doc_source.dart'; import '../../../downloads/service/service.dart'; @@ -226,6 +229,23 @@ class _RenderStreamListState extends State { return; } + PlaybackConfig config = getPlaybackConfig(); + + if (config.externalPlayer) { + if (!kIsWeb) { + if (item.source is URLSource || + item.source is TorrentSource) { + if (config.externalPlayer && Platform.isAndroid) { + openVideoUrlInExternalPlayerAndroid( + videoUrl: (item.source as URLSource).url, + playerPackage: config.currentPlayerPackage, + ); + return; + } + } + } + } + Navigator.of(context).push( MaterialPageRoute( builder: (ctx) => DocViewer( diff --git a/lib/features/connections/widget/stremio/stremio_item_viewer.dart b/lib/features/connections/widget/stremio/stremio_item_viewer.dart index a8f497e..7a36de3 100644 --- a/lib/features/connections/widget/stremio/stremio_item_viewer.dart +++ b/lib/features/connections/widget/stremio/stremio_item_viewer.dart @@ -274,10 +274,16 @@ class _StremioItemViewerState extends State { if (widget.original != null && widget.original?.type == "series" && widget.original?.videos?.isNotEmpty == true) - StremioItemSeasonSelector( - meta: item!, - library: widget.library, - service: widget.service, + SliverPadding( + padding: EdgeInsets.symmetric( + horizontal: isWideScreen ? (screenWidth - contentWidth) / 2 : 0, + vertical: 0, + ), + sliver: StremioItemSeasonSelector( + meta: item!, + library: widget.library, + service: widget.service, + ), ), SliverPadding( padding: EdgeInsets.symmetric( diff --git a/lib/features/connections/widget/stremio/stremio_season_selector.dart b/lib/features/connections/widget/stremio/stremio_season_selector.dart index 1c04b18..e693982 100644 --- a/lib/features/connections/widget/stremio/stremio_season_selector.dart +++ b/lib/features/connections/widget/stremio/stremio_season_selector.dart @@ -1,3 +1,5 @@ +import 'dart:math'; + import 'package:flutter/material.dart'; import 'package:intl/intl.dart' as intl; import 'package:madari_client/features/connection/types/stremio.dart'; @@ -89,6 +91,53 @@ class _StremioItemSeasonSelectorState extends State return groupBy(episodes, (Video video) => video.season); } + void openEpisode({ + required int currentSeason, + required Video episode, + }) async { + if (widget.service == null) { + return; + } + final onClose = showModalBottomSheet( + context: context, + builder: (context) { + final meta = widget.meta.copyWith( + id: episode.id, + ); + + return Scaffold( + appBar: AppBar( + title: Text("Streams for S$currentSeason E${episode.episode}"), + ), + body: RenderStreamList( + service: widget.service!, + library: widget.library, + id: meta, + season: currentSeason.toString(), + shouldPop: widget.shouldPop, + ), + ); + }, + ); + + if (widget.shouldPop) { + final val = await onClose; + + if (val is MediaURLSource && context.mounted) { + Navigator.pop( + context, + val, + ); + } + + return; + } + + onClose.then((data) { + getWatchHistory(); + }); + } + @override Widget build(BuildContext context) { final seasons = seasonMap.keys.toList()..sort(); @@ -123,6 +172,26 @@ class _StremioItemSeasonSelectorState extends State const SizedBox( height: 12, ), + Center( + child: Container( + constraints: const BoxConstraints(maxWidth: 320), + child: ElevatedButton.icon( + icon: const Icon(Icons.shuffle), + label: const Text("Random Episode"), + onPressed: () { + Random random = Random(); + int randomIndex = random.nextInt( + widget.meta.videos!.length, + ); + + openEpisode( + currentSeason: widget.meta.videos![randomIndex].season, + episode: widget.meta.videos![randomIndex], + ); + }, + ), + ), + ), Container( margin: const EdgeInsets.symmetric(horizontal: 16), decoration: BoxDecoration( @@ -162,46 +231,10 @@ class _StremioItemSeasonSelectorState extends State return InkWell( borderRadius: BorderRadius.circular(12), onTap: () async { - if (widget.service == null) { - return; - } - final onClose = showModalBottomSheet( - context: context, - builder: (context) { - final meta = widget.meta.copyWith( - id: episode.id, - ); - - return Scaffold( - appBar: AppBar( - title: const Text("Streams"), - ), - body: RenderStreamList( - service: widget.service!, - library: widget.library, - id: meta, - season: currentSeason.toString(), - shouldPop: widget.shouldPop, - ), - ); - }, + openEpisode( + currentSeason: currentSeason, + episode: episode, ); - - if (widget.shouldPop) { - final val = await onClose; - if (val is MediaURLSource && context.mounted) { - Navigator.pop( - context, - val, - ); - } - - return; - } - - onClose.then((data) { - getWatchHistory(); - }); }, child: Row( crossAxisAlignment: CrossAxisAlignment.start, diff --git a/lib/features/getting_started/container/create_connection.dart b/lib/features/getting_started/container/create_connection.dart index 805c83f..657a940 100644 --- a/lib/features/getting_started/container/create_connection.dart +++ b/lib/features/getting_started/container/create_connection.dart @@ -26,7 +26,9 @@ class _CreateConnectionStepState extends State { final PocketBase pb = AppEngine.engine.pb; final _formKey = GlobalKey(); final _urlController = TextEditingController(); - final _nameController = TextEditingController(text: "Stremio Addons"); + final _nameController = TextEditingController( + text: "Stremio Addons", + ); Connection? _existingConnection; bool _isLoading = false; @@ -43,9 +45,10 @@ class _CreateConnectionStepState extends State { loadExistingConnection() async { try { - final existingConnection = await pb - .collection("connection") - .getFirstListItem("type.type = 'stremio_addons'"); + final existingConnection = + await pb.collection("connection").getFirstListItem( + "type.type = 'stremio_addons'", + ); final connection = Connection.fromRecord(existingConnection); @@ -54,7 +57,11 @@ class _CreateConnectionStepState extends State { if (config['addons'] != null) { for (var url in config['addons']) { - _validateAddonUrl(url); + try { + await _validateAddonUrl(url); + } catch (e) { + print("Failed to load addon"); + } } } @@ -99,6 +106,7 @@ class _CreateConnectionStepState extends State { 'icon': manifest['logo'] ?? manifest['icon'], 'url': url, 'addons': manifest, + 'manifestParsed': _manifest, 'types': supportedTypes, }); _urlController.clear(); @@ -117,9 +125,102 @@ class _CreateConnectionStepState extends State { } } + Future showAddonWarningDialog( + BuildContext context, { + required bool isMeta, + required bool isAddon, + }) async { + bool continueAnyway = false; + + if (isMeta && isAddon) { + return true; + } + + await showDialog( + context: context, + builder: (BuildContext context) { + return AlertDialog( + title: const Text('Warning!'), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (!isMeta || !isAddon) + const Text( + 'You are missing the following addons:', + style: TextStyle(fontWeight: FontWeight.bold), + ), + const SizedBox( + height: 4, + ), + if (!isMeta) const Text('🔴 Meta Addon'), + if (!isAddon) const Text('🔴 Streaming Addon'), + const SizedBox(height: 10), + const Text( + 'Continuing without these addons may limit functionality. Are you sure you want to proceed?', + style: TextStyle(color: Colors.red), + ), + ], + ), + actions: [ + TextButton( + onPressed: () { + // User chooses to continue anyway + Navigator.of(context).pop(); + continueAnyway = true; + }, + child: const Text('CONTINUE ANYWAY'), + ), + ElevatedButton( + onPressed: () { + // User chooses to add addon + Navigator.of(context).pop(); + continueAnyway = false; + }, + child: const Text('ADD ADDON'), + ), + ], + ); + }, + ); + + return continueAnyway; + } + Future _saveConnection() async { if (!_formKey.currentState!.validate() || _addons.isEmpty) return; + bool hasMeta = false; + bool hasStream = false; + + for (final item in _addons) { + final manifest = item['manifestParsed'] as StremioManifest; + + if (manifest.resources == null) { + continue; + } + + for (final resource in manifest.resources!) { + if (resource.name == "meta") { + hasMeta = true; + } + + if (resource.name == "stream") { + hasStream = true; + } + } + } + + final result = await showAddonWarningDialog( + context, + isAddon: hasStream, + isMeta: hasMeta, + ); + + if (!result) { + return; + } + setState(() { _isLoading = true; _errorMessage = null; @@ -192,6 +293,16 @@ class _CreateConnectionStepState extends State { }); } + void _reorderAddon(int oldIndex, int newIndex) { + setState(() { + if (oldIndex < newIndex) { + newIndex -= 1; + } + final item = _addons.removeAt(oldIndex); + _addons.insert(newIndex, item); + }); + } + @override Widget build(BuildContext context) { return Form( @@ -268,7 +379,13 @@ class _CreateConnectionStepState extends State { }, ), ), - if (_isLoading) const Center(child: CircularProgressIndicator()), + if (_isLoading) + const Center( + child: Padding( + padding: EdgeInsets.only(top: 12), + child: CircularProgressIndicator(), + ), + ), if (_errorMessage != null) Padding( padding: const EdgeInsets.only(top: 8.0), @@ -289,10 +406,11 @@ class _CreateConnectionStepState extends State { const SizedBox(height: 10), Flexible( fit: FlexFit.loose, - child: ListView.builder( + child: ReorderableListView.builder( shrinkWrap: true, physics: const NeverScrollableScrollPhysics(), itemCount: _addons.length, + onReorder: _reorderAddon, itemBuilder: (context, index) { final addon = _addons[index]; final name = utf8.decode( @@ -300,6 +418,7 @@ class _CreateConnectionStepState extends State { ); return Card( + key: Key('$index'), margin: EdgeInsets.only( bottom: index + 1 != _addons.length ? 10 : 0, ), @@ -363,26 +482,27 @@ class _CreateConnectionStepState extends State { }, ), ), - Padding( - padding: const EdgeInsets.only( - bottom: 10.0, - ), - child: ElevatedButton( - onPressed: _addons.isEmpty ? null : _saveConnection, - style: ElevatedButton.styleFrom( - backgroundColor: Colors.white70, - foregroundColor: Colors.black, - ), - child: Text( - 'Next', - style: GoogleFonts.exo2().copyWith( - fontWeight: FontWeight.w600, - fontSize: 16, - ), - ), - ), - ) ], + Padding( + padding: const EdgeInsets.only( + bottom: 12.0, + top: 12.0, + ), + child: ElevatedButton( + onPressed: _addons.isEmpty ? null : _saveConnection, + style: ElevatedButton.styleFrom( + backgroundColor: Colors.white70, + foregroundColor: Colors.black, + ), + child: Text( + 'Next', + style: GoogleFonts.exo2().copyWith( + fontWeight: FontWeight.w600, + fontSize: 16, + ), + ), + ), + ), ], ), ], diff --git a/lib/features/library_item/container/stremio_stream_selector.dart b/lib/features/library_item/container/stremio_stream_selector.dart index 388488a..9627485 100644 --- a/lib/features/library_item/container/stremio_stream_selector.dart +++ b/lib/features/library_item/container/stremio_stream_selector.dart @@ -47,36 +47,13 @@ class _StremioStreamSelectorState extends State { if (!kIsWeb) _downloadService = DownloadService.instance; - _stream = widget.stremio - .getStreams( + _stream = widget.stremio.getStreams( widget.item.type, widget.id, episode: widget.episode, season: widget.season, - ) - .map((item) { - return [ - if (widget.item.type == "movie") - for (final item in (widget.stremio.configParsed.movieIframe ?? [])) - VideoStream( - title: widget.item.name, - behaviorHints: { - "filename": widget.item.name, - "iframe": item, - }, - ), - if (widget.item.type == "series") - for (final item in (widget.stremio.configParsed.seriesIframe ?? [])) - VideoStream( - title: widget.item.name, - behaviorHints: { - "filename": widget.item.name, - "iframe": item, - }, - ), - ...item, - ]; - }); + ); + _setupDownloadListener(); _checkExistingDownloads(); } @@ -223,8 +200,6 @@ class _StremioStreamSelectorState extends State { @override Widget build(BuildContext context) { - print("Neo"); - return StreamBuilder( stream: _stream, builder: (context, snapshot) { diff --git a/lib/features/settings/screen/playback_settings_screen.dart b/lib/features/settings/screen/playback_settings_screen.dart index b2c5d72..2d4f43f 100644 --- a/lib/features/settings/screen/playback_settings_screen.dart +++ b/lib/features/settings/screen/playback_settings_screen.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'package:flutter/material.dart'; +import 'package:madari_client/utils/external_player.dart'; import 'package:pocketbase/pocketbase.dart'; import '../../../engine/engine.dart'; @@ -22,6 +23,8 @@ class _PlaybackSettingsScreenState extends State { double _playbackSpeed = 1.0; String _defaultAudioTrack = 'eng'; String _defaultSubtitleTrack = 'eng'; + bool _enableExternalPlayer = true; + String? _defaultPlayerId; Map _availableLanguages = {}; @@ -51,9 +54,14 @@ class _PlaybackSettingsScreenState extends State { final playbackConfig = getPlaybackConfig(); _autoPlay = playbackConfig.autoPlay ?? true; - _playbackSpeed = playbackConfig.playbackSpeed.toDouble() ?? 1.0; - _defaultAudioTrack = playbackConfig.defaultAudioTrack ?? 'eng'; - _defaultSubtitleTrack = playbackConfig.defaultSubtitleTrack ?? 'eng'; + _playbackSpeed = playbackConfig.playbackSpeed.toDouble(); + _defaultAudioTrack = playbackConfig.defaultAudioTrack; + _defaultSubtitleTrack = playbackConfig.defaultSubtitleTrack; + _enableExternalPlayer = playbackConfig.externalPlayer; + _defaultPlayerId = + playbackConfig.externalPlayerId?.containsKey(currentPlatform) == true + ? playbackConfig.externalPlayerId![currentPlatform] + : null; } @override @@ -77,6 +85,10 @@ class _PlaybackSettingsScreenState extends State { final currentConfig = user.data['config'] as Map? ?? {}; + final extranalId = currentConfig['externalPlayerId'] ?? {}; + + extranalId[currentPlatform] = _defaultPlayerId; + final updatedConfig = { ...currentConfig, 'playback': { @@ -84,12 +96,16 @@ class _PlaybackSettingsScreenState extends State { 'playbackSpeed': _playbackSpeed, 'defaultAudioTrack': _defaultAudioTrack, 'defaultSubtitleTrack': _defaultSubtitleTrack, + 'externalPlayer': _enableExternalPlayer, + 'externalPlayerId': extranalId, }, }; await _engine.collection('users').update( user.id, - body: {'config': updatedConfig}, + body: { + 'config': updatedConfig, + }, ); if (mounted) { @@ -109,6 +125,8 @@ class _PlaybackSettingsScreenState extends State { } } + final currentPlatform = getPlatformInString(); + @override Widget build(BuildContext context) { if (_error != null) { @@ -128,6 +146,8 @@ class _PlaybackSettingsScreenState extends State { ); } + print(_defaultPlayerId); + return Scaffold( appBar: AppBar( title: const Text('Playback Settings'), @@ -190,6 +210,35 @@ class _PlaybackSettingsScreenState extends State { }, ), ), + if (!isWeb) + SwitchListTile( + title: const Text('External Player'), + subtitle: const Text('Always open video in external player?'), + value: _enableExternalPlayer, + onChanged: (value) { + setState(() => _enableExternalPlayer = value); + _debouncedSave(); + }, + ), + if (_enableExternalPlayer && + externalPlayers[currentPlatform]?.isNotEmpty == true) + ListTile( + title: const Text('Default Player'), + trailing: DropdownButton( + value: _defaultPlayerId, + items: externalPlayers[currentPlatform]! + .map( + (item) => item.toDropdownMenuItem(), + ) + .toList(), + onChanged: (value) { + if (value != null) { + setState(() => _defaultPlayerId = value); + _debouncedSave(); + } + }, + ), + ), ], ), ); diff --git a/lib/utils/external_player.dart b/lib/utils/external_player.dart new file mode 100644 index 0000000..44091c2 --- /dev/null +++ b/lib/utils/external_player.dart @@ -0,0 +1,116 @@ +import 'dart:io'; + +import 'package:android_intent_plus/android_intent.dart'; +import 'package:flutter/material.dart'; +import 'package:pocketbase/pocketbase.dart'; + +class ExternalMediaPlayer { + final String name; + final String id; + + ExternalMediaPlayer({ + required this.id, + required this.name, + }); + + DropdownMenuItem toDropdownMenuItem() { + return DropdownMenuItem( + value: id, + child: Text(name), + ); + } +} + +final Map> externalPlayers = { + "android": [ + ExternalMediaPlayer(id: "", name: "App chooser"), + ExternalMediaPlayer(id: "org.videolan.vlc", name: "VLC"), + ExternalMediaPlayer(id: "com.mxtech.videoplayer.ad", name: "MX Player"), + ExternalMediaPlayer( + id: "com.mxtech.videoplayer.pro", + name: "MX Player Pro", + ), + ExternalMediaPlayer( + id: "com.brouken.player", + name: "JustPlayer", + ), + ], + "ios": [ + ExternalMediaPlayer( + id: "open-vidhub", + name: "VidHub", + ), + ExternalMediaPlayer( + id: "infuse", + name: "Infuse", + ), + ExternalMediaPlayer( + id: "vlc", + name: "VLC", + ), + ExternalMediaPlayer( + id: "outplayer", + name: "Outplayer", + ), + ], + "macos": [ + ExternalMediaPlayer( + id: "open-vidhub", + name: "VidHub", + ), + ExternalMediaPlayer( + id: "infuse", + name: "Infuse", + ), + ExternalMediaPlayer( + id: "iina", + name: "IINA", + ), + ExternalMediaPlayer( + id: "omniplayer", + name: "OmniPlayer", + ), + ExternalMediaPlayer( + id: "nplayer-mac", + name: "nPlayer", + ), + ] +}; + +String getPlatformInString() { + if (isWeb) { + return "web"; + } + if (Platform.isAndroid) { + return "android"; + } + if (Platform.isIOS) { + return "ios"; + } + if (Platform.isMacOS) { + return "macos"; + } + if (Platform.isWindows) { + return "windows"; + } + if (Platform.isLinux) { + return "linux"; + } + + return "unknown"; +} + +Future openVideoUrlInExternalPlayerAndroid({ + required String videoUrl, + String? playerPackage, +}) async { + AndroidIntent intent = AndroidIntent( + action: 'action_view', + type: "video/*", + package: playerPackage, + data: videoUrl, + flags: const [268435456], + arguments: {}, + ); + await intent.launch(); +} diff --git a/lib/utils/load_language.dart b/lib/utils/load_language.dart index a27096e..5c1fc55 100644 --- a/lib/utils/load_language.dart +++ b/lib/utils/load_language.dart @@ -1,9 +1,13 @@ import 'dart:convert'; import 'package:flutter/cupertino.dart'; +import 'package:json_annotation/json_annotation.dart'; +import 'package:madari_client/utils/external_player.dart'; import '../engine/engine.dart'; +part 'load_language.g.dart'; + Future> loadLanguages(BuildContext context) async { final data = await DefaultAssetBundle.of(context) .loadString("assets/data/languages.json"); @@ -29,24 +33,33 @@ PlaybackConfig getPlaybackConfig() { final config = user.data['config'] as Map? ?? {}; final playbackConfig = config['playback'] as Map? ?? {}; - return PlaybackConfig( - autoPlay: playbackConfig['autoPlay'] ?? true, - playbackSpeed: playbackConfig['playbackSpeed']?.toDouble() ?? 1, - defaultAudioTrack: playbackConfig['defaultAudioTrack'] ?? 'eng', - defaultSubtitleTrack: playbackConfig['defaultSubtitleTrack'] ?? 'eng', - ); + return PlaybackConfig.fromJson(playbackConfig); } +@JsonSerializable() class PlaybackConfig { final bool autoPlay; final double playbackSpeed; final String defaultAudioTrack; final String defaultSubtitleTrack; + final bool externalPlayer; + final Map? externalPlayerId; PlaybackConfig({ required this.autoPlay, required this.playbackSpeed, required this.defaultAudioTrack, required this.defaultSubtitleTrack, + required this.externalPlayer, + this.externalPlayerId, }); + + String? get currentPlayerPackage { + return externalPlayerId?.containsKey(getPlatformInString()) == true + ? externalPlayerId![getPlatformInString()] + : null; + } + + factory PlaybackConfig.fromJson(Map config) => + _$PlaybackConfigFromJson(config); } diff --git a/pubspec.lock b/pubspec.lock index f5851e7..bd4a61b 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -30,6 +30,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.11.3" + android_intent_plus: + dependency: "direct main" + description: + name: android_intent_plus + sha256: "53136214d506d3128c9f4e5bfce3d026abe7e8038958629811a8d3223b1757c1" + url: "https://pub.dev" + source: hosted + version: "5.2.1" archive: dependency: transitive description: @@ -430,6 +438,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.1.0" + external_app_launcher: + dependency: "direct main" + description: + name: external_app_launcher + sha256: "69d843ae16598cbf86be8d65ae5f206bb403fbfb75ca9aaaa9ea91b15b040571" + url: "https://pub.dev" + source: hosted + version: "4.0.1" fake_async: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 36527f3..4a0e713 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -57,6 +57,8 @@ dependencies: fetch_client: ^1.1.2 cast: ^2.1.0 permission_handler: ^11.3.1 + android_intent_plus: ^5.2.1 + external_app_launcher: ^4.0.1 dependency_overrides: media_kit: