diff --git a/flutter/android/app/src/main/jniLibs/arm64-v8a/libfred_tv_lib.so b/flutter/android/app/src/main/jniLibs/arm64-v8a/libfred_tv_lib.so index 502dcdd..85aae21 100755 Binary files a/flutter/android/app/src/main/jniLibs/arm64-v8a/libfred_tv_lib.so and b/flutter/android/app/src/main/jniLibs/arm64-v8a/libfred_tv_lib.so differ diff --git a/flutter/android/app/src/main/jniLibs/armeabi-v7a/libfred_tv_lib.so b/flutter/android/app/src/main/jniLibs/armeabi-v7a/libfred_tv_lib.so index 486e6fc..940bb62 100755 Binary files a/flutter/android/app/src/main/jniLibs/armeabi-v7a/libfred_tv_lib.so and b/flutter/android/app/src/main/jniLibs/armeabi-v7a/libfred_tv_lib.so differ diff --git a/flutter/android/app/src/main/jniLibs/x86_64/libfred_tv_lib.so b/flutter/android/app/src/main/jniLibs/x86_64/libfred_tv_lib.so index ac809f8..89c45ef 100755 Binary files a/flutter/android/app/src/main/jniLibs/x86_64/libfred_tv_lib.so and b/flutter/android/app/src/main/jniLibs/x86_64/libfred_tv_lib.so differ diff --git a/flutter/assets/btc.png b/flutter/assets/btc.png new file mode 100644 index 0000000..e1ec1e8 Binary files /dev/null and b/flutter/assets/btc.png differ diff --git a/flutter/assets/fred.jpg b/flutter/assets/fred.jpg new file mode 100644 index 0000000..da969c6 Binary files /dev/null and b/flutter/assets/fred.jpg differ diff --git a/flutter/assets/github.png b/flutter/assets/github.png new file mode 100644 index 0000000..98d6baa Binary files /dev/null and b/flutter/assets/github.png differ diff --git a/flutter/assets/paypal.png b/flutter/assets/paypal.png new file mode 100644 index 0000000..f8eb551 Binary files /dev/null and b/flutter/assets/paypal.png differ diff --git a/flutter/lib/app_keys.dart b/flutter/lib/app_keys.dart new file mode 100644 index 0000000..d6cfddc --- /dev/null +++ b/flutter/lib/app_keys.dart @@ -0,0 +1,4 @@ +import 'package:flutter/material.dart'; + +final navigatorKey = GlobalKey(); +final scaffoldMessengerKey = GlobalKey(); diff --git a/flutter/lib/bottom_nav.dart b/flutter/lib/bottom_nav.dart index 2d4d6ea..ab35b9f 100644 --- a/flutter/lib/bottom_nav.dart +++ b/flutter/lib/bottom_nav.dart @@ -1,17 +1,16 @@ import 'package:flutter/material.dart'; import 'package:open_tv/models/view_type.dart'; import 'package:open_tv/settings_view.dart'; +import 'package:open_tv/task_service.dart'; class BottomNav extends StatefulWidget { final Function(ViewType) updateViewMode; final ViewType startingView; - final bool blockSettings; final bool tvMode; const BottomNav({ super.key, required this.updateViewMode, this.startingView = ViewType.all, - this.blockSettings = false, this.tvMode = false, }); @@ -36,12 +35,8 @@ class _BottomNavState extends State { } void onBarTapped(int index) { - if (widget.blockSettings && index == ViewType.settings.index) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text("Settings disabled while refreshing on start"), - ), - ); + if (TaskService.instance.isDeletingSource) { + TaskService.instance.notifyBusy(); return; } setState(() { @@ -51,8 +46,7 @@ class _BottomNavState extends State { Navigator.pushAndRemoveUntil( context, PageRouteBuilder( - pageBuilder: (_, __, ___) => - SettingsView(tvMode: widget.tvMode), + pageBuilder: (_, __, ___) => SettingsView(tvMode: widget.tvMode), transitionDuration: Duration.zero, reverseTransitionDuration: Duration.zero, transitionsBuilder: (context, animation, secondaryAnimation, child) => diff --git a/flutter/lib/channel_tile.dart b/flutter/lib/channel_tile.dart index 84cfeec..7303ad8 100644 --- a/flutter/lib/channel_tile.dart +++ b/flutter/lib/channel_tile.dart @@ -11,6 +11,7 @@ import 'package:open_tv/models/node_type.dart'; import 'package:open_tv/native_bridge.dart'; import 'package:open_tv/player.dart'; import 'package:open_tv/exo_player.dart'; +import 'package:open_tv/task_service.dart'; import 'dart:io' show Platform; class ChannelTile extends StatefulWidget { @@ -152,38 +153,32 @@ class _ChannelTileState extends State { Future favorite() async { if (widget.channel.mediaType == MediaType.group) return; await Error.tryAsyncNoLoading(() async { - await NativeBridge.instance.favorite( + final applied = await TaskService.instance.favorite( widget.channel.id!, !widget.channel.favorite, ); - if (!mounted) return; + if (!applied || !mounted) return; setState(() { widget.channel.favorite = !widget.channel.favorite; }); - ScaffoldMessenger.of(context).showSnackBar( + Error.showSnackBar( const SnackBar( + persist: false, content: Text("Added to favorites"), duration: Duration(milliseconds: 500), ), ); - }, context); + }); } Future _handleSeries() async { if (widget.channel.url?.isEmpty == true) { - if (context.mounted) { - Error.handleError(context, "Invalid series: series ID is null"); - } + Error.handleError("Invalid series: series ID is null"); return null; } final seriesId = int.tryParse(widget.channel.url!); if (seriesId == null) { - if (context.mounted) { - Error.handleError( - context, - "Invalid series: series ID is not a valid number", - ); - } + Error.handleError("Invalid series: series ID is not a valid number"); return null; } await Error.tryAsync( @@ -227,8 +222,9 @@ class _ChannelTileState extends State { ); } else { var settings = await NativeBridge.instance.getSettings(); - NativeBridge.instance.addLastWatched(widget.channel.id!); + TaskService.instance.addLastWatched(widget.channel.id!); if (!mounted) return; + TaskService.instance.playerVisible.value = true; await Navigator.push( context, MaterialPageRoute( @@ -237,6 +233,7 @@ class _ChannelTileState extends State { : Player(channel: widget.channel, settings: settings), ), ); + TaskService.instance.playerVisible.value = false; if (mounted) _focusNode.requestFocus(); } } @@ -256,7 +253,7 @@ class _ChannelTileState extends State { statesController: _statesController, borderRadius: BorderRadius.circular(12), onLongPress: favorite, - onTap: () async => await play(), + onTap: play, child: Row( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ diff --git a/flutter/lib/donate_view.dart b/flutter/lib/donate_view.dart new file mode 100644 index 0000000..0d830c8 --- /dev/null +++ b/flutter/lib/donate_view.dart @@ -0,0 +1,292 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:open_tv/error.dart'; +import 'package:url_launcher/url_launcher.dart'; + +const paypalUrl = "https://www.paypal.com/paypalme/fredolx"; +const githubSponsorsUrl = "https://github.com/sponsors/Fredolx"; +const btcAddress = "bc1q7v27u4mrxhtqzl97pcp4vl52npss760epsheu3"; + +const _sideBySideWidth = 600.0; +const _photoAsset = "assets/fred.jpg"; +const _heading = "Support Fred TV"; +const _signature = "Frédéric Lachapelle"; + +const _letter = ''' +Hi! It's me, Fred, the developer of Fred TV. I made Fred TV with the sole and unique goal to bring back the notion of what software is truly meant to be: tools. Tools to serve us humans, not to control us or to exploit us. + +I want to dedicate myself to making open-source apps for a living. Help my dream become true, and be part of an open-source revolution to free us from the shackles of exploitative spyware ridden apps. Every donation helps towards this goal and funds future development. + +Thank you for using Fred TV. Please share this app far and wide if you enjoy it and consider making a donation of any amount, even a dollar. Your trust, support and continued use of my applications are greatly appreciated. + +IPTV is not the first or last domain we will change together. Expect more Fred apps in the future! + +Thank you!'''; + +class DonateView extends StatelessWidget { + final bool tvMode; + const DonateView({super.key, this.tvMode = false}); + + Future openLink(String url) => + launchUrl(Uri.parse(url), mode: LaunchMode.externalApplication); + + Future copyBtcAddress() async { + await Clipboard.setData(const ClipboardData(text: btcAddress)); + Error.showMessage("Bitcoin address copied"); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text(_heading)), + body: SafeArea( + child: Scrollbar( + child: SingleChildScrollView( + child: tvMode ? buildTvLayout(context) : buildMobileLayout(context), + ), + ), + ), + ); + } + + Widget buildTvLayout(BuildContext context) { + return Padding( + padding: const EdgeInsets.fromLTRB(40, 4, 40, 4), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.only(top: 20), + child: buildPhoto(context, 200), + ), + const SizedBox(width: 40), + Expanded(child: buildTextPanel(context)), + ], + ), + const SizedBox(height: 40), + buildQrRow(context), + ], + ), + ); + } + + Widget buildTextPanel(BuildContext context) { + return Focus( + autofocus: true, + child: Builder( + builder: (context) => + buildTextCard(context, focused: Focus.of(context).hasFocus), + ), + ); + } + + Widget buildTextCard(BuildContext context, {bool focused = false}) { + return Container( + margin: const EdgeInsets.only(top: 12), + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(16), + color: Theme.of(context).colorScheme.surfaceContainer, + border: Border.all( + color: focused + ? Theme.of(context).colorScheme.primary + : Colors.transparent, + width: 2, + ), + ), + child: buildText(context), + ); + } + + Widget buildMobileLayout(BuildContext context) { + return Padding( + padding: const EdgeInsets.fromLTRB(24, 24, 24, 32), + child: LayoutBuilder( + builder: (context, constraints) => Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (constraints.maxWidth >= _sideBySideWidth) + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.only(top: 20), + child: buildPhoto(context, 180), + ), + const SizedBox(width: 32), + Expanded(child: buildTextCard(context)), + ], + ) + else ...[ + Center(child: buildPhoto(context, 150)), + const SizedBox(height: 24), + buildTextCard(context), + ], + const SizedBox(height: 32), + buildMobileActions(), + ], + ), + ), + ); + } + + Widget buildPhoto(BuildContext context, double size) { + return Container( + width: size, + height: size, + decoration: BoxDecoration( + shape: BoxShape.circle, + border: Border.all( + color: Theme.of(context).colorScheme.primary, + width: 3, + ), + boxShadow: [ + BoxShadow( + color: Theme.of(context).colorScheme.primary.withValues(alpha: 0.3), + blurRadius: 24, + spreadRadius: 2, + ), + ], + ), + child: ClipOval( + child: Image.asset( + _photoAsset, + fit: BoxFit.cover, + cacheWidth: (size * 3).round(), + ), + ), + ); + } + + Widget buildText(BuildContext context) { + final bodySize = tvMode + ? (MediaQuery.sizeOf(context).height * 0.021).clamp(14.0, 24.0) + : 16.0; + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(_letter, style: TextStyle(fontSize: bodySize, height: 1.5)), + const SizedBox(height: 8), + Text( + _signature, + style: TextStyle( + fontSize: bodySize + 1, + fontWeight: FontWeight.bold, + fontStyle: FontStyle.normal, + color: Theme.of(context).colorScheme.primary, + ), + ), + ], + ); + } + + Widget buildQrRow(BuildContext context) { + final size = (MediaQuery.sizeOf(context).height * 0.24).clamp(120.0, 280.0); + return Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + _QrCard(label: "PayPal", asset: "assets/paypal.png", size: size), + _QrCard( + label: "GitHub Sponsors", + asset: "assets/github.png", + size: size, + ), + _QrCard(label: "Bitcoin", asset: "assets/btc.png", size: size), + ], + ); + } + + Widget buildMobileActions() { + const style = ButtonStyle( + padding: WidgetStatePropertyAll(EdgeInsets.symmetric(vertical: 16)), + textStyle: WidgetStatePropertyAll(TextStyle(fontSize: 16)), + ); + final buttons = [ + FilledButton.icon( + onPressed: () => openLink(paypalUrl), + icon: const Icon(Icons.favorite), + style: style, + label: const Text("Donate with PayPal"), + ), + FilledButton.tonalIcon( + onPressed: () => openLink(githubSponsorsUrl), + icon: const Icon(Icons.code), + style: style, + label: const Text("GitHub Sponsors"), + ), + OutlinedButton.icon( + onPressed: copyBtcAddress, + icon: const Icon(Icons.copy), + style: style, + label: const Text("Copy Bitcoin address"), + ), + ]; + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + spacing: 12, + children: buttons, + ); + } +} + +class _QrCard extends StatelessWidget { + final String label; + final String asset; + final double size; + const _QrCard({required this.label, required this.asset, required this.size}); + + @override + Widget build(BuildContext context) { + return Focus( + child: Builder( + builder: (context) { + final focused = Focus.of(context).hasFocus; + final highlight = Theme.of(context).colorScheme.primary; + return AnimatedScale( + scale: focused ? 1.08 : 1, + duration: const Duration(milliseconds: 150), + curve: Curves.easeOut, + child: Padding( + padding: const EdgeInsets.only(bottom: 22), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + AnimatedContainer( + duration: const Duration(milliseconds: 150), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(6), + border: Border.all( + color: focused ? highlight : Colors.transparent, + width: 3, + ), + ), + child: Image.asset( + asset, + width: size, + height: size, + filterQuality: FilterQuality.medium, + ), + ), + const SizedBox(height: 10), + Text( + label, + style: TextStyle( + fontSize: (size * 0.11).clamp(15.0, 24.0), + letterSpacing: 0.5, + fontWeight: focused ? FontWeight.bold : FontWeight.normal, + color: focused ? highlight : Colors.white, + ), + ), + ], + ), + ), + ); + }, + ), + ); + } +} diff --git a/flutter/lib/edit_dialog.dart b/flutter/lib/edit_dialog.dart index 1226e88..284aaa1 100644 --- a/flutter/lib/edit_dialog.dart +++ b/flutter/lib/edit_dialog.dart @@ -39,7 +39,7 @@ class _EditDialogState extends State { } Navigator.of(context).pop(); await Error.tryAsyncNoLoading( - () async => await NativeBridge.instance.updateSource( + () => NativeBridge.instance.updateSource( Source( id: widget.source.id, name: widget.source.name, @@ -53,7 +53,6 @@ class _EditDialogState extends State { : null, ), ), - widget.parentContext, ); await widget.afterSave(); }, diff --git a/flutter/lib/error.dart b/flutter/lib/error.dart index 7302995..b706dee 100644 --- a/flutter/lib/error.dart +++ b/flutter/lib/error.dart @@ -1,131 +1,148 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:loader_overlay/loader_overlay.dart'; +import 'package:open_tv/app_keys.dart'; import 'package:open_tv/models/result.dart'; import 'package:url_launcher/url_launcher.dart'; class Error { - static Future handleError(BuildContext context, String error) async { - if (context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( + static final ValueNotifier visibleSnackBars = ValueNotifier(0); + + static void showSnackBar(SnackBar snackBar) { + final controller = scaffoldMessengerKey.currentState?.showSnackBar(snackBar); + if (controller == null) return; + visibleSnackBars.value++; + controller.closed.then((_) => visibleSnackBars.value--); + } + + static Future handleError(String error) async { + final context = navigatorKey.currentContext; + if (context != null && context.mounted) { + showSnackBar( SnackBar( - backgroundColor: Colors.red[700], - content: const Text( - "An error occured. Click on 'Details' for more information", - style: TextStyle(color: Colors.white), + persist: false, + backgroundColor: Colors.red[700], + content: const Text( + "An error occured. Click on 'Details' for more information", + style: TextStyle(color: Colors.white), + ), + action: SnackBarAction( + label: 'Details', + textColor: Colors.white, + onPressed: () => showDialog( + barrierDismissible: true, + context: context, + builder: (builder) => AlertDialog( + title: const Text('Error'), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Text( + "The following error occured. If this error persists, please report it.\n", + ), + Container( + width: double.infinity, + padding: const EdgeInsets.all( + 8.0, + ), + decoration: BoxDecoration( + color: Colors.black, + borderRadius: BorderRadius.circular(8.0), + ), + child: ConstrainedBox( + constraints: const BoxConstraints(maxHeight: 200), + child: SingleChildScrollView( + child: Text( + error, + style: const TextStyle(color: Colors.white), + ), + ), + ), + ), + ], + ), + actions: [ + TextButton( + style: TextButton.styleFrom( + textStyle: Theme.of(context).textTheme.labelLarge, + ), + child: const Text('Report issue'), + onPressed: () async { + final Uri url = Uri.parse( + 'https://github.com/fredolx/fred-tv-mobile/issues/new?template=Blank+issue', + ); + await launchUrl( + url, + mode: LaunchMode.externalApplication, + ); + }, + ), + TextButton( + style: TextButton.styleFrom( + textStyle: Theme.of(context).textTheme.labelLarge, + ), + child: const Text('Copy'), + onPressed: () { + Clipboard.setData(ClipboardData(text: error.toString())); + }, + ), + TextButton( + style: TextButton.styleFrom( + textStyle: Theme.of(context).textTheme.labelLarge, + ), + child: const Text('Close'), + onPressed: () { + Navigator.of(context).pop(); + }, + ), + ], + ), ), - action: SnackBarAction( - label: 'Details', - textColor: Colors.white, - onPressed: () async => { - await showDialog( - barrierDismissible: true, - context: context, - builder: (builder) => AlertDialog( - title: const Text('Error'), - content: Column( - mainAxisSize: MainAxisSize.min, - children: [ - const Text( - "The following error occured. If this error persists, please report it.\n"), - Container( - width: double.infinity, - padding: const EdgeInsets.all( - 8.0), // Padding inside the box - decoration: BoxDecoration( - color: Colors.black, - borderRadius: - BorderRadius.circular(8.0)), - child: ConstrainedBox( - constraints: const BoxConstraints( - maxHeight: 200), - child: SingleChildScrollView( - child: Text( - error, - style: const TextStyle( - color: Colors.white), - )))) - ], - ), - actions: [ - TextButton( - style: TextButton.styleFrom( - textStyle: Theme.of(context) - .textTheme - .labelLarge, - ), - child: const Text('Report issue'), - onPressed: () async { - final Uri url = Uri.parse( - 'https://github.com/fredolx/fred-tv-mobile/issues/new?template=Blank+issue'); - await launchUrl(url, - mode: LaunchMode.externalApplication); - }, - ), - TextButton( - style: TextButton.styleFrom( - textStyle: Theme.of(context) - .textTheme - .labelLarge, - ), - child: const Text('Copy'), - onPressed: () { - Clipboard.setData(ClipboardData( - text: error.toString())); - }, - ), - TextButton( - style: TextButton.styleFrom( - textStyle: Theme.of(context) - .textTheme - .labelLarge, - ), - child: const Text('Close'), - onPressed: () { - Navigator.of(context).pop(); - }, - ), - ], - )) - })), + ), + ), ); } } - static void showSuccess(BuildContext context, String message) { - if (context.mounted) { - ScaffoldMessenger.of(context) - .showSnackBar(SnackBar(content: Text(message))); - } + static void showMessage(String message) { + showSnackBar(SnackBar(content: Text(message), persist: false)); } static Future> tryAsync( - Future Function() fn, BuildContext context, - [String? successMessage = "Action completed successfully", - bool useLoading = true, - bool useSuccess = true]) async { + Future Function() fn, + BuildContext? context, [ + String? successMessage = "Action completed successfully", + bool useLoading = true, + bool useSuccess = true, + ]) async { var success = false; T? result; - if (useLoading && context.mounted) { + if (useLoading && context != null && context.mounted) { context.loaderOverlay.show(); } try { result = await fn(); - if (useSuccess) showSuccess(context, successMessage!); + if (useSuccess) showMessage(successMessage!); success = true; } catch (e, stackTrace) { - final error = "${e.toString()}\n\n-- Dart Stack Trace --\n${stackTrace.toString()}"; - await handleError(context, error); + final error = + "${e.toString()}\n\n-- Dart Stack Trace --\n${stackTrace.toString()}"; + await handleError(error); } - if (useLoading && context.mounted && context.loaderOverlay.visible) { + if (useLoading && + context != null && + context.mounted && + context.loaderOverlay.visible) { context.loaderOverlay.hide(); } return Result(success: success, data: result); } static Future> tryAsyncNoLoading( - Future Function() fn, BuildContext context, - [bool useSuccess = false, String? successMessage]) async { - return await tryAsync(fn, context, successMessage, false, useSuccess); + Future Function() fn, [ + bool useSuccess = false, + String? successMessage, + ]) async { + return await tryAsync(fn, null, successMessage, false, useSuccess); } } diff --git a/flutter/lib/exo_player.dart b/flutter/lib/exo_player.dart index 61a8456..e2a884b 100644 --- a/flutter/lib/exo_player.dart +++ b/flutter/lib/exo_player.dart @@ -11,6 +11,7 @@ import 'package:open_tv/models/channel_http_headers.dart'; import 'package:open_tv/models/media_type.dart'; import 'package:open_tv/models/settings.dart'; import 'package:open_tv/native_bridge.dart'; +import 'package:open_tv/task_service.dart'; class ExoPlayerScreen extends StatefulWidget { final Channel channel; @@ -47,15 +48,19 @@ class _ExoPlayerScreenState extends State { } Future _init() async { - final ChannelHttpHeaders? headers = (await Error.tryAsyncNoLoading(() async { - return await NativeBridge.instance.getChannelHeaders(widget.channel.id!); - }, context)).data; + final ChannelHttpHeaders? headers = (await Error.tryAsyncNoLoading( + () async { + return await NativeBridge.instance.getChannelHeaders( + widget.channel.id!, + ); + }, + )).data; final seconds = widget.channel.mediaType == MediaType.movie ? (await Error.tryAsyncNoLoading(() async { return await NativeBridge.instance.getMoviePosition( widget.channel.id!, ); - }, context)).data + })).data : null; if (!mounted) return; setState(() { @@ -88,7 +93,7 @@ class _ExoPlayerScreenState extends State { if (widget.channel.mediaType == MediaType.movie && _channel != null) { try { final posMs = await _channel!.invokeMethod("getPosition") ?? 0; - await NativeBridge.instance.setMoviePosition( + await TaskService.instance.setMoviePosition( widget.channel.id!, posMs ~/ 1000, ); @@ -138,7 +143,9 @@ class _ExoPlayerScreenState extends State { creationParamsCodec: const StandardMessageCodec(), onFocus: () => params.onFocusChanged(true), ); - controller.addOnPlatformViewCreatedListener(params.onPlatformViewCreated); + controller.addOnPlatformViewCreatedListener( + params.onPlatformViewCreated, + ); controller.addOnPlatformViewCreatedListener(_onPlatformViewCreated); controller.create(); return controller; diff --git a/flutter/lib/extensions/int_extensions.dart b/flutter/lib/extensions/int_extensions.dart new file mode 100644 index 0000000..11355e5 --- /dev/null +++ b/flutter/lib/extensions/int_extensions.dart @@ -0,0 +1,82 @@ +extension IntExtensions on int { + DateTime get _asDateTime => DateTime.fromMillisecondsSinceEpoch(this * 1000); + + String toTimeAgo() { + final seconds = DateTime.now().difference(_asDateTime).inSeconds; + if (seconds < 29) return 'Just now'; + + final interval = _formatInterval(seconds, _timeAgoIntervals); + return interval != null ? '$interval ago' : toString(); + } + + String toTimeUntil() { + final targetDate = _asDateTime; + final seconds = targetDate.difference(DateTime.now()).inSeconds; + final formattedDate = _formatExactDate(targetDate); + + if (seconds < 0) return 'Expired ($formattedDate)'; + + final interval = _formatInterval(seconds, _timeUntilIntervals); + final relativeText = interval != null + ? 'In $interval' + : 'In less than an hour'; + + return '$relativeText ($formattedDate)'; + } + + static const _timeAgoIntervals = { + 'day': 86400, + 'hour': 3600, + 'minute': 60, + 'second': 1, + }; + + static const _timeUntilIntervals = { + 'year': 31536000, + 'month': 2592000, + 'week': 604800, + 'day': 86400, + 'hour': 3600, + }; + + static const _months = [ + 'January', + 'February', + 'March', + 'April', + 'May', + 'June', + 'July', + 'August', + 'September', + 'October', + 'November', + 'December', + ]; + + static String? _formatInterval(int seconds, Map intervals) { + for (final entry in intervals.entries) { + final counter = seconds ~/ entry.value; + if (counter > 0) { + return '$counter ${entry.key}${counter == 1 ? '' : 's'}'; + } + } + return null; + } + + static String _formatExactDate(DateTime date) { + final month = _months[date.month - 1]; + final day = date.day; + return '$month $day${_getOrdinalSuffix(day)} ${date.year}'; + } + + static String _getOrdinalSuffix(int day) { + if (day >= 11 && day <= 13) return 'th'; + return switch (day % 10) { + 1 => 'st', + 2 => 'nd', + 3 => 'rd', + _ => 'th', + }; + } +} diff --git a/flutter/lib/models/proto_extensions.dart b/flutter/lib/extensions/proto_extensions.dart similarity index 96% rename from flutter/lib/models/proto_extensions.dart rename to flutter/lib/extensions/proto_extensions.dart index 943e680..feb559b 100644 --- a/flutter/lib/models/proto_extensions.dart +++ b/flutter/lib/extensions/proto_extensions.dart @@ -35,6 +35,7 @@ extension SourceProtoExtension on pb.Source { username: hasUsername() ? username : null, password: hasPassword() ? password : null, sourceType: SourceType.values[sourceType], + lastUpdated: hasLastUpdated() ? lastUpdated.toInt() : null, enabled: enabled, ); } @@ -63,7 +64,7 @@ extension FiltersDomainExtension on Filters { season: seasonId != null ? Int64(seasonId!) : null, groupId: groupId != null ? Int64(groupId!) : null, useKeywords: useKeywords, - sort: sort.index, + sort: sort?.index ?? SortType.provider.index, ); } diff --git a/flutter/lib/generated/bindings.dart b/flutter/lib/generated/bindings.dart index c56fc2f..2a468a1 100644 --- a/flutter/lib/generated/bindings.dart +++ b/flutter/lib/generated/bindings.dart @@ -1626,16 +1626,30 @@ class RustLibBindings { late final _getloadavg = _getloadavgPtr .asFunction, int)>(); - void add_last_watched(int task_id, FfiCallback callback, ffi.Pointer ptr, int len) { + void add_last_watched( + int task_id, + FfiCallback callback, + ffi.Pointer ptr, + int len, + ) { return _add_last_watched(task_id, callback, ptr, len); } late final _add_last_watchedPtr = _lookup< - ffi.NativeFunction, ffi.Size)> + ffi.NativeFunction< + ffi.Void Function( + ffi.Uint64, + FfiCallback, + ffi.Pointer, + ffi.Size, + ) + > >('add_last_watched'); late final _add_last_watched = _add_last_watchedPtr - .asFunction, int)>(); + .asFunction< + void Function(int, FfiCallback, ffi.Pointer, int) + >(); void clear_history(int task_id, FfiCallback callback) { return _clear_history(task_id, callback); @@ -1648,27 +1662,55 @@ class RustLibBindings { late final _clear_history = _clear_historyPtr .asFunction(); - void delete_source(int task_id, FfiCallback callback, ffi.Pointer ptr, int len) { + void delete_source( + int task_id, + FfiCallback callback, + ffi.Pointer ptr, + int len, + ) { return _delete_source(task_id, callback, ptr, len); } late final _delete_sourcePtr = _lookup< - ffi.NativeFunction, ffi.Size)> + ffi.NativeFunction< + ffi.Void Function( + ffi.Uint64, + FfiCallback, + ffi.Pointer, + ffi.Size, + ) + > >('delete_source'); late final _delete_source = _delete_sourcePtr - .asFunction, int)>(); + .asFunction< + void Function(int, FfiCallback, ffi.Pointer, int) + >(); - void favorite(int task_id, FfiCallback callback, ffi.Pointer ptr, int len) { + void favorite( + int task_id, + FfiCallback callback, + ffi.Pointer ptr, + int len, + ) { return _favorite(task_id, callback, ptr, len); } late final _favoritePtr = _lookup< - ffi.NativeFunction, ffi.Size)> + ffi.NativeFunction< + ffi.Void Function( + ffi.Uint64, + FfiCallback, + ffi.Pointer, + ffi.Size, + ) + > >('favorite'); late final _favorite = _favoritePtr - .asFunction, int)>(); + .asFunction< + void Function(int, FfiCallback, ffi.Pointer, int) + >(); void free_message(ffi.Pointer ptr, int len) { return _free_message(ptr, len); @@ -1676,34 +1718,71 @@ class RustLibBindings { late final _free_messagePtr = _lookup< - ffi.NativeFunction< - ffi.Void Function(ffi.Pointer, ffi.Size) - > + ffi.NativeFunction, ffi.Size)> >('free_message'); late final _free_message = _free_messagePtr .asFunction, int)>(); - void get_channel_headers(int task_id, FfiCallback callback, ffi.Pointer ptr, int len) { + void get_all_expiries(int task_id, FfiCallback callback) { + return _get_all_expiries(task_id, callback); + } + + late final _get_all_expiriesPtr = + _lookup>( + 'get_all_expiries', + ); + late final _get_all_expiries = _get_all_expiriesPtr + .asFunction(); + + void get_channel_headers( + int task_id, + FfiCallback callback, + ffi.Pointer ptr, + int len, + ) { return _get_channel_headers(task_id, callback, ptr, len); } late final _get_channel_headersPtr = _lookup< - ffi.NativeFunction, ffi.Size)> + ffi.NativeFunction< + ffi.Void Function( + ffi.Uint64, + FfiCallback, + ffi.Pointer, + ffi.Size, + ) + > >('get_channel_headers'); late final _get_channel_headers = _get_channel_headersPtr - .asFunction, int)>(); + .asFunction< + void Function(int, FfiCallback, ffi.Pointer, int) + >(); - void get_channels(int task_id, FfiCallback callback, ffi.Pointer ptr, int len) { + void get_channels( + int task_id, + FfiCallback callback, + ffi.Pointer ptr, + int len, + ) { return _get_channels(task_id, callback, ptr, len); } late final _get_channelsPtr = _lookup< - ffi.NativeFunction, ffi.Size)> + ffi.NativeFunction< + ffi.Void Function( + ffi.Uint64, + FfiCallback, + ffi.Pointer, + ffi.Size, + ) + > >('get_channels'); late final _get_channels = _get_channelsPtr - .asFunction, int)>(); + .asFunction< + void Function(int, FfiCallback, ffi.Pointer, int) + >(); void get_enabled_sources_minimal(int task_id, FfiCallback callback) { return _get_enabled_sources_minimal(task_id, callback); @@ -1716,27 +1795,55 @@ class RustLibBindings { late final _get_enabled_sources_minimal = _get_enabled_sources_minimalPtr .asFunction(); - void get_episodes(int task_id, FfiCallback callback, ffi.Pointer ptr, int len) { + void get_episodes( + int task_id, + FfiCallback callback, + ffi.Pointer ptr, + int len, + ) { return _get_episodes(task_id, callback, ptr, len); } late final _get_episodesPtr = _lookup< - ffi.NativeFunction, ffi.Size)> + ffi.NativeFunction< + ffi.Void Function( + ffi.Uint64, + FfiCallback, + ffi.Pointer, + ffi.Size, + ) + > >('get_episodes'); late final _get_episodes = _get_episodesPtr - .asFunction, int)>(); + .asFunction< + void Function(int, FfiCallback, ffi.Pointer, int) + >(); - void get_movie_position(int task_id, FfiCallback callback, ffi.Pointer ptr, int len) { + void get_movie_position( + int task_id, + FfiCallback callback, + ffi.Pointer ptr, + int len, + ) { return _get_movie_position(task_id, callback, ptr, len); } late final _get_movie_positionPtr = _lookup< - ffi.NativeFunction, ffi.Size)> + ffi.NativeFunction< + ffi.Void Function( + ffi.Uint64, + FfiCallback, + ffi.Pointer, + ffi.Size, + ) + > >('get_movie_position'); late final _get_movie_position = _get_movie_positionPtr - .asFunction, int)>(); + .asFunction< + void Function(int, FfiCallback, ffi.Pointer, int) + >(); void get_settings(int task_id, FfiCallback callback) { return _get_settings(task_id, callback); @@ -1771,27 +1878,55 @@ class RustLibBindings { late final _has_sources = _has_sourcesPtr .asFunction(); - void initialize(int task_id, FfiCallback callback, ffi.Pointer ptr, int len) { + void initialize( + int task_id, + FfiCallback callback, + ffi.Pointer ptr, + int len, + ) { return _initialize(task_id, callback, ptr, len); } late final _initializePtr = _lookup< - ffi.NativeFunction, ffi.Size)> + ffi.NativeFunction< + ffi.Void Function( + ffi.Uint64, + FfiCallback, + ffi.Pointer, + ffi.Size, + ) + > >('initialize'); late final _initialize = _initializePtr - .asFunction, int)>(); + .asFunction< + void Function(int, FfiCallback, ffi.Pointer, int) + >(); - void process_source(int task_id, FfiCallback callback, ffi.Pointer ptr, int len) { + void process_source( + int task_id, + FfiCallback callback, + ffi.Pointer ptr, + int len, + ) { return _process_source(task_id, callback, ptr, len); } late final _process_sourcePtr = _lookup< - ffi.NativeFunction, ffi.Size)> + ffi.NativeFunction< + ffi.Void Function( + ffi.Uint64, + FfiCallback, + ffi.Pointer, + ffi.Size, + ) + > >('process_source'); late final _process_source = _process_sourcePtr - .asFunction, int)>(); + .asFunction< + void Function(int, FfiCallback, ffi.Pointer, int) + >(); void refresh_all(int task_id, FfiCallback callback) { return _refresh_all(task_id, callback); @@ -1804,60 +1939,130 @@ class RustLibBindings { late final _refresh_all = _refresh_allPtr .asFunction(); - void refresh_source(int task_id, FfiCallback callback, ffi.Pointer ptr, int len) { + void refresh_source( + int task_id, + FfiCallback callback, + ffi.Pointer ptr, + int len, + ) { return _refresh_source(task_id, callback, ptr, len); } late final _refresh_sourcePtr = _lookup< - ffi.NativeFunction, ffi.Size)> + ffi.NativeFunction< + ffi.Void Function( + ffi.Uint64, + FfiCallback, + ffi.Pointer, + ffi.Size, + ) + > >('refresh_source'); late final _refresh_source = _refresh_sourcePtr - .asFunction, int)>(); + .asFunction< + void Function(int, FfiCallback, ffi.Pointer, int) + >(); - void set_movie_position(int task_id, FfiCallback callback, ffi.Pointer ptr, int len) { + void set_movie_position( + int task_id, + FfiCallback callback, + ffi.Pointer ptr, + int len, + ) { return _set_movie_position(task_id, callback, ptr, len); } late final _set_movie_positionPtr = _lookup< - ffi.NativeFunction, ffi.Size)> + ffi.NativeFunction< + ffi.Void Function( + ffi.Uint64, + FfiCallback, + ffi.Pointer, + ffi.Size, + ) + > >('set_movie_position'); late final _set_movie_position = _set_movie_positionPtr - .asFunction, int)>(); + .asFunction< + void Function(int, FfiCallback, ffi.Pointer, int) + >(); - void set_source_enabled(int task_id, FfiCallback callback, ffi.Pointer ptr, int len) { + void set_source_enabled( + int task_id, + FfiCallback callback, + ffi.Pointer ptr, + int len, + ) { return _set_source_enabled(task_id, callback, ptr, len); } late final _set_source_enabledPtr = _lookup< - ffi.NativeFunction, ffi.Size)> + ffi.NativeFunction< + ffi.Void Function( + ffi.Uint64, + FfiCallback, + ffi.Pointer, + ffi.Size, + ) + > >('set_source_enabled'); late final _set_source_enabled = _set_source_enabledPtr - .asFunction, int)>(); + .asFunction< + void Function(int, FfiCallback, ffi.Pointer, int) + >(); - void should_show_whats_new(int task_id, FfiCallback callback, ffi.Pointer ptr, int len) { + void should_show_whats_new( + int task_id, + FfiCallback callback, + ffi.Pointer ptr, + int len, + ) { return _should_show_whats_new(task_id, callback, ptr, len); } late final _should_show_whats_newPtr = _lookup< - ffi.NativeFunction, ffi.Size)> + ffi.NativeFunction< + ffi.Void Function( + ffi.Uint64, + FfiCallback, + ffi.Pointer, + ffi.Size, + ) + > >('should_show_whats_new'); late final _should_show_whats_new = _should_show_whats_newPtr - .asFunction, int)>(); + .asFunction< + void Function(int, FfiCallback, ffi.Pointer, int) + >(); - void source_name_exists(int task_id, FfiCallback callback, ffi.Pointer ptr, int len) { + void source_name_exists( + int task_id, + FfiCallback callback, + ffi.Pointer ptr, + int len, + ) { return _source_name_exists(task_id, callback, ptr, len); } late final _source_name_existsPtr = _lookup< - ffi.NativeFunction, ffi.Size)> + ffi.NativeFunction< + ffi.Void Function( + ffi.Uint64, + FfiCallback, + ffi.Pointer, + ffi.Size, + ) + > >('source_name_exists'); late final _source_name_exists = _source_name_existsPtr - .asFunction, int)>(); + .asFunction< + void Function(int, FfiCallback, ffi.Pointer, int) + >(); void update_last_seen_version( int task_id, @@ -1870,32 +2075,69 @@ class RustLibBindings { late final _update_last_seen_versionPtr = _lookup< - ffi.NativeFunction, ffi.Size)> + ffi.NativeFunction< + ffi.Void Function( + ffi.Uint64, + FfiCallback, + ffi.Pointer, + ffi.Size, + ) + > >('update_last_seen_version'); late final _update_last_seen_version = _update_last_seen_versionPtr - .asFunction, int)>(); + .asFunction< + void Function(int, FfiCallback, ffi.Pointer, int) + >(); - void update_settings(int task_id, FfiCallback callback, ffi.Pointer ptr, int len) { + void update_settings( + int task_id, + FfiCallback callback, + ffi.Pointer ptr, + int len, + ) { return _update_settings(task_id, callback, ptr, len); } late final _update_settingsPtr = _lookup< - ffi.NativeFunction, ffi.Size)> + ffi.NativeFunction< + ffi.Void Function( + ffi.Uint64, + FfiCallback, + ffi.Pointer, + ffi.Size, + ) + > >('update_settings'); late final _update_settings = _update_settingsPtr - .asFunction, int)>(); + .asFunction< + void Function(int, FfiCallback, ffi.Pointer, int) + >(); - void update_source(int task_id, FfiCallback callback, ffi.Pointer ptr, int len) { + void update_source( + int task_id, + FfiCallback callback, + ffi.Pointer ptr, + int len, + ) { return _update_source(task_id, callback, ptr, len); } late final _update_sourcePtr = _lookup< - ffi.NativeFunction, ffi.Size)> + ffi.NativeFunction< + ffi.Void Function( + ffi.Uint64, + FfiCallback, + ffi.Pointer, + ffi.Size, + ) + > >('update_source'); late final _update_source = _update_sourcePtr - .asFunction, int)>(); + .asFunction< + void Function(int, FfiCallback, ffi.Pointer, int) + >(); } typedef ptrdiff_t = ffi.Long; @@ -2405,7 +2647,6 @@ typedef __compar_fn_tFunction = typedef Dart__compar_fn_tFunction = int Function(ffi.Pointer, ffi.Pointer); typedef __compar_fn_t = ffi.Pointer>; - typedef FfiCallbackFunction = ffi.Void Function( ffi.Uint64 task_id, @@ -2814,8 +3055,6 @@ const int __have_pthread_attr_t = 1; const int _ALLOCA_H = 1; -const int ALL = 0; - const int ALPHABETICAL_ASC = 0; const int ALPHABETICAL_DESC = 1; @@ -2842,6 +3081,4 @@ const int SEASON = 4; const int SERIE = 2; -const int SETTINGS = 4; - const int XTREAM = 2; diff --git a/flutter/lib/generated/generated_proto.pb.dart b/flutter/lib/generated/generated_proto.pb.dart index 006bb5f..3b3feb5 100644 --- a/flutter/lib/generated/generated_proto.pb.dart +++ b/flutter/lib/generated/generated_proto.pb.dart @@ -1588,6 +1588,58 @@ class SetSourceEnabled extends $pb.GeneratedMessage { void clearEnabled() => $_clearField(2); } +class Expiries extends $pb.GeneratedMessage { + factory Expiries({ + $core.Iterable<$core.MapEntry<$fixnum.Int64, $fixnum.Int64>>? expiries, + }) { + final result = create(); + if (expiries != null) result.expiries.addEntries(expiries); + return result; + } + + Expiries._(); + + factory Expiries.fromBuffer($core.List<$core.int> data, + [$pb.ExtensionRegistry registry = $pb.ExtensionRegistry.EMPTY]) => + create()..mergeFromBuffer(data, registry); + factory Expiries.fromJson($core.String json, + [$pb.ExtensionRegistry registry = $pb.ExtensionRegistry.EMPTY]) => + create()..mergeFromJson(json, registry); + + static final $pb.BuilderInfo _i = $pb.BuilderInfo( + _omitMessageNames ? '' : 'Expiries', + package: + const $pb.PackageName(_omitMessageNames ? '' : 'generated_proto'), + createEmptyInstance: create) + ..m<$fixnum.Int64, $fixnum.Int64>(1, _omitFieldNames ? '' : 'expiries', + entryClassName: 'Expiries.ExpiriesEntry', + keyFieldType: $pb.PbFieldType.O6, + valueFieldType: $pb.PbFieldType.O6, + packageName: const $pb.PackageName('generated_proto')) + ..hasRequiredFields = false; + + @$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.') + Expiries clone() => deepCopy(); + @$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.') + Expiries copyWith(void Function(Expiries) updates) => + super.copyWith((message) => updates(message as Expiries)) as Expiries; + + @$core.override + $pb.BuilderInfo get info_ => _i; + + @$core.pragma('dart2js:noInline') + static Expiries create() => Expiries._(); + @$core.override + Expiries createEmptyInstance() => create(); + @$core.pragma('dart2js:noInline') + static Expiries getDefault() => + _defaultInstance ??= $pb.GeneratedMessage.$_defaultFor(create); + static Expiries? _defaultInstance; + + @$pb.TagNumber(1) + $pb.PbMap<$fixnum.Int64, $fixnum.Int64> get expiries => $_getMap(0); +} + enum FFIResult_Data { settings, source, @@ -1597,6 +1649,7 @@ enum FFIResult_Data { headers, enabledSourcesMinimal, sourceList, + expiries, notSet } @@ -1612,6 +1665,7 @@ class FFIResult extends $pb.GeneratedMessage { ChannelHttpHeaders? headers, GetEnabledSourcesMinimal? enabledSourcesMinimal, SourceList? sourceList, + Expiries? expiries, }) { final result = create(); if (success != null) result.success = success; @@ -1625,6 +1679,7 @@ class FFIResult extends $pb.GeneratedMessage { if (enabledSourcesMinimal != null) result.enabledSourcesMinimal = enabledSourcesMinimal; if (sourceList != null) result.sourceList = sourceList; + if (expiries != null) result.expiries = expiries; return result; } @@ -1646,6 +1701,7 @@ class FFIResult extends $pb.GeneratedMessage { 9: FFIResult_Data.headers, 10: FFIResult_Data.enabledSourcesMinimal, 11: FFIResult_Data.sourceList, + 12: FFIResult_Data.expiries, 0: FFIResult_Data.notSet }; static final $pb.BuilderInfo _i = $pb.BuilderInfo( @@ -1653,7 +1709,7 @@ class FFIResult extends $pb.GeneratedMessage { package: const $pb.PackageName(_omitMessageNames ? '' : 'generated_proto'), createEmptyInstance: create) - ..oo(0, [3, 4, 6, 7, 8, 9, 10, 11]) + ..oo(0, [3, 4, 6, 7, 8, 9, 10, 11, 12]) ..aOB(1, _omitFieldNames ? '' : 'success') ..aOS(2, _omitFieldNames ? '' : 'errorMessage') ..aOM(3, _omitFieldNames ? '' : 'settings', @@ -1672,6 +1728,8 @@ class FFIResult extends $pb.GeneratedMessage { subBuilder: GetEnabledSourcesMinimal.create) ..aOM(11, _omitFieldNames ? '' : 'sourceList', subBuilder: SourceList.create) + ..aOM(12, _omitFieldNames ? '' : 'expiries', + subBuilder: Expiries.create) ..hasRequiredFields = false; @$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.') @@ -1700,6 +1758,7 @@ class FFIResult extends $pb.GeneratedMessage { @$pb.TagNumber(9) @$pb.TagNumber(10) @$pb.TagNumber(11) + @$pb.TagNumber(12) FFIResult_Data whichData() => _FFIResult_DataByTag[$_whichOneof(0)]!; @$pb.TagNumber(3) @$pb.TagNumber(4) @@ -1709,6 +1768,7 @@ class FFIResult extends $pb.GeneratedMessage { @$pb.TagNumber(9) @$pb.TagNumber(10) @$pb.TagNumber(11) + @$pb.TagNumber(12) void clearData() => $_clearField($_whichOneof(0)); @$pb.TagNumber(1) @@ -1817,6 +1877,17 @@ class FFIResult extends $pb.GeneratedMessage { void clearSourceList() => $_clearField(11); @$pb.TagNumber(11) SourceList ensureSourceList() => $_ensure(9); + + @$pb.TagNumber(12) + Expiries get expiries => $_getN(10); + @$pb.TagNumber(12) + set expiries(Expiries value) => $_setField(12, value); + @$pb.TagNumber(12) + $core.bool hasExpiries() => $_has(10); + @$pb.TagNumber(12) + void clearExpiries() => $_clearField(12); + @$pb.TagNumber(12) + Expiries ensureExpiries() => $_ensure(10); } const $core.bool _omitFieldNames = diff --git a/flutter/lib/generated/generated_proto.pbjson.dart b/flutter/lib/generated/generated_proto.pbjson.dart index ad91a06..d643d73 100644 --- a/flutter/lib/generated/generated_proto.pbjson.dart +++ b/flutter/lib/generated/generated_proto.pbjson.dart @@ -698,6 +698,38 @@ final $typed_data.Uint8List setSourceEnabledDescriptor = $convert.base64Decode( 'ChBTZXRTb3VyY2VFbmFibGVkEhsKCXNvdXJjZV9pZBgBIAEoA1IIc291cmNlSWQSGAoHZW5hYm' 'xlZBgCIAEoCFIHZW5hYmxlZA=='); +@$core.Deprecated('Use expiriesDescriptor instead') +const Expiries$json = { + '1': 'Expiries', + '2': [ + { + '1': 'expiries', + '3': 1, + '4': 3, + '5': 11, + '6': '.generated_proto.Expiries.ExpiriesEntry', + '10': 'expiries' + }, + ], + '3': [Expiries_ExpiriesEntry$json], +}; + +@$core.Deprecated('Use expiriesDescriptor instead') +const Expiries_ExpiriesEntry$json = { + '1': 'ExpiriesEntry', + '2': [ + {'1': 'key', '3': 1, '4': 1, '5': 3, '10': 'key'}, + {'1': 'value', '3': 2, '4': 1, '5': 3, '10': 'value'}, + ], + '7': {'7': true}, +}; + +/// Descriptor for `Expiries`. Decode as a `google.protobuf.DescriptorProto`. +final $typed_data.Uint8List expiriesDescriptor = $convert.base64Decode( + 'CghFeHBpcmllcxJDCghleHBpcmllcxgBIAMoCzInLmdlbmVyYXRlZF9wcm90by5FeHBpcmllcy' + '5FeHBpcmllc0VudHJ5UghleHBpcmllcxo7Cg1FeHBpcmllc0VudHJ5EhAKA2tleRgBIAEoA1ID' + 'a2V5EhQKBXZhbHVlGAIgASgDUgV2YWx1ZToCOAE='); + @$core.Deprecated('Use fFIResultDescriptor instead') const FFIResult$json = { '1': 'FFIResult', @@ -784,6 +816,15 @@ const FFIResult$json = { '9': 0, '10': 'sourceList' }, + { + '1': 'expiries', + '3': 12, + '4': 1, + '5': 11, + '6': '.generated_proto.Expiries', + '9': 0, + '10': 'expiries' + }, ], '8': [ {'1': 'data'}, @@ -804,4 +845,5 @@ final $typed_data.Uint8List fFIResultDescriptor = $convert.base64Decode( 'SGVhZGVyc0gAUgdoZWFkZXJzEmMKF2VuYWJsZWRfc291cmNlc19taW5pbWFsGAogASgLMikuZ2' 'VuZXJhdGVkX3Byb3RvLkdldEVuYWJsZWRTb3VyY2VzTWluaW1hbEgAUhVlbmFibGVkU291cmNl' 'c01pbmltYWwSPgoLc291cmNlX2xpc3QYCyABKAsyGy5nZW5lcmF0ZWRfcHJvdG8uU291cmNlTG' - 'lzdEgAUgpzb3VyY2VMaXN0QgYKBGRhdGFCEAoOX2Vycm9yX21lc3NhZ2U='); + 'lzdEgAUgpzb3VyY2VMaXN0EjcKCGV4cGlyaWVzGAwgASgLMhkuZ2VuZXJhdGVkX3Byb3RvLkV4' + 'cGlyaWVzSABSCGV4cGlyaWVzQgYKBGRhdGFCEAoOX2Vycm9yX21lc3NhZ2U='); diff --git a/flutter/lib/home.dart b/flutter/lib/home.dart index 65e5402..8e9cec3 100644 --- a/flutter/lib/home.dart +++ b/flutter/lib/home.dart @@ -21,13 +21,11 @@ import 'package:open_tv/utils.dart'; class Home extends StatefulWidget { final HomeManager home; - final bool refresh; final bool firstLaunch; final bool tvMode; const Home({ super.key, required this.home, - this.refresh = false, this.firstLaunch = false, this.tvMode = false, }); @@ -47,7 +45,6 @@ class _HomeState extends State { final ScrollController _scrollController = ScrollController(); int currentlyFocusedChannel = 0; bool isLoading = false; - bool blockSettings = false; int? previousScroll; bool scrolledDeepEnough = false; @@ -66,31 +63,18 @@ class _HomeState extends State { final sources = await NativeBridge.instance.getEnabledSourcesMinimal(); widget.home.filters.sourceIds = sources; } - if (widget.home.filters.mediaTypes == null) { + if (widget.home.filters.mediaTypes == null || + widget.home.filters.sort == null) { final settings = await NativeBridge.instance.getSettings(); - widget.home.filters.mediaTypes = settings.getMediaTypes(); - widget.home.filters.sort = settings.defaultSort; + widget.home.filters.mediaTypes ??= settings.getMediaTypes(); + widget.home.filters.sort ??= settings.defaultSort; } + await load(); if (!mounted) return; if (widget.firstLaunch) { - await Utils.maybeShowWhatsNew(context); - } - if (!mounted) return; - if (widget.refresh) { - Error.tryAsyncNoLoading( - () async { - setState(() { - blockSettings = true; - }); - await NativeBridge.instance.refreshAll(); - }, - context, - true, - "Refreshed all sources", - ); - setState(() { - blockSettings = false; + WidgetsBinding.instance.addPostFrameCallback((_) { + Utils.maybeShowWhatsNew(context); }); } } @@ -111,6 +95,7 @@ class _HomeState extends State { Navigator.of(context).pop(); load(false); }, + previouslySelectedId: widget.home.filters.sort?.index, ), ); } @@ -143,7 +128,7 @@ class _HomeState extends State { }); } reachedMax = channels.length < pageSize; - }, context); + }); } @override @@ -288,6 +273,7 @@ class _HomeState extends State { viewType: type, mediaTypes: widget.home.filters.mediaTypes, sourceIds: widget.home.filters.sourceIds, + sort: widget.home.filters.sort, ), ), ), @@ -303,6 +289,7 @@ class _HomeState extends State { viewType: ViewType.all, mediaTypes: widget.home.filters.mediaTypes, sourceIds: widget.home.filters.sourceIds, + sort: widget.home.filters.sort, ), ); if (widget.home.filters.groupId != null) { @@ -317,8 +304,7 @@ class _HomeState extends State { } Navigator.of(context).push( NoPushAnimationMaterialPageRoute( - builder: (context) => - Home(home: home, tvMode: widget.tvMode), + builder: (context) => Home(home: home, tvMode: widget.tvMode), ), ); } @@ -402,7 +388,15 @@ class _HomeState extends State { IconButton( focusNode: _sortFocusNode, onPressed: showSortDialog, - icon: const Icon(Icons.sort), + icon: + widget.home.filters.sort == null || + widget.home.filters.sort == + SortType.provider + ? const Icon(Icons.sort) + : widget.home.filters.sort == + SortType.alphabeticalAsc + ? const Icon(Icons.arrow_upward) + : const Icon(Icons.arrow_downward), ), ], ), @@ -442,7 +436,6 @@ class _HomeState extends State { bottomNavigationBar: !widget.tvMode ? BottomNav( startingView: getStartingView(), - blockSettings: blockSettings, updateViewMode: updateViewMode, tvMode: widget.tvMode, ) diff --git a/flutter/lib/main.dart b/flutter/lib/main.dart index a4519da..3e65d0f 100644 --- a/flutter/lib/main.dart +++ b/flutter/lib/main.dart @@ -3,7 +3,10 @@ import 'dart:io'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import 'package:open_tv/app_keys.dart'; import 'package:open_tv/generated/generated_proto.pb.dart' as gen; +import 'package:open_tv/task_banner.dart'; +import 'package:open_tv/task_service.dart'; import 'package:open_tv/home.dart'; import 'package:open_tv/models/custom_shortcut.dart'; import 'package:open_tv/models/device_detector.dart'; @@ -50,6 +53,9 @@ Future main() async { isTV: isTV, ), ); + if (hasSources && settings.refreshOnStart) { + TaskService.instance.refreshAll(); + } } class MyApp extends StatelessWidget { @@ -57,8 +63,6 @@ class MyApp extends StatelessWidget { final Settings settings; final bool hasTouchScreen; final bool isTV; - static final GlobalKey navigatorKey = - GlobalKey(); const MyApp({ super.key, @@ -90,6 +94,7 @@ class MyApp extends StatelessWidget { return MaterialApp( title: 'Fred TV', navigatorKey: navigatorKey, + scaffoldMessengerKey: scaffoldMessengerKey, builder: (context, child) { return CallbackShortcuts( bindings: { @@ -106,7 +111,12 @@ class MyApp extends StatelessWidget { navigatorKey.currentState?.maybePop(); }, }, - child: child ?? const SizedBox.shrink(), + child: Stack( + children: [ + child ?? const SizedBox.shrink(), + TaskBanner(hasBottomNav: !_isTvMode), + ], + ), ); }, theme: ThemeData( @@ -176,10 +186,9 @@ class MyApp extends StatelessWidget { debugShowCheckedModeBanner: false, home: skipSetup ? (_isTvMode - ? const TvHome() + ? const TvHome(firstLaunch: true) : Home( firstLaunch: true, - refresh: settings.refreshOnStart, home: HomeManager( filters: Filters(viewType: settings.defaultView), ), diff --git a/flutter/lib/models/filters.dart b/flutter/lib/models/filters.dart index 6f530f1..07f59b5 100644 --- a/flutter/lib/models/filters.dart +++ b/flutter/lib/models/filters.dart @@ -12,7 +12,7 @@ class Filters { int? seasonId; int? groupId; bool useKeywords; - SortType sort; + SortType? sort; Filters({ this.query, @@ -24,6 +24,6 @@ class Filters { this.seasonId, this.groupId, this.useKeywords = false, - this.sort = SortType.provider, + this.sort, }); } diff --git a/flutter/lib/models/id_data.dart b/flutter/lib/models/id_data.dart index f3fd7ce..65d2867 100644 --- a/flutter/lib/models/id_data.dart +++ b/flutter/lib/models/id_data.dart @@ -1,6 +1,9 @@ +import 'package:flutter/widgets.dart'; + class IdData { int id; T data; + IconData? icon; - IdData({required this.id, required this.data}); + IdData({required this.id, required this.data, this.icon}); } diff --git a/flutter/lib/models/source.dart b/flutter/lib/models/source.dart index 041602a..336297f 100644 --- a/flutter/lib/models/source.dart +++ b/flutter/lib/models/source.dart @@ -9,6 +9,7 @@ class Source { String? password; SourceType sourceType; bool enabled; + int? lastUpdated; Source({ this.id, @@ -19,5 +20,6 @@ class Source { this.password, required this.sourceType, this.enabled = true, + this.lastUpdated, }); } diff --git a/flutter/lib/native_bridge.dart b/flutter/lib/native_bridge.dart index 67d30e4..6d324b0 100644 --- a/flutter/lib/native_bridge.dart +++ b/flutter/lib/native_bridge.dart @@ -12,7 +12,7 @@ import 'package:open_tv/models/channel.dart'; import 'package:open_tv/models/source.dart'; import 'package:open_tv/models/filters.dart'; import 'package:open_tv/models/settings.dart'; -import 'package:open_tv/models/proto_extensions.dart'; +import 'package:open_tv/extensions/proto_extensions.dart'; import 'package:open_tv/models/channel_http_headers.dart'; class NativeBridge { @@ -21,7 +21,8 @@ class NativeBridge { final ffi.RustLibBindings _bindings; int _nextTaskId = 0; final Map> _pendingRequests = {}; - late final NativeCallable, Size)> _globalCallback; + late final NativeCallable, Size)> + _globalCallback; static NativeBridge get instance => _instance ??= NativeBridge._internal( ffi.RustLibBindings(_openDynamicLibrary()), @@ -41,26 +42,29 @@ class NativeBridge { } NativeBridge._internal(this._bindings) { - _globalCallback = NativeCallable, Size)>.listener( - (int taskId, Pointer ptr, int len) { - final completer = _pendingRequests.remove(taskId); - if (completer == null) return; + _globalCallback = + NativeCallable, Size)>.listener(( + int taskId, + Pointer ptr, + int len, + ) { + final completer = _pendingRequests.remove(taskId); + if (completer == null) return; - try { - final Uint8List copiedBytes; try { - final u8List = ptr.asTypedList(len); - copiedBytes = Uint8List.fromList(u8List); - } finally { - _bindings.free_message(ptr, len); + final Uint8List copiedBytes; + try { + final u8List = ptr.asTypedList(len); + copiedBytes = Uint8List.fromList(u8List); + } finally { + _bindings.free_message(ptr, len); + } + final result = pb.FFIResult.fromBuffer(copiedBytes); + completer.complete(result); + } catch (e, stackTrace) { + completer.completeError(e, stackTrace); } - final result = pb.FFIResult.fromBuffer(copiedBytes); - completer.complete(result); - } catch (e, stackTrace) { - completer.completeError(e, stackTrace); - } - }, - ); + }); } Future _executeAsync( @@ -72,14 +76,21 @@ class NativeBridge { ffiAction(taskId, _globalCallback.nativeFunction); final result = await completer.future; if (!result.success) { - throw Exception(result.hasErrorMessage() ? result.errorMessage : "Unknown FFI error"); + throw Exception( + result.hasErrorMessage() ? result.errorMessage : "Unknown FFI error", + ); } return result; } Future _executeWithMsg( T request, - void Function(int taskId, Pointer ptr, int len, ffi.FfiCallback callback) + void Function( + int taskId, + Pointer ptr, + int len, + ffi.FfiCallback callback, + ) ffiAction, ) async { final pbBytes = request.writeToBuffer(); @@ -202,7 +213,12 @@ class NativeBridge { } Future getMoviePosition(int id) async { - final result = await _executeWithMsg(pb.IdMessage(value: Int64(id)), (id, ptr, len, cb) { + final result = await _executeWithMsg(pb.IdMessage(value: Int64(id)), ( + id, + ptr, + len, + cb, + ) { _bindings.get_movie_position(id, cb, ptr, len); }); return result.hasMoviePosition() && result.moviePosition.hasPosition() @@ -211,13 +227,22 @@ class NativeBridge { } Future getChannelHeaders(int id) async { - final result = await _executeWithMsg(pb.IdMessage(value: Int64(id)), (id, ptr, len, cb) { + final result = await _executeWithMsg(pb.IdMessage(value: Int64(id)), ( + id, + ptr, + len, + cb, + ) { _bindings.get_channel_headers(id, cb, ptr, len); }); return result.hasHeaders() ? result.headers.toDomain() : null; } - Future getEpisodes(int seriesId, int sourceId, String? fallbackImage) async { + Future getEpisodes( + int seriesId, + int sourceId, + String? fallbackImage, + ) async { final msg = pb.GetEpisodes( seriesId: Int64(seriesId), sourceId: Int64(sourceId), @@ -241,16 +266,24 @@ class NativeBridge { } Future sourceNameExists(String name) async { - final result = await _executeWithMsg(pb.StrMessage(value: name), (id, ptr, len, cb) { + final result = await _executeWithMsg(pb.StrMessage(value: name), ( + id, + ptr, + len, + cb, + ) { _bindings.source_name_exists(id, cb, ptr, len); }); return result.boolMessage.value; } Future shouldShowWhatsNew(String? currentVersion) async { - final result = await _executeWithMsg(pb.OptStrMessage(value: currentVersion), (id, ptr, len, cb) { - _bindings.should_show_whats_new(id, cb, ptr, len); - }); + final result = await _executeWithMsg( + pb.OptStrMessage(value: currentVersion), + (id, ptr, len, cb) { + _bindings.should_show_whats_new(id, cb, ptr, len); + }, + ); return result.boolMessage.value; } @@ -260,6 +293,15 @@ class NativeBridge { }); } + Future> getAllExpiries() async { + final result = await _executeAsync((id, cb) { + _bindings.get_all_expiries(id, cb); + }); + return result.expiries.expiries.map( + (key, value) => MapEntry(key.toInt(), value.toInt()), + ); + } + void dispose() { _globalCallback.close(); for (final completer in _pendingRequests.values) { diff --git a/flutter/lib/player.dart b/flutter/lib/player.dart index 88f2e76..b207a48 100644 --- a/flutter/lib/player.dart +++ b/flutter/lib/player.dart @@ -11,6 +11,7 @@ import 'package:media_kit/media_kit.dart' as mk; import 'package:media_kit_video/media_kit_video.dart' as mkvideo; import 'package:open_tv/models/settings.dart'; import 'package:open_tv/native_bridge.dart'; +import 'package:open_tv/task_service.dart'; import 'package:open_tv/select_dialog.dart'; import 'package:open_tv/error.dart'; @@ -47,7 +48,7 @@ class _PlayerState extends State { return await NativeBridge.instance.getMoviePosition( widget.channel.id!, ); - }, context)).data + })).data : null; await _startPlayback(seconds != null ? Duration(seconds: seconds) : null); subscriptions.add( @@ -86,7 +87,7 @@ class _PlayerState extends State { return await NativeBridge.instance.getChannelHeaders( widget.channel.id!, ); - }, context)).data; + })).data; await player.open( mk.Media( widget.channel.url!, @@ -142,6 +143,9 @@ class _PlayerState extends State { ), ) .toList(), + previouslySelectedId: player.state.tracks.subtitle.indexOf( + player.state.track.subtitle, + ), ), ); } @@ -167,6 +171,9 @@ class _PlayerState extends State { ), ) .toList(), + previouslySelectedId: player.state.tracks.audio.indexOf( + player.state.track.audio, + ), ), ); } @@ -190,7 +197,7 @@ class _PlayerState extends State { key: key, controller: videoController, onExitFullscreen: (Platform.isAndroid || Platform.isIOS) - ? () async => onExit() + ? onExit : defaultExitNativeFullscreen, controls: AdaptiveVideoControls, ), @@ -200,11 +207,11 @@ class _PlayerState extends State { ); } - void onExit() async { + Future onExit() async { if (exiting) return; exiting = true; if (widget.channel.mediaType == MediaType.movie) { - NativeBridge.instance.setMoviePosition( + TaskService.instance.setMoviePosition( widget.channel.id!, player.state.position.inSeconds, ); @@ -274,16 +281,15 @@ class _PlayerState extends State { ), if (!(Platform.isAndroid || Platform.isIOS)) ...[ const Spacer(), - const MaterialFullscreenButton( - iconSize: 32, - iconColor: Colors.white, - ), + const MaterialFullscreenButton(iconSize: 32, iconColor: Colors.white), ], ], ); } - MaterialDesktopVideoControlsThemeData getDesktopThemeData(BuildContext context) { + MaterialDesktopVideoControlsThemeData getDesktopThemeData( + BuildContext context, + ) { return MaterialDesktopVideoControlsThemeData( seekBarMargin: const EdgeInsets.only(bottom: 60), seekBarThumbSize: 20, diff --git a/flutter/lib/select_dialog.dart b/flutter/lib/select_dialog.dart index 1ff2439..8bf1e0f 100644 --- a/flutter/lib/select_dialog.dart +++ b/flutter/lib/select_dialog.dart @@ -2,28 +2,27 @@ import 'package:flutter/material.dart'; import 'package:open_tv/models/id_data.dart'; class SelectDialog extends StatelessWidget { - const SelectDialog( - {super.key, - required this.action, - required this.data, - required this.title}); + const SelectDialog({ + super.key, + required this.action, + required this.data, + required this.title, + this.previouslySelectedId, + }); final Function(int id) action; final List> data; final String title; + final int? previouslySelectedId; @override Widget build(BuildContext context) { - return AlertDialog( + return SimpleDialog( title: Text(title), - content: SingleChildScrollView( - child: Column( - mainAxisSize: MainAxisSize.min, - children: data - .asMap() - .entries - .map((entry) => getItem(entry.value, entry.key == 0)) - .toList(), - )), + children: data + .asMap() + .entries + .map((entry) => getItem(entry.value, entry.key == 0)) + .toList(), ); } @@ -31,6 +30,9 @@ class SelectDialog extends StatelessWidget { return ListTile( autofocus: autofocus, title: Text(item.data), + trailing: previouslySelectedId == null + ? (item.icon != null ? Icon(item.icon) : null) + : (item.id == previouslySelectedId ? const Icon(Icons.check) : null), onTap: () => action(item.id), ); } diff --git a/flutter/lib/settings_view.dart b/flutter/lib/settings_view.dart index b3d1968..3cba233 100644 --- a/flutter/lib/settings_view.dart +++ b/flutter/lib/settings_view.dart @@ -1,7 +1,9 @@ import 'package:flutter/material.dart'; +import 'package:open_tv/extensions/int_extensions.dart'; import 'package:open_tv/native_bridge.dart'; import 'package:open_tv/bottom_nav.dart'; import 'package:open_tv/confirm_delete.dart'; +import 'package:open_tv/donate_view.dart'; import 'package:open_tv/models/filters.dart'; import 'package:open_tv/select_dialog.dart'; import 'package:open_tv/edit_dialog.dart'; @@ -15,12 +17,11 @@ import 'package:open_tv/models/source_type.dart'; import 'package:open_tv/models/sort_type.dart'; import 'package:open_tv/models/view_type.dart'; import 'package:open_tv/error.dart'; +import 'package:open_tv/task_service.dart'; import 'package:open_tv/setup.dart'; -import 'package:url_launcher/url_launcher.dart'; class SettingsView extends StatefulWidget { final bool tvMode; - const SettingsView({super.key, this.tvMode = false}); @override @@ -31,17 +32,33 @@ class _SettingsState extends State { Settings settings = Settings(); List sources = []; bool loading = true; + Future> expiriesFuture = NativeBridge.instance + .getAllExpiries() + .catchError((_) => {}); + @override void initState() { super.initState(); + TaskService.instance.runningTask.addListener(reloadAfterTask); initAsync(); } + @override + void dispose() { + TaskService.instance.runningTask.removeListener(reloadAfterTask); + super.dispose(); + } + + void reloadAfterTask() { + if (!TaskService.instance.busy) reloadSources(); + } + Future initAsync() async { var results = await Future.wait([ NativeBridge.instance.getSettings(), NativeBridge.instance.getSources(), ]); + if (!mounted) return; setState(() { settings = results[0] as Settings; sources = results[1] as List; @@ -98,6 +115,7 @@ class _SettingsState extends State { }); Navigator.of(context).pop(); }, + previouslySelectedId: settings.defaultView.index, ); }, ); @@ -120,6 +138,7 @@ class _SettingsState extends State { }); Navigator.of(context).pop(); }, + previouslySelectedId: settings.defaultSort.index, ); }, ); @@ -127,73 +146,108 @@ class _SettingsState extends State { Future toggleSource(Source source) async { await Error.tryAsyncNoLoading( - () async => await NativeBridge.instance.setSourceEnabled( - source.id!, - !source.enabled, - ), - context, + () => NativeBridge.instance.setSourceEnabled(source.id!, !source.enabled), ); await reloadSources(); if (!mounted) return; - ScaffoldMessenger.of(context).showSnackBar( + Error.showSnackBar( SnackBar( + persist: false, content: Text("Source ${!source.enabled ? "enabled" : "disabled"}"), duration: const Duration(milliseconds: 500), ), ); } - Widget getSource(Source source) { - return Card( - margin: const EdgeInsets.symmetric( - horizontal: 10, - vertical: 5, - ), // Spacing around the tile - elevation: 5, - child: ListTile( - leading: Icon(source.enabled ? Icons.tv : Icons.tv_off), - horizontalTitleGap: 25, - onLongPress: () => toggleSource(source), - contentPadding: const EdgeInsets.only(left: 20), - title: Text(source.name), - subtitle: Text(source.sourceType.label), - trailing: Row( - mainAxisSize: - MainAxisSize.min, // Ensures the row takes up minimal space - children: [ - Offstage( - offstage: source.sourceType == SourceType.m3u, - child: IconButton( - icon: const Icon(Icons.refresh), - onPressed: () async { - await Error.tryAsync( - () async { - await NativeBridge.instance.refreshSource(source); - }, - context, - "Source has been refreshed successfully", - ); - }, - ), - ), - Offstage( - offstage: - source.sourceType == SourceType.m3u || widget.tvMode, - child: IconButton( - icon: const Icon(Icons.edit), - onPressed: () async => await showEditDialog(context, source), - ), - ), - IconButton( - icon: const Icon(Icons.delete), - onPressed: () async => await showConfirmDeleteDialog(source), - ), - ], + Widget getSource(Source source, bool busy) { + final subtitleStyle = Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ); + return Opacity( + opacity: busy ? 0.4 : 1, + child: Card( + margin: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), + elevation: 5, + child: ListTile( + leading: Icon(source.enabled ? Icons.tv : Icons.tv_off), + horizontalTitleGap: 25, + contentPadding: const EdgeInsets.symmetric(horizontal: 20), + title: Text(source.name), + subtitle: Text(source.sourceType.label), + onTap: () => busy + ? TaskService.instance.notifyBusy() + : showSourceActions(source), + trailing: Column( + crossAxisAlignment: CrossAxisAlignment.end, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + if (source.lastUpdated != null) + Text( + "Last updated: ${source.lastUpdated!.toTimeAgo()}", + style: subtitleStyle, + ), + if (source.sourceType == SourceType.xtream) + FutureBuilder>( + future: expiriesFuture, + builder: (context, snapshot) { + if (snapshot.hasData && + snapshot.data!.containsKey(source.id)) { + return Text( + "Expires: ${snapshot.data![source.id]!.toTimeUntil()}", + style: subtitleStyle, + ); + } + return const SizedBox.shrink(); + }, + ), + ], + ), ), ), ); } + Future refreshSource(Source source) => + TaskService.instance.refreshSource(source); + + Future showSourceActions(Source source) async { + final actions = >[ + IdData( + id: 0, + data: source.enabled ? "Disable" : "Enable", + icon: source.enabled ? Icons.tv_off : Icons.tv, + ), + if (source.sourceType != SourceType.m3u) + IdData(id: 1, data: "Refresh", icon: Icons.refresh), + if (!widget.tvMode) IdData(id: 2, data: "Edit", icon: Icons.edit), + IdData(id: 3, data: "Delete", icon: Icons.delete), + ]; + final name = source.name.length > 20 + ? "${source.name.substring(0, 20)}…" + : source.name; + await showDialog( + barrierDismissible: true, + context: context, + builder: (context) => SelectDialog( + title: "Select action for $name", + data: actions, + action: (id) { + Navigator.of(context).pop(); + switch (id) { + case 0: + toggleSource(source); + case 1: + refreshSource(source); + case 2: + showEditDialog(context, source); + case 3: + showConfirmDeleteDialog(source); + } + }, + ), + ); + } + Future showConfirmDeleteDialog(Source source) async { await showDialog( barrierDismissible: true, @@ -202,11 +256,7 @@ class _SettingsState extends State { type: "source", name: source.name, confirm: () async { - await Error.tryAsync( - () async => await NativeBridge.instance.deleteSource(source.id!), - context, - "Successfully deleted source", - ); + await TaskService.instance.deleteSource(source.id!); await reloadSources(); if (!mounted) return; if (sources.isEmpty) { @@ -226,8 +276,8 @@ class _SettingsState extends State { Future reloadSources() async { await Error.tryAsyncNoLoading( () async => sources = await NativeBridge.instance.getSources(), - context, ); + if (!mounted) return; setState(() { sources; }); @@ -235,214 +285,239 @@ class _SettingsState extends State { Future updateSettings() async { await Error.tryAsyncNoLoading( - () async => await NativeBridge.instance.updateSettings(settings), - context, + () => NativeBridge.instance.updateSettings(settings), ); } @override Widget build(BuildContext context) { - return Scaffold( - body: Visibility( - visible: !loading, - child: Loading( - child: SafeArea( - child: Padding( - padding: const EdgeInsetsDirectional.symmetric(vertical: 10), - child: ListView( - children: [ - const SizedBox(height: 10), - const Padding( - padding: EdgeInsets.only(left: 10), - child: Text( - 'Settings', - style: TextStyle( - fontSize: 30, - fontWeight: FontWeight.bold, - ), - ), - ), - const SizedBox(height: 10), - ListTile( - title: const Text("Donate"), - subtitle: const Text( - "Fred TV needs your help! Consider donating ❤️", - ), - onTap: () async => await launchUrl( - Uri.parse( - "https://github.com/Fredolx/fred-tv-mobile/discussions/1", - ), - mode: LaunchMode.externalApplication, + return ValueListenableBuilder( + valueListenable: TaskService.instance.runningTask, + builder: (context, task, _) { + final busy = task != null; + return PopScope( + canPop: !TaskService.instance.isDeletingSource, + child: Scaffold( + body: Visibility( + visible: !loading, + child: Loading( + child: SafeArea( + child: Padding( + padding: const EdgeInsetsDirectional.symmetric( + vertical: 10, ), - ), - ListTile( - title: const Text("Default view"), - subtitle: Text(viewTypeToString(settings.defaultView)), - onTap: () async => await _showDefaultViewDialog(context), - ), - ListTile( - title: const Text("Default sort"), - subtitle: Text(sortTypeToString(settings.defaultSort)), - onTap: () async => await _showDefaultSortDialog(context), - ), - ListTile( - title: const Text("Force TV Mode"), - trailing: Row( - mainAxisSize: MainAxisSize.min, + child: ListView( children: [ - Switch( - value: settings.forceTVMode, - onChanged: (bool value) { - setState(() { - settings.forceTVMode = value; - }); - updateSettings(); - }, - ), - ], - ), - ), - ListTile( - title: const Text("Low latency livestreams"), - trailing: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Switch( - value: settings.lowLatency, - onChanged: (bool value) { - setState(() { - settings.lowLatency = value; - }); - updateSettings(); - }, - ), - ], - ), - ), - ListTile( - title: const Text("Refresh sources on start"), - trailing: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Switch( - value: settings.refreshOnStart, - onChanged: (bool value) { - setState(() { - settings.refreshOnStart = value; - }); - updateSettings(); - }, + const SizedBox(height: 10), + const Padding( + padding: EdgeInsets.only(left: 10), + child: Text( + 'Settings', + style: TextStyle( + fontSize: 30, + fontWeight: FontWeight.bold, + ), + ), ), - ], - ), - ), - ListTile( - title: const Text("Show livestreams"), - trailing: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Switch( - value: settings.showLivestreams, - onChanged: (bool value) { - setState(() { - settings.showLivestreams = value; - }); - updateSettings(); - }, + const SizedBox(height: 10), + ListTile( + title: const Text("Donate"), + subtitle: const Text( + "Fred TV needs your help! Consider donating ❤️", + ), + onTap: () => Navigator.push( + context, + MaterialPageRoute( + builder: (context) => + DonateView(tvMode: widget.tvMode), + ), + ), ), - ], - ), - ), - ListTile( - title: const Text("Show movies"), - trailing: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Switch( - value: settings.showMovies, - onChanged: (bool value) { - setState(() { - settings.showMovies = value; - }); - updateSettings(); - }, + if (!widget.tvMode) + ListTile( + title: const Text("Default view"), + subtitle: Text( + viewTypeToString(settings.defaultView), + ), + onTap: () => _showDefaultViewDialog(context), + ), + ListTile( + title: const Text("Default sort"), + subtitle: Text( + sortTypeToString(settings.defaultSort), + ), + onTap: () => _showDefaultSortDialog(context), ), - ], - ), - ), - ListTile( - title: const Text("Show series"), - trailing: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Switch( - value: settings.showSeries, - onChanged: (bool value) { - setState(() { - settings.showSeries = value; - }); - updateSettings(); - }, + ListTile( + title: const Text("Force TV Mode"), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Switch( + value: settings.forceTVMode, + onChanged: (bool value) { + setState(() { + settings.forceTVMode = value; + }); + updateSettings(); + }, + ), + ], + ), ), - ], - ), - ), - const Divider(), - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - const Padding( - padding: EdgeInsets.only(left: 10), - child: Text( - 'Sources', - style: TextStyle( - fontSize: 30, - fontWeight: FontWeight.bold, + if (!widget.tvMode) + ListTile( + title: const Text("Low latency livestreams"), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Switch( + value: settings.lowLatency, + onChanged: (bool value) { + setState(() { + settings.lowLatency = value; + }); + updateSettings(); + }, + ), + ], + ), + ), + ListTile( + title: const Text("Refresh sources on start"), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Switch( + value: settings.refreshOnStart, + onChanged: (bool value) { + setState(() { + settings.refreshOnStart = value; + }); + updateSettings(); + }, + ), + ], ), ), - ), - Row( - children: [ - IconButton( - onPressed: () async => await Error.tryAsync( - () async => - await NativeBridge.instance.refreshAll(), - context, - "Successfully refreshed all sources", + if (!widget.tvMode) + ListTile( + title: const Text("Show livestreams"), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Switch( + value: settings.showLivestreams, + onChanged: (bool value) { + setState(() { + settings.showLivestreams = value; + }); + updateSettings(); + }, + ), + ], ), - icon: const Icon(Icons.refresh), ), - IconButton( - onPressed: () => Navigator.push( - context, - MaterialPageRoute( - builder: (context) => Setup( - showAppBar: true, - tvMode: widget.tvMode, + if (!widget.tvMode) + ListTile( + title: const Text("Show movies"), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Switch( + value: settings.showMovies, + onChanged: (bool value) { + setState(() { + settings.showMovies = value; + }); + updateSettings(); + }, ), - ), + ], ), - icon: const Icon(Icons.add), ), - ], - ), - ], + if (!widget.tvMode) + ListTile( + title: const Text("Show series"), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Switch( + value: settings.showSeries, + onChanged: (bool value) { + setState(() { + settings.showSeries = value; + }); + updateSettings(); + }, + ), + ], + ), + ), + const Divider(), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Padding( + padding: EdgeInsets.only(left: 10), + child: Text( + 'Sources', + style: TextStyle( + fontSize: 30, + fontWeight: FontWeight.bold, + ), + ), + ), + Row( + children: [ + IconButton( + color: busy + ? Theme.of(context).disabledColor + : null, + onPressed: () => busy + ? TaskService.instance.notifyBusy() + : TaskService.instance.refreshAll(), + icon: const Icon(Icons.refresh), + ), + IconButton( + color: busy + ? Theme.of(context).disabledColor + : null, + onPressed: () => busy + ? TaskService.instance.notifyBusy() + : Navigator.push( + context, + MaterialPageRoute( + builder: (context) => Setup( + showAppBar: true, + tvMode: widget.tvMode, + ), + ), + ), + icon: const Icon(Icons.add), + ), + ], + ), + ], + ), + const SizedBox(height: 10), + ...sources.map((x) => getSource(x, busy)), + ], + ), ), - const SizedBox(height: 10), - ...sources.map(getSource), - ], + ), ), ), + bottomNavigationBar: !widget.tvMode + ? BottomNav( + updateViewMode: updateView, + startingView: ViewType.settings, + tvMode: widget.tvMode, + ) + : null, ), - ), - ), - bottomNavigationBar: !widget.tvMode - ? BottomNav( - updateViewMode: updateView, - startingView: ViewType.settings, - tvMode: widget.tvMode, - ) - : null, + ); + }, ); } } diff --git a/flutter/lib/setup.dart b/flutter/lib/setup.dart index 86ba407..4c33ce9 100644 --- a/flutter/lib/setup.dart +++ b/flutter/lib/setup.dart @@ -4,7 +4,6 @@ import 'package:animations/animations.dart'; import 'package:flutter/services.dart'; import 'package:flutter_form_builder/flutter_form_builder.dart'; import 'package:form_builder_validators/form_builder_validators.dart'; -import 'package:loader_overlay/loader_overlay.dart'; import 'package:open_tv/correction_modal.dart'; import 'package:open_tv/home.dart'; import 'package:open_tv/models/filters.dart'; @@ -107,7 +106,9 @@ class _SetupState extends State { } Future finish() async { - loading = true; + setState(() { + loading = true; + }); var result = await Error.tryAsync( () async { await NativeBridge.instance.processSource( @@ -128,10 +129,12 @@ class _SetupState extends State { }, context, null, - true, + false, false, ); - loading = false; + setState(() { + loading = false; + }); if (!result.success) { return; } @@ -255,7 +258,9 @@ class _SetupState extends State { MaterialPageRoute( builder: (_) => widget.tvMode ? const TvHome() - : Home(home: HomeManager(filters: Filters(viewType: ViewType.all))), + : Home( + home: HomeManager(filters: Filters(viewType: ViewType.all)), + ), ), (route) => false, ); @@ -270,122 +275,137 @@ class _SetupState extends State { if (!didPop) prevStep(); }, child: Scaffold( - appBar: widget.showAppBar ? AppBar() : null, + appBar: widget.showAppBar + ? AppBar(automaticallyImplyLeading: step != Steps.finish) + : null, body: SafeArea( - child: LoaderOverlay( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Padding( - padding: const EdgeInsets.symmetric( - horizontal: 24.0, - vertical: 16, - ), - child: TweenAnimationBuilder( - tween: Tween( - begin: 0, - end: (step.index + 1) / Steps.values.length, - ), - duration: const Duration(milliseconds: 400), - curve: Curves.easeInOut, - builder: (context, value, child) { - return ClipRRect( - borderRadius: BorderRadius.circular(4), - child: LinearProgressIndicator( - value: value, - minHeight: 6, - ), - ); - }, - ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Padding( + padding: const EdgeInsets.symmetric( + horizontal: 24.0, + vertical: 16, ), - Expanded( - child: PageTransitionSwitcher( - duration: const Duration(milliseconds: 400), - reverse: !isForward, - transitionBuilder: - (child, primaryAnimation, secondaryAnimation) { - return SharedAxisTransition( - animation: primaryAnimation, - secondaryAnimation: secondaryAnimation, - transitionType: SharedAxisTransitionType.horizontal, - child: child, - ); - }, - child: currentPage, + child: TweenAnimationBuilder( + tween: Tween( + begin: 0, + end: (step.index + 1) / Steps.values.length, ), + duration: const Duration(milliseconds: 400), + curve: Curves.easeInOut, + builder: (context, value, child) { + return ClipRRect( + borderRadius: BorderRadius.circular(4), + child: LinearProgressIndicator( + value: value, + minHeight: 6, + ), + ); + }, ), - Padding( - padding: const EdgeInsets.all(24.0), - child: FocusTraversalGroup( - policy: OrderedTraversalPolicy(), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - AnimatedOpacity( - opacity: step != Steps.welcome && step != Steps.finish - ? 1 - : 0, - duration: const Duration(milliseconds: 300), - curve: Curves.easeInOut, - child: IgnorePointer( - ignoring: - step == Steps.welcome || step == Steps.finish, - child: FocusTraversalOrder( - order: const NumericFocusOrder(2.0), - child: FilledButton.tonal( - onPressed: prevStep, - style: FilledButton.styleFrom( - padding: const EdgeInsets.symmetric( - horizontal: 24, - vertical: 16, - ), - ), - child: const Text( - "Back", - style: TextStyle(fontSize: 18), + ), + Expanded( + child: PageTransitionSwitcher( + duration: const Duration(milliseconds: 400), + reverse: !isForward, + transitionBuilder: + (child, primaryAnimation, secondaryAnimation) { + return SharedAxisTransition( + animation: primaryAnimation, + secondaryAnimation: secondaryAnimation, + transitionType: SharedAxisTransitionType.horizontal, + child: child, + ); + }, + child: currentPage, + ), + ), + Padding( + padding: const EdgeInsets.all(24.0), + child: FocusTraversalGroup( + policy: OrderedTraversalPolicy(), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + AnimatedOpacity( + opacity: showBackButton ? 1 : 0, + duration: const Duration(milliseconds: 300), + curve: Curves.easeInOut, + child: IgnorePointer( + ignoring: !showBackButton, + child: FocusTraversalOrder( + order: const NumericFocusOrder(2.0), + child: FilledButton.tonal( + onPressed: prevStep, + style: FilledButton.styleFrom( + padding: const EdgeInsets.symmetric( + horizontal: 24, + vertical: 16, ), ), + child: const Text( + "Back", + style: TextStyle(fontSize: 18), + ), ), ), ), - FocusTraversalOrder( - order: const NumericFocusOrder(1.0), - child: FilledButton( - focusNode: nextButtonFocusNode, - onPressed: !formPages.contains(step) || formValid - ? handleNext - : null, - style: FilledButton.styleFrom( - padding: const EdgeInsets.symmetric( - horizontal: 24, - vertical: 16, - ), - ), - child: Text( - step == Steps.name && - selectedSourceType == SourceType.m3u - ? "Select file" - : step == Steps.finish - ? "Finish" - : "Next", - style: const TextStyle(fontSize: 18), + ), + FocusTraversalOrder( + order: const NumericFocusOrder(1.0), + child: FilledButton( + focusNode: nextButtonFocusNode, + onPressed: + !loading && + (!formPages.contains(step) || formValid) + ? handleNext + : null, + style: FilledButton.styleFrom( + padding: const EdgeInsets.symmetric( + horizontal: 24, + vertical: 16, ), ), + child: Text( + step == Steps.name && + selectedSourceType == SourceType.m3u + ? "Select file" + : step == Steps.finish + ? "Finish" + : "Next", + style: const TextStyle(fontSize: 18), + ), ), - ], - ), + ), + ], ), ), - ], - ), + ), + ], ), ), ), ); } + bool get showBackButton => + !loading && step != Steps.welcome && step != Steps.finish; + + Widget get loadingPage => getPage( + "Adding your source", + "This can take a moment, hang tight", + const [ + SizedBox( + width: 36, + height: 36, + child: CircularProgressIndicator(strokeWidth: 3), + ), + ], + ); + Widget get currentPage { + if (loading) return loadingPage; switch (step) { case Steps.welcome: return getPage( diff --git a/flutter/lib/task_banner.dart b/flutter/lib/task_banner.dart new file mode 100644 index 0000000..2c9e2e3 --- /dev/null +++ b/flutter/lib/task_banner.dart @@ -0,0 +1,87 @@ +import 'package:flutter/material.dart'; +import 'package:open_tv/error.dart'; +import 'package:open_tv/task_service.dart'; + +const _bottomNavHeight = 80.0; +const _snackBarClearance = 50.0; +const _edgeMargin = 12.0; +const _snackBarTransition = Duration(milliseconds: 250); + +class TaskBanner extends StatelessWidget { + final bool hasBottomNav; + const TaskBanner({super.key, this.hasBottomNav = false}); + + @override + Widget build(BuildContext context) { + final service = TaskService.instance; + return Positioned.fill( + child: IgnorePointer( + child: SafeArea( + child: Align( + alignment: Alignment.bottomLeft, + child: ListenableBuilder( + listenable: Listenable.merge([ + service.runningTask, + service.playerVisible, + Error.visibleSnackBars, + ]), + builder: (context, _) => + service.busy && !service.playerVisible.value + ? _Banner( + label: service.runningTask.value!, + hasBottomNav: hasBottomNav, + hasSnackBar: Error.visibleSnackBars.value > 0, + ) + : const SizedBox.shrink(), + ), + ), + ), + ), + ); + } +} + +class _Banner extends StatelessWidget { + final String label; + final bool hasBottomNav; + final bool hasSnackBar; + const _Banner({ + required this.label, + required this.hasBottomNav, + required this.hasSnackBar, + }); + + @override + Widget build(BuildContext context) { + return AnimatedPadding( + duration: _snackBarTransition, + curve: Curves.fastOutSlowIn, + padding: EdgeInsets.only( + left: _edgeMargin, + bottom: + (hasBottomNav ? _bottomNavHeight : 0) + + (hasSnackBar ? _snackBarClearance : _edgeMargin), + ), + child: Material( + elevation: 6, + borderRadius: BorderRadius.circular(20), + color: Theme.of(context).colorScheme.surfaceContainer, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox( + width: 14, + height: 14, + child: CircularProgressIndicator(strokeWidth: 2), + ), + const SizedBox(width: 10), + Text(label, style: Theme.of(context).textTheme.bodyMedium), + ], + ), + ), + ), + ); + } +} diff --git a/flutter/lib/task_service.dart b/flutter/lib/task_service.dart new file mode 100644 index 0000000..e387886 --- /dev/null +++ b/flutter/lib/task_service.dart @@ -0,0 +1,72 @@ +import 'package:flutter/foundation.dart'; +import 'package:open_tv/error.dart'; +import 'package:open_tv/models/source.dart'; +import 'package:open_tv/native_bridge.dart'; + +class TaskService { + TaskService._(); + static final TaskService instance = TaskService._(); + + final ValueNotifier runningTask = ValueNotifier(null); + final ValueNotifier playerVisible = ValueNotifier(false); + + static const _refreshLabel = "Refresh in progress"; + static const _deleteSourceLabel = "Deleting source"; + + bool get busy => runningTask.value != null; + + bool get isDeletingSource => runningTask.value == _deleteSourceLabel; + + void notifyBusy() => Error.showMessage("${runningTask.value}, please wait"); + + Future favorite(int channelId, bool value) async { + if (busy) { + notifyBusy(); + return false; + } + await NativeBridge.instance.favorite(channelId, value); + return true; + } + + Future addLastWatched(int channelId) async { + if (busy) return; + await NativeBridge.instance.addLastWatched(channelId); + } + + Future setMoviePosition(int channelId, int position) async { + if (busy) return; + await NativeBridge.instance.setMoviePosition(channelId, position); + } + + Future refreshAll() => _run( + _refreshLabel, + () => NativeBridge.instance.refreshAll(), + "Successfully refreshed all sources", + ); + + Future refreshSource(Source source) => _run( + _refreshLabel, + () => NativeBridge.instance.refreshSource(source), + "Source has been refreshed successfully", + ); + + Future deleteSource(int id) => _run( + _deleteSourceLabel, + () => NativeBridge.instance.deleteSource(id), + "Successfully deleted source", + ); + + Future _run( + String label, + Future Function() task, + String successMessage, + ) async { + if (busy) return; + runningTask.value = label; + try { + await Error.tryAsyncNoLoading(task, true, successMessage); + } finally { + runningTask.value = null; + } + } +} diff --git a/flutter/lib/tv_home.dart b/flutter/lib/tv_home.dart index 6e8aafe..4137c6a 100644 --- a/flutter/lib/tv_home.dart +++ b/flutter/lib/tv_home.dart @@ -11,7 +11,13 @@ import 'package:open_tv/utils.dart'; class TvHome extends StatefulWidget { final bool nested; final ViewType? previousViewType; - const TvHome({super.key, this.nested = false, this.previousViewType}); + final bool firstLaunch; + const TvHome({ + super.key, + this.nested = false, + this.previousViewType, + this.firstLaunch = false, + }); @override State createState() => _TvHomeState(); @@ -21,7 +27,7 @@ class _TvHomeState extends State { @override void initState() { super.initState(); - if (!widget.nested) { + if (widget.firstLaunch) { WidgetsBinding.instance.addPostFrameCallback( (_) => Utils.maybeShowWhatsNew(context), ); @@ -47,9 +53,7 @@ class _TvHomeState extends State { void navSettings() { Navigator.of(context).push( - MaterialPageRoute( - builder: (context) => const SettingsView(tvMode: true), - ), + MaterialPageRoute(builder: (context) => const SettingsView(tvMode: true)), ); } diff --git a/flutter/linux/libs/libfred_tv_lib.so b/flutter/linux/libs/libfred_tv_lib.so index 52454b7..e09ff19 100755 Binary files a/flutter/linux/libs/libfred_tv_lib.so and b/flutter/linux/libs/libfred_tv_lib.so differ diff --git a/protocol/generated_proto.proto b/protocol/generated_proto.proto index ec5a1ca..00524f6 100644 --- a/protocol/generated_proto.proto +++ b/protocol/generated_proto.proto @@ -139,6 +139,10 @@ message SetSourceEnabled { bool enabled = 2; } +message Expiries { + map expiries = 1; +} + message FFIResult { bool success = 1; optional string error_message = 2; @@ -151,6 +155,7 @@ message FFIResult { ChannelHttpHeaders headers = 9; GetEnabledSourcesMinimal enabled_sources_minimal = 10; SourceList source_list = 11; + Expiries expiries = 12; } } diff --git a/src/generated_proto.rs b/src/generated_proto.rs index c46751b..68ee4fb 100644 --- a/src/generated_proto.rs +++ b/src/generated_proto.rs @@ -191,12 +191,17 @@ pub struct SetSourceEnabled { pub enabled: bool, } #[derive(Clone, PartialEq, ::prost::Message)] +pub struct Expiries { + #[prost(map = "int64, int64", tag = "1")] + pub expiries: ::std::collections::HashMap, +} +#[derive(Clone, PartialEq, ::prost::Message)] pub struct FfiResult { #[prost(bool, tag = "1")] pub success: bool, #[prost(string, optional, tag = "2")] pub error_message: ::core::option::Option<::prost::alloc::string::String>, - #[prost(oneof = "ffi_result::Data", tags = "3, 4, 6, 7, 8, 9, 10, 11")] + #[prost(oneof = "ffi_result::Data", tags = "3, 4, 6, 7, 8, 9, 10, 11, 12")] pub data: ::core::option::Option, } /// Nested message and enum types in `FFIResult`. @@ -219,6 +224,8 @@ pub mod ffi_result { EnabledSourcesMinimal(super::GetEnabledSourcesMinimal), #[prost(message, tag = "11")] SourceList(super::SourceList), + #[prost(message, tag = "12")] + Expiries(super::Expiries), } } #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, ::prost::Enumeration)] diff --git a/src/lib.rs b/src/lib.rs index 521533b..dbd383b 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -257,7 +257,12 @@ pub extern "C" fn update_settings(task_id: u64, callback: FfiCallback, ptr: *con } #[unsafe(no_mangle)] -pub extern "C" fn add_last_watched(task_id: u64, callback: FfiCallback, ptr: *const u8, len: usize) { +pub extern "C" fn add_last_watched( + task_id: u64, + callback: FfiCallback, + ptr: *const u8, + len: usize, +) { c::queue_blocking_with_message( task_id, callback, @@ -268,7 +273,12 @@ pub extern "C" fn add_last_watched(task_id: u64, callback: FfiCallback, ptr: *co } #[unsafe(no_mangle)] -pub extern "C" fn set_movie_position(task_id: u64, callback: FfiCallback, ptr: *const u8, len: usize) { +pub extern "C" fn set_movie_position( + task_id: u64, + callback: FfiCallback, + ptr: *const u8, + len: usize, +) { c::queue_blocking_with_message( task_id, callback, @@ -281,7 +291,12 @@ pub extern "C" fn set_movie_position(task_id: u64, callback: FfiCallback, ptr: * } #[unsafe(no_mangle)] -pub extern "C" fn get_movie_position(task_id: u64, callback: FfiCallback, ptr: *const u8, len: usize) { +pub extern "C" fn get_movie_position( + task_id: u64, + callback: FfiCallback, + ptr: *const u8, + len: usize, +) { c::queue_blocking_with_message( task_id, callback, @@ -303,7 +318,12 @@ pub extern "C" fn clear_history(task_id: u64, callback: FfiCallback) { } #[unsafe(no_mangle)] -pub extern "C" fn source_name_exists(task_id: u64, callback: FfiCallback, ptr: *const u8, len: usize) { +pub extern "C" fn source_name_exists( + task_id: u64, + callback: FfiCallback, + ptr: *const u8, + len: usize, +) { c::queue_blocking_with_message( task_id, callback, @@ -338,7 +358,12 @@ pub extern "C" fn get_episodes(task_id: u64, callback: FfiCallback, ptr: *const } #[unsafe(no_mangle)] -pub extern "C" fn should_show_whats_new(task_id: u64, callback: FfiCallback, ptr: *const u8, len: usize) { +pub extern "C" fn should_show_whats_new( + task_id: u64, + callback: FfiCallback, + ptr: *const u8, + len: usize, +) { c::queue_blocking_with_message( task_id, callback, @@ -355,7 +380,12 @@ pub extern "C" fn should_show_whats_new(task_id: u64, callback: FfiCallback, ptr } #[unsafe(no_mangle)] -pub extern "C" fn update_last_seen_version(task_id: u64, callback: FfiCallback, ptr: *const u8, len: usize) { +pub extern "C" fn update_last_seen_version( + task_id: u64, + callback: FfiCallback, + ptr: *const u8, + len: usize, +) { c::queue_blocking_with_message( task_id, callback, @@ -375,7 +405,12 @@ pub extern "C" fn refresh_all(task_id: u64, callback: FfiCallback) { } #[unsafe(no_mangle)] -pub extern "C" fn get_channel_headers(task_id: u64, callback: FfiCallback, ptr: *const u8, len: usize) { +pub extern "C" fn get_channel_headers( + task_id: u64, + callback: FfiCallback, + ptr: *const u8, + len: usize, +) { c::queue_blocking_with_message( task_id, callback, @@ -439,7 +474,12 @@ pub extern "C" fn get_sources(task_id: u64, callback: FfiCallback) { } #[unsafe(no_mangle)] -pub extern "C" fn set_source_enabled(task_id: u64, callback: FfiCallback, ptr: *const u8, len: usize) { +pub extern "C" fn set_source_enabled( + task_id: u64, + callback: FfiCallback, + ptr: *const u8, + len: usize, +) { c::queue_blocking_with_message( task_id, callback, @@ -454,6 +494,18 @@ pub extern "C" fn set_source_enabled(task_id: u64, callback: FfiCallback, ptr: * ) } +#[unsafe(no_mangle)] +pub extern "C" fn get_all_expiries(task_id: u64, callback: FfiCallback) { + c::queue_async(task_id, callback, async move { + let result = xtream::get_all_expiries().await; + result.map(|res| { + crate::generated_proto::ffi_result::Data::Expiries(generated_proto::Expiries { + expiries: res, + }) + }) + }) +} + #[unsafe(no_mangle)] pub extern "C" fn free_message(ptr: *mut u8, len: usize) { unsafe { diff --git a/src/sql.rs b/src/sql.rs index 4e237bd..184acf0 100644 --- a/src/sql.rs +++ b/src/sql.rs @@ -19,29 +19,44 @@ use rusqlite_migration::{M, Migrations}; const PAGE_SIZE: u8 = 36; pub const DB_NAME: &str = "db_rust.sqlite"; +pub const DB_SETTINGS_NAME: &str = "db_rust_settings.sqlite"; pub const LAST_SEEN_VERSION_KEY: &str = "last_seen_version"; pub static DB_PATH_OVERRIDE: OnceLock = OnceLock::new(); -static CONN: LazyLock> = LazyLock::new(|| create_connection_pool()); +static CONN: LazyLock> = + LazyLock::new(|| create_connection_pool(DB_NAME)); +static SETTINGS_CONN: LazyLock> = + LazyLock::new(|| create_connection_pool(DB_SETTINGS_NAME)); pub fn get_conn() -> Result> { CONN.try_get().context("No sqlite conns available") } -fn create_connection_pool() -> Pool { - let manager = SqliteConnectionManager::file(get_and_create_sqlite_db_path()); +pub fn get_settings_conn() -> Result> { + SETTINGS_CONN.try_get().context("No sqlite conns available") +} + +fn create_connection_pool(db_name: &str) -> Pool { + let manager = SqliteConnectionManager::file(get_and_create_db_path(db_name)) + .with_init(|c| c.pragma_update_and_check(None, "journal_mode", "WAL", |_| Ok(()))); r2d2::Pool::builder().max_size(20).build(manager).unwrap() } -fn get_and_create_sqlite_db_path() -> String { +fn get_and_create_db_path(db_name: &str) -> String { let mut path = PathBuf::from_str(DB_PATH_OVERRIDE.get().unwrap()).unwrap(); if !path.exists() { std::fs::create_dir_all(&path).unwrap(); } - path.push(DB_NAME); + path.push(db_name); return path.to_string_lossy().to_string(); } pub fn apply_migrations() -> Result<()> { + apply_main_migrations()?; + migrate_settings_database()?; + Ok(()) +} + +fn apply_main_migrations() -> Result<()> { let mut sql = get_conn()?; let migrations = Migrations::new(vec![M::up( r#" @@ -145,6 +160,13 @@ CREATE UNIQUE INDEX index_channel_http_headers_channel_id ON channel_http_header CREATE UNIQUE INDEX index_movie_positions_channel_id ON movie_positions(channel_id); CREATE UNIQUE INDEX unique_seasons ON seasons(season_number, series_id, source_id); +ANALYZE; +"#, + ), + M::up( + r#" +DROP TABLE IF EXISTS settings; + ANALYZE; "#, )]); @@ -152,6 +174,22 @@ ANALYZE; Ok(()) } +fn migrate_settings_database() -> Result<()> { + let mut conn = get_settings_conn()?; + let migrations = Migrations::new(vec![M::up( + r#" +CREATE TABLE "settings" ( + "key" VARCHAR(50) PRIMARY KEY, + "value" VARCHAR(100) +); + +ANALYZE; +"#, + )]); + migrations.to_latest(&mut conn)?; + Ok(()) +} + pub fn create_or_find_source_by_name(tx: &Transaction, source: &Source) -> Result { let id: Option = tx .query_row( @@ -326,7 +364,7 @@ fn row_to_channel_headers(row: &Row) -> Result Result> { - let sql = get_conn()?; + let sql = get_settings_conn()?; let map = sql .prepare("SELECT key, value FROM Settings")? .query_map([], |row| { @@ -340,7 +378,7 @@ pub fn get_settings() -> Result> { } pub fn update_settings(map: HashMap>) -> Result<()> { - let mut sql: PooledConnection = get_conn()?; + let mut sql: PooledConnection = get_settings_conn()?; let tx = sql.transaction()?; for (key, value) in map { tx.execute( @@ -965,7 +1003,7 @@ pub fn get_movie_position(channel_id: i64) -> Result> { } pub fn get_whats_new() -> Result> { - let sql = get_conn()?; + let sql = get_settings_conn()?; let version: Option = sql .query_row( r#" @@ -990,3 +1028,13 @@ pub fn has_sources() -> Result { .optional()? .is_some()) } + +pub fn get_sources_by_type(source_type: u8) -> Result> { + let sql = get_conn()?; + let sources: Vec = sql + .prepare("SELECT * FROM sources WHERE source_type = ?")? + .query_map([source_type], row_to_source)? + .filter_map(Result::ok) + .collect(); + Ok(sources) +} diff --git a/src/types.rs b/src/types.rs index 9904c6a..f736aaf 100644 --- a/src/types.rs +++ b/src/types.rs @@ -103,3 +103,13 @@ pub struct ChannelPreserve { #[serde(default)] pub is_group: bool, } + +#[derive(Clone, PartialEq, Debug, Deserialize, Serialize)] +pub struct XtreamStatus { + pub user_info: XtreamStatusUserInfo, +} + +#[derive(Clone, PartialEq, Debug, Deserialize, Serialize)] +pub struct XtreamStatusUserInfo { + pub exp_date: serde_json::Value, +} diff --git a/src/xtream.rs b/src/xtream.rs index f4b5e49..131ac80 100644 --- a/src/xtream.rs +++ b/src/xtream.rs @@ -1,13 +1,16 @@ use crate::media_type; +use crate::source_type; use crate::sql; use crate::sql::insert_season; use crate::types::Channel; use crate::types::ChannelPreserve; use crate::types::Season; use crate::types::Source; +use crate::types::XtreamStatus; use crate::utils::get_user_agent_from_source; use anyhow::anyhow; use anyhow::{Context, Result}; +use futures::future::join_all; use reqwest::Client; use reqwest::Url; use rusqlite::Transaction; @@ -475,3 +478,27 @@ fn episode_to_channel( tv_archive: None, }) } + +async fn get_status(source: &mut Source) -> Result<(i64, XtreamStatus)> { + let url = build_xtream_url(source)?; + let user_agent = get_user_agent_from_source(&source)?; + let client = Client::builder().user_agent(user_agent).build()?; + let data = client.get(url).send().await?.json::().await?; + Ok((source.id.context("no id")?, data)) +} + +pub async fn get_all_expiries() -> Result> { + let mut sources = sql::get_sources_by_type(source_type::XTREAM)?; + let to_await = sources.iter_mut().map(|source| get_status(source)); + let results: Vec> = + join_all(to_await).await; + let statuses: HashMap = results + .into_iter() + .flatten() + .filter_map(|(id, status)| { + let exp_date = get_serde_json_i64(&status.user_info.exp_date)?; + Some((id, exp_date)) + }) + .collect(); + Ok(statuses) +}