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