fix: small issues

This commit is contained in:
omkar 2025-02-01 13:49:17 +05:30
parent 1910432364
commit 309be2be2c
5 changed files with 352 additions and 235 deletions

View file

@ -348,7 +348,20 @@ class Meta {
@JsonKey(name: "moviedb_id")
final int? moviedbId;
@JsonKey(name: "runtime")
final String? runtime;
final String? runtime_;
String? get runtime {
try {
if (runtime_ == null) {
return runtime_;
}
return formatTimeFromMinutes(runtime_!);
} catch (e) {
return runtime_;
}
}
@JsonKey(name: "trailers")
final List<Trailer>? trailers;
@JsonKey(name: "popularity")
@ -432,7 +445,7 @@ class Meta {
this.logo,
this.awards,
this.moviedbId,
this.runtime,
this.runtime_,
this.trailers,
this.popularity,
required this.id,
@ -520,7 +533,7 @@ class Meta {
logo: logo ?? this.logo,
awards: awards ?? this.awards,
moviedbId: moviedbId ?? this.moviedbId,
runtime: runtime ?? this.runtime,
runtime_: runtime ?? this.runtime,
trailers: trailers ?? this.trailers,
popularity: popularity ?? this.popularity,
id: id ?? this.id,
@ -898,6 +911,14 @@ class VideoStream {
_$VideoStreamFromJson(json);
Map<String, dynamic> toJson() => _$VideoStreamToJson(this);
@override
int get hashCode => "$url$name$title$description".length;
@override
bool operator ==(Object other) {
return super.hashCode == other.hashCode;
}
}
class StreamInfo {
@ -1002,8 +1023,10 @@ class StreamParser {
final unratedMatch = _unratedRegex.hasMatch(name);
final sizeMatch = _sizeRegex.firstMatch(name);
final res = resMatch?.group(1)?.toUpperCase();
return StreamInfo(
resolution: resMatch?.group(1)?.toUpperCase(),
resolution: res == "2160P" ? "4K" : res,
quality: qualMatch?.group(1)?.toUpperCase(),
codec: codecMatch?.group(1)?.toUpperCase(),
audio: audioMatch?.group(1)?.toUpperCase(),
@ -1014,3 +1037,28 @@ class StreamParser {
);
}
}
String formatTimeFromMinutes(String minutesInput) {
int? minutes = int.tryParse(minutesInput);
if (minutes == null) {
return minutesInput;
}
int hours = minutes ~/ 60;
int remainingMinutes = minutes % 60;
if (hours == 0) {
return '$remainingMinutes minutes';
}
if (remainingMinutes == 0) {
return '$hours hours';
}
return '$hours hours $remainingMinutes minutes';
// Format 2 (Alternative): Compact format
// return '${hours}h ${remainingMinutes}m';
// Format 3 (Alternative): Digital clock format
// return '${hours.toString().padLeft(2, '0')}:${remainingMinutes.toString().padLeft(2, '0')}';
}

View file

@ -6,6 +6,7 @@ import 'package:logging/logging.dart';
import 'package:madari_client/data/db.dart';
import 'package:madari_client/features/streamio_addons/extension/query_extension.dart';
import 'package:madari_client/utils/array-extension.dart';
import 'package:pocketbase/pocketbase.dart';
import '../../pocketbase/service/pocketbase.service.dart';
import '../../widgetter/plugins/stremio/models/cast_info.dart';
@ -208,8 +209,22 @@ class StremioAddonService {
await getInstalledAddons().refetch();
} catch (e, stack) {
print(e);
print(stack);
_logger.warning("Error to save addon", e, stack);
if (e is ClientException) {
try {
if (e.response.values.first["url"]["code"] ==
"validation_not_unique") {
throw Exception(
"Addon already installed make sure you don't have this in disabled addons.",
);
}
} catch (ex, stack) {
_logger.warning("Error find error", ex, stack);
throw Exception(e.response.values);
}
}
throw Exception('Failed to save addon: $e');
}
}

View file

@ -1,6 +1,7 @@
import 'package:cached_query_flutter/cached_query_flutter.dart';
import 'package:flutter/material.dart';
import 'package:madari_client/features/streamio_addons/extension/query_extension.dart';
import 'package:pocketbase/pocketbase.dart';
import '../models/stremio_base_types.dart';
import '../service/stremio_addon_service.dart';
@ -65,7 +66,7 @@ class _AddAddonSheetState extends State<AddAddonSheet> {
final manifest = await query.queryFn();
if (!mounted) return;
_installAddon(manifest);
await _installAddon(manifest);
}
Future<void> _installAddon(StremioManifest manifest) async {
@ -79,9 +80,17 @@ class _AddAddonSheetState extends State<AddAddonSheet> {
Navigator.of(context).pop();
}
} catch (e) {
print(e);
if (mounted) {
if (e is ClientException) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Failed to install addon: ${e.response}')),
);
return;
}
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Failed to install addon: $e')),
SnackBar(content: Text('Failed to install addon: ${e}')),
);
}
} finally {
@ -144,7 +153,9 @@ class _AddAddonSheetState extends State<AddAddonSheet> {
FilledButton.icon(
onPressed: () => _validateManifest(),
icon: const Icon(Icons.check),
label: const Text('Validate'),
label: _isInstalling
? const Text("Installing")
: const Text('Install'),
),
const SizedBox(height: 24),
const Text(

View file

@ -35,6 +35,7 @@ class _StreamioStreamListState extends State<StreamioStreamList> {
Set<String> _selectedAudios = {};
Set<String> _selectedSizes = {};
final Set<String> _selectedAddons = {};
final Map<String, List<StreamWithAddon>> streamsByAddon = {};
Set<String> _resolutions = {};
Set<String> _qualities = {};
@ -53,7 +54,6 @@ class _StreamioStreamListState extends State<StreamioStreamList> {
_logger.info('Loading streams for ${widget.meta.id}');
final addons = service.getInstalledAddons();
final result = await addons.queryFn();
final count = result
@ -63,7 +63,6 @@ class _StreamioStreamListState extends State<StreamioStreamList> {
return true;
}
}
return false;
})
.toList()
@ -86,7 +85,6 @@ class _StreamioStreamListState extends State<StreamioStreamList> {
callback: (items, addonName, error) {
setState(() {
left -= 1;
if (left <= 0) {
_isLoading = false;
}
@ -98,7 +96,7 @@ class _StreamioStreamListState extends State<StreamioStreamList> {
return;
}
if (items != null) {
if (items != null && addonName != null) {
final Set<String> resSet = {};
final Set<String> qualSet = {};
final Set<String> codecSet = {};
@ -106,12 +104,10 @@ class _StreamioStreamListState extends State<StreamioStreamList> {
final Set<String> sizeSet = {};
final streamsWithAddon = items
.map(
(stream) => StreamWithAddon(
stream: stream,
addonName: addonName,
),
)
.map((stream) => StreamWithAddon(
stream: stream,
addonName: addonName,
))
.toList();
for (var streamData in streamsWithAddon) {
@ -135,14 +131,13 @@ class _StreamioStreamListState extends State<StreamioStreamList> {
if (mounted) {
setState(() {
streams.addAll(streamsWithAddon);
if (addonName != null) _addons.add(addonName);
_resolutions = resSet;
_qualities = qualSet;
_codecs = codecSet;
_audios = audioSet;
_sizes = sizeSet;
_isLoading = false;
streamsByAddon[addonName] = streamsWithAddon;
_addons.add(addonName);
_resolutions.addAll(resSet);
_qualities.addAll(qualSet);
_codecs.addAll(codecSet);
_audios.addAll(audioSet);
_sizes.addAll(sizeSet);
});
}
}
@ -150,26 +145,20 @@ class _StreamioStreamListState extends State<StreamioStreamList> {
);
}
Color _getQualityColor(String resolution) {
final res = resolution.toUpperCase();
if (res.contains('2160P') || res.contains('4K') || res.contains('UHD')) {
return Colors.amberAccent;
} else if (res.contains('1080P')) {
return Colors.blue;
} else if (res.contains('720P')) {
return Colors.green;
}
return Colors.grey;
}
List<StreamWithAddon> _getFilteredStreams() {
return streams.where((streamData) {
if (streamData.stream.name == null) return false;
if (_selectedAddons.isNotEmpty &&
!_selectedAddons.contains(streamData.addonName)) {
return false;
List<StreamWithAddon> allStreams = [];
if (_selectedAddons.isEmpty) {
streamsByAddon.values.forEach(allStreams.addAll);
} else {
for (var addon in _selectedAddons) {
if (streamsByAddon.containsKey(addon)) {
allStreams.addAll(streamsByAddon[addon]!);
}
}
}
return allStreams.where((streamData) {
if (streamData.stream.name == null) return false;
try {
final info = StreamParser.parseStreamName(streamData.stream.name!);
@ -195,7 +184,9 @@ class _StreamioStreamListState extends State<StreamioStreamList> {
matchesSize;
} catch (e) {
_logger.warning(
'Error parsing stream info: ${streamData.stream.name}', e);
'Error parsing stream info: ${streamData.stream.name}',
e,
);
return false;
}
}).toList();
@ -263,154 +254,6 @@ class _StreamioStreamListState extends State<StreamioStreamList> {
);
}
Widget _buildStreamCard(StreamWithAddon streamData, ThemeData theme) {
final stream = streamData.stream;
final info = StreamParser.parseStreamName(stream.name ?? '');
return InkWell(
onTap: stream.url != null
? () async {
if (stream.url != null) {
final settings =
await PlaybackSettingsService.instance.getSettings();
if (settings.externalPlayer) {
await ExternalPlayerService.openInExternalPlayer(
videoUrl: stream.url!,
playerPackage: settings.selectedExternalPlayer,
);
return;
}
String url =
'/player/${widget.meta.type}/${widget.meta.id}/${Uri.encodeQueryComponent(stream.url!)}?';
final List<String> query = [];
if (widget.meta.selectedVideoIndex != null) {
query.add("index=${widget.meta.selectedVideoIndex}");
}
if (stream.behaviorHints?["bingeGroup"] != null) {
query.add(
"binge-group=${Uri.encodeQueryComponent(stream.behaviorHints?["bingeGroup"])}",
);
}
if (mounted) {
context.push(
url + query.join("&"),
extra: {
"meta": widget.meta,
},
);
}
}
}
: null,
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (info.resolution != null)
Padding(
padding: const EdgeInsets.only(bottom: 8),
child: StreamTag(
text: info.resolution!,
color: _getQualityColor(info.resolution!),
outlined: true,
),
),
Text(
(stream.name ?? 'Unknown Title') +
(stream.url != null ? "" : " (Not supported)"),
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w500,
),
),
Text(
stream.title ?? 'Unknown Title',
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w500,
),
),
if (stream.description != null)
Padding(
padding: const EdgeInsets.only(top: 4),
child: Text(
stream.description!,
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
),
if (streamData.addonName != null)
Padding(
padding: const EdgeInsets.only(top: 4),
child: Text(
'From: ${streamData.addonName}',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
),
],
),
),
],
),
if (info.quality != null &&
info.codec != null &&
info.audio != null &&
info.size != null &&
info.unrated)
const SizedBox(height: 12),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
if (info.quality != null)
StreamTag(
text: info.quality!,
color: theme.colorScheme.secondary,
),
if (info.codec != null)
StreamTag(
text: info.codec!,
color: theme.colorScheme.tertiary,
),
if (info.audio != null)
StreamTag(
text: info.audio!,
color: theme.colorScheme.primary,
),
if (info.size != null)
StreamTag(
text: StreamParser.getSizeCategory(info.size),
color: theme.colorScheme.secondary,
),
if (info.unrated)
StreamTag(
text: 'UNRATED',
color: theme.colorScheme.error,
),
],
),
],
),
),
);
}
@override
Widget build(BuildContext context) {
if (_isLoading) {
@ -490,18 +333,13 @@ class _StreamioStreamListState extends State<StreamioStreamList> {
value: addon,
child: Row(
children: [
Checkbox(
value: _selectedAddons.contains(addon),
onChanged: (bool? value) {
setState(() {
if (value == true) {
_selectedAddons.add(addon);
} else {
_selectedAddons.remove(addon);
}
});
Navigator.pop(context);
},
Icon(
_selectedAddons.contains(addon)
? Icons.check_circle
: Icons.circle_outlined,
),
const SizedBox(
width: 6,
),
Text(addon),
],
@ -538,11 +376,16 @@ class _StreamioStreamListState extends State<StreamioStreamList> {
)
: ListView.separated(
itemCount: filteredStreams.length,
separatorBuilder: (context, index) =>
const Divider(height: 1),
separatorBuilder: (context, index) => const Divider(
height: 1,
),
itemBuilder: (context, index) {
final streamData = filteredStreams[index];
return _buildStreamCard(streamData, theme);
return StreamCard(
streamWithAddon: streamData,
meta: widget.meta,
);
},
),
),
@ -557,6 +400,13 @@ class StreamWithAddon {
final String? addonName;
StreamWithAddon({required this.stream, this.addonName});
StreamWithAddon copy() {
return StreamWithAddon(
stream: stream.copyWith(),
addonName: addonName,
);
}
}
class StreamTag extends StatelessWidget {
@ -597,3 +447,181 @@ class StreamTag extends StatelessWidget {
);
}
}
class StreamCard extends StatelessWidget {
final StreamWithAddon streamWithAddon;
final Meta meta;
const StreamCard({
super.key,
required this.meta,
required this.streamWithAddon,
});
VideoStream get stream {
return streamWithAddon.stream.copyWith();
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final info = StreamParser.parseStreamName(stream.name ?? '');
return InkWell(
onTap: stream.url != null
? () async {
if (stream.url != null) {
final settings =
await PlaybackSettingsService.instance.getSettings();
if (settings.externalPlayer) {
await ExternalPlayerService.openInExternalPlayer(
videoUrl: stream.url!,
playerPackage: settings.selectedExternalPlayer,
);
return;
}
String url =
'/player/${meta.type}/${meta.id}/${Uri.encodeQueryComponent(stream.url!)}?';
final List<String> query = [];
if (meta.selectedVideoIndex != null) {
query.add("index=${meta.selectedVideoIndex}");
}
if (stream.behaviorHints?["bingeGroup"] != null) {
query.add(
"binge-group=${Uri.encodeQueryComponent(stream.behaviorHints?["bingeGroup"])}",
);
}
if (context.mounted) {
context.push(
url + query.join("&"),
extra: {
"meta": meta,
},
);
}
}
}
: null,
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (info.resolution != null)
Padding(
padding: const EdgeInsets.only(bottom: 8),
child: StreamTag(
text: info.resolution!,
color: _getQualityColor(info.resolution!),
outlined: true,
),
),
Text(
(stream.name ?? 'Unknown Title') +
(stream.url != null ? "" : " (Not supported)"),
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w500,
),
),
if (stream.title != null)
Text(
stream.title!,
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w500,
),
),
if (stream.description != null)
Padding(
padding: const EdgeInsets.only(top: 4),
child: Text(
stream.description!,
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
),
if (streamWithAddon.addonName != null)
Padding(
padding: const EdgeInsets.only(top: 4),
child: Text(
'From: ${streamWithAddon.addonName}',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
),
],
),
),
],
),
if (info.quality != null &&
info.codec != null &&
info.audio != null &&
info.size != null &&
info.unrated)
const SizedBox(height: 12),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
if (info.quality != null)
StreamTag(
text: info.quality!,
color: theme.colorScheme.secondary,
),
if (info.codec != null)
StreamTag(
text: info.codec!,
color: theme.colorScheme.tertiary,
),
if (info.audio != null)
StreamTag(
text: info.audio!,
color: theme.colorScheme.primary,
),
if (info.size != null)
StreamTag(
text: StreamParser.getSizeCategory(info.size),
color: theme.colorScheme.secondary,
),
if (info.unrated)
StreamTag(
text: 'UNRATED',
color: theme.colorScheme.error,
),
],
),
],
),
),
);
}
Color _getQualityColor(String resolution) {
final res = resolution.toUpperCase();
if (res.contains('2160P') || res.contains('4K') || res.contains('UHD')) {
return Colors.amberAccent;
} else if (res.contains('1080P')) {
return Colors.blue;
} else if (res.contains('720P')) {
return Colors.green;
}
return Colors.grey;
}
}

View file

@ -20,7 +20,11 @@ Future<void> openVideoStream(BuildContext context, Meta meta) async {
builder: (context) {
return Scaffold(
body: StreamioStreamList(
meta: meta,
meta: meta.type == "series"
? meta.copyWith(
selectedVideoIndex: meta.selectedVideoIndex ?? 0,
)
: meta,
),
);
},
@ -115,19 +119,21 @@ class StreamioHeroSection extends StatelessWidget {
),
),
),
IconButton.filled(
onPressed: () {
_logger.info('Play button pressed for ${meta.name}');
if (meta.type != "series")
IconButton.filled(
onPressed: () {
_logger.info('Play button pressed for ${meta.name}');
openVideoStream(context, meta);
},
icon: const Icon(Icons.play_arrow, size: 32),
style: IconButton.styleFrom(
backgroundColor: Theme.of(context).colorScheme.primary,
foregroundColor:
Theme.of(context).colorScheme.onPrimary,
openVideoStream(context, meta);
},
icon: const Icon(Icons.play_arrow, size: 32),
style: IconButton.styleFrom(
backgroundColor:
Theme.of(context).colorScheme.primary,
foregroundColor:
Theme.of(context).colorScheme.onPrimary,
),
),
),
],
),
),
@ -149,11 +155,18 @@ class StreamioHeroSection extends StatelessWidget {
),
const SizedBox(height: 8),
Text(
'${meta.year ?? ''}${meta.runtime ?? ''}${meta.genres?.join(', ') ?? ''}',
'${meta.year ?? ''}${meta.genres?.join(', ') ?? ''}',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
color: Colors.white.withValues(alpha: 0.7),
),
),
if (meta.runtime != null)
Text(
meta.runtime!,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
color: Colors.white.withValues(alpha: 0.7),
),
),
if (meta.imdbRating.isNotEmpty &&
meta.imdbRating.toString() != "null") ...[
const SizedBox(height: 16),
@ -171,19 +184,21 @@ class StreamioHeroSection extends StatelessWidget {
],
),
],
const SizedBox(
height: 12,
),
OutlinedButton.icon(
onPressed: () {
openVideoStream(
context,
meta,
);
},
icon: const Icon(Icons.play_arrow),
label: const Text("Play"),
),
if (meta.type != "series") ...[
const SizedBox(
height: 12,
),
OutlinedButton.icon(
onPressed: () {
openVideoStream(
context,
meta,
);
},
icon: const Icon(Icons.play_arrow),
label: const Text("Play"),
),
],
const SizedBox(
height: 12,
),