diff --git a/lib/features/streamio_addons/models/stremio_base_types.dart b/lib/features/streamio_addons/models/stremio_base_types.dart index cc55df4..53026bd 100644 --- a/lib/features/streamio_addons/models/stremio_base_types.dart +++ b/lib/features/streamio_addons/models/stremio_base_types.dart @@ -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? 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 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')}'; +} diff --git a/lib/features/streamio_addons/service/stremio_addon_service.dart b/lib/features/streamio_addons/service/stremio_addon_service.dart index 4ebf828..2b05d52 100644 --- a/lib/features/streamio_addons/service/stremio_addon_service.dart +++ b/lib/features/streamio_addons/service/stremio_addon_service.dart @@ -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'); } } diff --git a/lib/features/streamio_addons/widget/add_addon_sheet.dart b/lib/features/streamio_addons/widget/add_addon_sheet.dart index 7841a05..2e63fd0 100644 --- a/lib/features/streamio_addons/widget/add_addon_sheet.dart +++ b/lib/features/streamio_addons/widget/add_addon_sheet.dart @@ -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 { final manifest = await query.queryFn(); if (!mounted) return; - _installAddon(manifest); + await _installAddon(manifest); } Future _installAddon(StremioManifest manifest) async { @@ -79,9 +80,17 @@ class _AddAddonSheetState extends State { 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 { 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( diff --git a/lib/features/widgetter/plugins/stremio/containers/stream_list.dart b/lib/features/widgetter/plugins/stremio/containers/stream_list.dart index f8a3823..940591b 100644 --- a/lib/features/widgetter/plugins/stremio/containers/stream_list.dart +++ b/lib/features/widgetter/plugins/stremio/containers/stream_list.dart @@ -35,6 +35,7 @@ class _StreamioStreamListState extends State { Set _selectedAudios = {}; Set _selectedSizes = {}; final Set _selectedAddons = {}; + final Map> streamsByAddon = {}; Set _resolutions = {}; Set _qualities = {}; @@ -53,7 +54,6 @@ class _StreamioStreamListState extends State { _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 { return true; } } - return false; }) .toList() @@ -86,7 +85,6 @@ class _StreamioStreamListState extends State { callback: (items, addonName, error) { setState(() { left -= 1; - if (left <= 0) { _isLoading = false; } @@ -98,7 +96,7 @@ class _StreamioStreamListState extends State { return; } - if (items != null) { + if (items != null && addonName != null) { final Set resSet = {}; final Set qualSet = {}; final Set codecSet = {}; @@ -106,12 +104,10 @@ class _StreamioStreamListState extends State { final Set 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 { 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 { ); } - 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 _getFilteredStreams() { - return streams.where((streamData) { - if (streamData.stream.name == null) return false; - - if (_selectedAddons.isNotEmpty && - !_selectedAddons.contains(streamData.addonName)) { - return false; + List 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 { 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 { ); } - 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 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 { 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 { ) : 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 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; + } +} diff --git a/lib/features/widgetter/plugins/stremio/containers/streamio_background.dart b/lib/features/widgetter/plugins/stremio/containers/streamio_background.dart index 655e3d4..a05c17d 100644 --- a/lib/features/widgetter/plugins/stremio/containers/streamio_background.dart +++ b/lib/features/widgetter/plugins/stremio/containers/streamio_background.dart @@ -20,7 +20,11 @@ Future 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, ),