Project import generated by Copybara.

GitOrigin-RevId: f4c147c7549de641b05ccec9b800842278b45b0f
This commit is contained in:
Madari Developers 2025-01-04 14:30:05 +00:00
parent 420b4b1c12
commit 2cba15132b
14 changed files with 486 additions and 110 deletions

View file

@ -5,6 +5,13 @@
<uses-permission android:name="android.permission.READ_MEDIA_AUDIO"/>
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
<queries>
<package android:name="org.videolan.vlc" />
<package android:name="com.mxtech.videoplayer.ad" />
<package android:name="com.mxtech.videoplayer.pro" />
<package android:name="com.brouken.player" />
</queries>
<application
android:label="madari"
android:icon="@mipmap/launcher_icon"
@ -27,6 +34,8 @@
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
<category android:name="android.intent.category.HOME"/>
<category android:name="android.intent.category.DEFAULT"/>
</intent-filter>
<intent-filter>

View file

@ -2,8 +2,10 @@
<resources>
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
<item name="android:windowBackground">@drawable/launch_background</item>
<item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>
</style>
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
<item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>
</style>
</resources>

View file

@ -2,8 +2,10 @@
<resources>
<style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
<item name="android:windowBackground">@drawable/launch_background</item>
<item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>
</style>
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
<item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>
</style>
</resources>

View file

@ -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;

View file

@ -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<RenderStreamList> {
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(

View file

@ -274,10 +274,16 @@ class _StremioItemViewerState extends State<StremioItemViewer> {
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(

View file

@ -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<StremioItemSeasonSelector>
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<StremioItemSeasonSelector>
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<StremioItemSeasonSelector>
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,

View file

@ -26,7 +26,9 @@ class _CreateConnectionStepState extends State<CreateConnectionStep> {
final PocketBase pb = AppEngine.engine.pb;
final _formKey = GlobalKey<FormState>();
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<CreateConnectionStep> {
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<CreateConnectionStep> {
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<CreateConnectionStep> {
'icon': manifest['logo'] ?? manifest['icon'],
'url': url,
'addons': manifest,
'manifestParsed': _manifest,
'types': supportedTypes,
});
_urlController.clear();
@ -117,9 +125,102 @@ class _CreateConnectionStepState extends State<CreateConnectionStep> {
}
}
Future<bool> 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: <Widget>[
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<void> _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<CreateConnectionStep> {
});
}
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<CreateConnectionStep> {
},
),
),
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<CreateConnectionStep> {
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<CreateConnectionStep> {
);
return Card(
key: Key('$index'),
margin: EdgeInsets.only(
bottom: index + 1 != _addons.length ? 10 : 0,
),
@ -363,26 +482,27 @@ class _CreateConnectionStepState extends State<CreateConnectionStep> {
},
),
),
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,
),
),
),
),
],
),
],

View file

@ -47,36 +47,13 @@ class _StremioStreamSelectorState extends State<StremioStreamSelector> {
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<StremioStreamSelector> {
@override
Widget build(BuildContext context) {
print("Neo");
return StreamBuilder(
stream: _stream,
builder: (context, snapshot) {

View file

@ -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<PlaybackSettingsScreen> {
double _playbackSpeed = 1.0;
String _defaultAudioTrack = 'eng';
String _defaultSubtitleTrack = 'eng';
bool _enableExternalPlayer = true;
String? _defaultPlayerId;
Map<String, String> _availableLanguages = {};
@ -51,9 +54,14 @@ class _PlaybackSettingsScreenState extends State<PlaybackSettingsScreen> {
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<PlaybackSettingsScreen> {
final currentConfig = user.data['config'] as Map<String, dynamic>? ?? {};
final extranalId = currentConfig['externalPlayerId'] ?? {};
extranalId[currentPlatform] = _defaultPlayerId;
final updatedConfig = {
...currentConfig,
'playback': {
@ -84,12 +96,16 @@ class _PlaybackSettingsScreenState extends State<PlaybackSettingsScreen> {
'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<PlaybackSettingsScreen> {
}
}
final currentPlatform = getPlatformInString();
@override
Widget build(BuildContext context) {
if (_error != null) {
@ -128,6 +146,8 @@ class _PlaybackSettingsScreenState extends State<PlaybackSettingsScreen> {
);
}
print(_defaultPlayerId);
return Scaffold(
appBar: AppBar(
title: const Text('Playback Settings'),
@ -190,6 +210,35 @@ class _PlaybackSettingsScreenState extends State<PlaybackSettingsScreen> {
},
),
),
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<String>(
value: _defaultPlayerId,
items: externalPlayers[currentPlatform]!
.map(
(item) => item.toDropdownMenuItem(),
)
.toList(),
onChanged: (value) {
if (value != null) {
setState(() => _defaultPlayerId = value);
_debouncedSave();
}
},
),
),
],
),
);

View file

@ -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<String> toDropdownMenuItem() {
return DropdownMenuItem<String>(
value: id,
child: Text(name),
);
}
}
final Map<String, List<ExternalMediaPlayer>> 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<void> openVideoUrlInExternalPlayerAndroid({
required String videoUrl,
String? playerPackage,
}) async {
AndroidIntent intent = AndroidIntent(
action: 'action_view',
type: "video/*",
package: playerPackage,
data: videoUrl,
flags: const <int>[268435456],
arguments: {},
);
await intent.launch();
}

View file

@ -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<Map<String, String>> 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<String, dynamic>? ?? {};
final playbackConfig = config['playback'] as Map<String, dynamic>? ?? {};
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<String, String>? 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<String, dynamic> config) =>
_$PlaybackConfigFromJson(config);
}

View file

@ -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:

View file

@ -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: