LCOV - code coverage report
Current view: top level - widgets - mini_controller.dart Coverage Total Hit
Test: lcov_cleaned.info Lines: 0.0 % 169 0
Test Date: 2025-06-20 10:50:47 Functions: - 0 0

            Line data    Source code
       1              : import 'package:flutter/material.dart';
       2              : import 'package:flutter_chrome_cast/lib.dart' hide GoogleCastPlayerTheme;
       3              : import 'package:marquee/marquee.dart';
       4              : 
       5              : import '../themes.dart';
       6              : 
       7              : /// A floating mini controller widget for Google Cast media playback.
       8              : ///
       9              : /// This widget provides a beautiful, floating mini controller that displays:
      10              : /// - Media artwork with rounded corners and shadows
      11              : /// - Media title and casting device information
      12              : /// - Play/pause controls with theme-aware styling
      13              : /// - A sleek progress indicator
      14              : ///
      15              : /// The widget consumes and respects the `ExpandedGoogleCastPlayerTheme` for consistent styling.
      16              : ///
      17              : /// Example usage:
      18              : /// ```dart
      19              : /// GoogleCastMiniController(
      20              : ///   theme: ExpandedGoogleCastPlayerTheme(
      21              : ///     backgroundColor: Colors.white,
      22              : ///     titleTextStyle: TextStyle(
      23              : ///       fontSize: 16,
      24              : ///       fontWeight: FontWeight.w600,
      25              : ///       color: Colors.black,
      26              : ///     ),
      27              : ///     deviceTextStyle: TextStyle(
      28              : ///       fontSize: 12,
      29              : ///       color: Colors.grey[600],
      30              : ///     ),
      31              : ///     iconColor: Colors.blue,
      32              : ///     iconSize: 28,
      33              : ///     imageBorderRadius: BorderRadius.circular(12),
      34              : ///     imageShadow: [
      35              : ///       BoxShadow(
      36              : ///         color: Colors.black26,
      37              : ///         blurRadius: 8,
      38              : ///         offset: Offset(0, 2),
      39              : ///       ),
      40              : ///     ],
      41              : ///   ),
      42              : ///   margin: EdgeInsets.all(16),
      43              : ///   borderRadius: BorderRadius.circular(16),
      44              : ///   showDeviceName: true,
      45              : /// )
      46              : /// ```
      47              : class GoogleCastMiniController extends StatefulWidget {
      48              :   /// Theme configuration for customizing the visual appearance
      49              :   final GoogleCastPlayerTheme? theme;
      50              : 
      51              :   /// Custom margin for the mini controller
      52              :   final EdgeInsets? margin;
      53              : 
      54              :   /// Custom border radius for the mini controller
      55              :   final BorderRadius? borderRadius;
      56              : 
      57              :   /// Whether to show the device name
      58              :   final bool showDeviceName;
      59              : 
      60              :   /// Creates a new [GoogleCastMiniController].
      61            0 :   const GoogleCastMiniController({
      62              :     super.key,
      63              :     this.theme,
      64              :     this.margin,
      65              :     this.borderRadius,
      66              :     this.showDeviceName = true,
      67              :   });
      68              : 
      69            0 :   @override
      70              :   State<GoogleCastMiniController> createState() =>
      71            0 :       _GoogleCastMiniControllerState();
      72              : }
      73              : 
      74              : class _GoogleCastMiniControllerState extends State<GoogleCastMiniController> {
      75              :   bool isExpanded = false;
      76              : 
      77            0 :   @override
      78              :   Widget build(BuildContext context) {
      79            0 :     return StreamBuilder<GoogleCastSession?>(
      80            0 :         stream: GoogleCastSessionManager.instance.currentSessionStream,
      81            0 :         builder: (context, snapshot) {
      82            0 :           return StreamBuilder<GoggleCastMediaStatus?>(
      83            0 :             stream: GoogleCastRemoteMediaClient.instance.mediaStatusStream,
      84            0 :             builder: ((context, snapshot) {
      85            0 :               final mediaStatus = snapshot.data;
      86              :               final hasConnectedSession =
      87            0 :                   GoogleCastSessionManager.instance.hasConnectedSession;
      88              : 
      89              :               if (!hasConnectedSession) return const SizedBox.shrink();
      90              : 
      91              :               if (mediaStatus == null) return const SizedBox.shrink();
      92              : 
      93            0 :               if (isExpanded) {
      94            0 :                 return ExpandedGoogleCastPlayerController(
      95            0 :                   toggleExpand: _toggleExpand,
      96            0 :                   theme: widget.theme ??
      97            0 :                       GoogleCastPlayerTheme(
      98            0 :                           titleTextStyle: TextStyle(color: Colors.white)),
      99              :                 );
     100              :               }
     101            0 :               return Align(
     102              :                 alignment: Alignment.bottomCenter,
     103            0 :                 child: Container(
     104            0 :                   margin: widget.margin ?? const EdgeInsets.all(16),
     105            0 :                   child: _miniPlayerController(mediaStatus),
     106              :                 ),
     107              :               );
     108              :             }),
     109              :           );
     110              :         });
     111              :   }
     112              : 
     113            0 :   Widget _miniPlayerController(GoggleCastMediaStatus mediaStatus) {
     114            0 :     final theme = widget.theme;
     115            0 :     final borderRadius = widget.borderRadius ?? BorderRadius.circular(16);
     116              : 
     117            0 :     return Container(
     118              :       constraints: const BoxConstraints(
     119              :         minHeight: 84,
     120              :         maxHeight: 96,
     121              :       ),
     122            0 :       decoration: BoxDecoration(
     123            0 :         color: theme?.backgroundColor ?? Colors.white,
     124              :         borderRadius: borderRadius,
     125            0 :         boxShadow: [
     126            0 :           BoxShadow(
     127            0 :             color: Colors.black.withValues(alpha: 0.15),
     128              :             blurRadius: 20,
     129              :             offset: const Offset(0, 8),
     130              :             spreadRadius: 0,
     131              :           ),
     132            0 :           BoxShadow(
     133            0 :             color: Colors.black.withValues(alpha: 0.1),
     134              :             blurRadius: 6,
     135              :             offset: const Offset(0, 2),
     136              :             spreadRadius: 0,
     137              :           ),
     138              :         ],
     139              :       ),
     140            0 :       child: Material(
     141              :         color: Colors.transparent,
     142              :         borderRadius: borderRadius,
     143            0 :         child: InkWell(
     144            0 :           onTap: _toggleExpand,
     145              :           borderRadius: borderRadius,
     146            0 :           child: Padding(
     147              :             padding: const EdgeInsets.all(12),
     148            0 :             child: Column(
     149              :               mainAxisSize: MainAxisSize.min,
     150            0 :               children: [
     151            0 :                 Flexible(
     152            0 :                   child: Row(
     153              :                     crossAxisAlignment: CrossAxisAlignment.center,
     154            0 :                     children: [
     155            0 :                       _image(mediaStatus),
     156              :                       const SizedBox(width: 16),
     157            0 :                       Expanded(
     158            0 :                         child: ConstrainedBox(
     159              :                           constraints: const BoxConstraints(
     160              :                             minHeight: 40,
     161              :                             maxHeight: 80,
     162              :                           ),
     163            0 :                           child: Column(
     164              :                             crossAxisAlignment: CrossAxisAlignment.start,
     165              :                             mainAxisSize: MainAxisSize.min,
     166              :                             mainAxisAlignment: MainAxisAlignment.center,
     167            0 :                             children: [
     168            0 :                               Flexible(child: _buildTitle(mediaStatus)),
     169              :                               const SizedBox(height: 2),
     170            0 :                               Flexible(child: _buildSubtitle(mediaStatus)),
     171              :                             ],
     172              :                           ),
     173              :                         ),
     174              :                       ),
     175              :                       const SizedBox(width: 12),
     176            0 :                       Container(
     177            0 :                         decoration: BoxDecoration(
     178            0 :                           color: (theme?.iconColor ?? Colors.grey[800])
     179            0 :                               ?.withValues(alpha: 0.1),
     180            0 :                           borderRadius: BorderRadius.circular(24),
     181              :                         ),
     182            0 :                         child: IconButton(
     183            0 :                           onPressed: () =>
     184            0 :                               _togglePlayAndPause.call(mediaStatus.playerState),
     185              :                           icon:
     186            0 :                               _getIconFromPlayerState(mediaStatus.playerState),
     187            0 :                           iconSize: theme?.iconSize ?? 28,
     188            0 :                           color: theme?.iconColor ?? Colors.grey[800],
     189              :                           padding: const EdgeInsets.all(8),
     190              :                         ),
     191              :                       ),
     192              :                     ],
     193              :                   ),
     194              :                 ),
     195              :                 const SizedBox(height: 8),
     196            0 :                 _buildProgressIndicator(mediaStatus),
     197              :               ],
     198              :             ),
     199              :           ),
     200              :         ),
     201              :       ),
     202              :     );
     203              :   }
     204              : 
     205            0 :   Widget _image(GoggleCastMediaStatus mediaStatus) {
     206            0 :     final metadata = mediaStatus.mediaInformation?.metadata;
     207            0 :     final images = metadata?.images;
     208            0 :     final theme = widget.theme;
     209              : 
     210            0 :     if (images != null && images.isNotEmpty) {
     211              :       // Try to find the best image for the mini controller
     212            0 :       final sortedImages = List<GoogleCastImage>.from(images);
     213            0 :       sortedImages.sort((a, b) {
     214            0 :         final aSize = (a.width ?? 0) * (a.height ?? 0);
     215            0 :         final bSize = (b.width ?? 0) * (b.height ?? 0);
     216            0 :         return bSize.compareTo(aSize); // Descending order
     217              :       });
     218              : 
     219            0 :       return Container(
     220              :         width: 56,
     221              :         height: 56,
     222            0 :         decoration: BoxDecoration(
     223            0 :           borderRadius: theme?.imageBorderRadius ?? BorderRadius.circular(12),
     224            0 :           boxShadow: theme?.imageShadow ??
     225            0 :               [
     226            0 :                 BoxShadow(
     227            0 :                   color: Colors.black.withValues(alpha: 0.2),
     228              :                   blurRadius: 8,
     229              :                   offset: const Offset(0, 2),
     230              :                 ),
     231              :               ],
     232              :         ),
     233            0 :         child: Hero(
     234              :           tag: 'com.felnanuke.google_cast.controller.image',
     235            0 :           child: ClipRRect(
     236            0 :             borderRadius: theme?.imageBorderRadius ?? BorderRadius.circular(12),
     237            0 :             child: Image.network(
     238            0 :               sortedImages.first.url.toString(),
     239            0 :               fit: theme?.imageFit ?? BoxFit.cover,
     240            0 :               loadingBuilder: (context, child, loadingProgress) {
     241              :                 if (loadingProgress == null) return child;
     242            0 :                 return Container(
     243            0 :                   decoration: BoxDecoration(
     244            0 :                     color: Colors.grey[300],
     245              :                     borderRadius:
     246            0 :                         theme?.imageBorderRadius ?? BorderRadius.circular(12),
     247              :                   ),
     248              :                   child: const Center(
     249              :                     child: SizedBox(
     250              :                       width: 16,
     251              :                       height: 16,
     252              :                       child: CircularProgressIndicator(strokeWidth: 2),
     253              :                     ),
     254              :                   ),
     255              :                 );
     256              :               },
     257            0 :               errorBuilder: (context, error, stackTrace) => Container(
     258            0 :                 decoration: BoxDecoration(
     259            0 :                   color: Colors.grey[300],
     260              :                   borderRadius:
     261            0 :                       theme?.imageBorderRadius ?? BorderRadius.circular(12),
     262              :                 ),
     263            0 :                 child: Icon(
     264            0 :                   _getMediaIcon(metadata?.metadataType),
     265              :                   size: 24,
     266            0 :                   color: Colors.grey[600],
     267              :                 ),
     268              :               ),
     269              :             ),
     270              :           ),
     271              :         ),
     272              :       );
     273              :     }
     274              : 
     275              :     // Fallback when no image is available
     276            0 :     return Container(
     277              :       width: 56,
     278              :       height: 56,
     279            0 :       decoration: BoxDecoration(
     280            0 :         color: Colors.grey[300],
     281            0 :         borderRadius: theme?.imageBorderRadius ?? BorderRadius.circular(12),
     282            0 :         boxShadow: theme?.imageShadow ??
     283            0 :             [
     284            0 :               BoxShadow(
     285            0 :                 color: Colors.black.withValues(alpha: 0.2),
     286              :                 blurRadius: 8,
     287              :                 offset: const Offset(0, 2),
     288              :               ),
     289              :             ],
     290              :       ),
     291            0 :       child: Icon(
     292            0 :         _getMediaIcon(metadata?.metadataType),
     293              :         size: 24,
     294            0 :         color: Colors.grey[600],
     295              :       ),
     296              :     );
     297              :   }
     298              : 
     299            0 :   IconData _getMediaIcon(GoogleCastMediaMetadataType? metadataType) {
     300              :     switch (metadataType) {
     301            0 :       case GoogleCastMediaMetadataType.movieMediaMetadata:
     302              :         return Icons.movie;
     303            0 :       case GoogleCastMediaMetadataType.musicTrackMediaMetadata:
     304              :         return Icons.music_note;
     305            0 :       case GoogleCastMediaMetadataType.tvShowMediaMetadata:
     306              :         return Icons.tv;
     307            0 :       case GoogleCastMediaMetadataType.photoMediaMetadata:
     308              :         return Icons.photo;
     309              :       case GoogleCastMediaMetadataType.genericMediaMetadata:
     310              :       default:
     311              :         return Icons.play_circle_filled;
     312              :     }
     313              :   }
     314              : 
     315              :   /// Helper method to create scrolling text using marquee package
     316            0 :   Widget _buildScrollingText({
     317              :     required String text,
     318              :     required TextStyle style,
     319              :   }) {
     320            0 :     if (text.isEmpty) {
     321              :       return const SizedBox.shrink();
     322              :     }
     323              : 
     324            0 :     return Marquee(
     325              :       text: text,
     326              :       style: style,
     327              :       scrollAxis: Axis.horizontal,
     328              :       crossAxisAlignment: CrossAxisAlignment.start,
     329              :       blankSpace: 20.0,
     330              :       velocity: 30.0,
     331              :       pauseAfterRound: const Duration(seconds: 2),
     332              :       startPadding: 0.0,
     333              :       accelerationDuration: const Duration(seconds: 1),
     334              :       accelerationCurve: Curves.linear,
     335              :       decelerationDuration: const Duration(milliseconds: 500),
     336              :       decelerationCurve: Curves.easeOut,
     337              :     );
     338              :   }
     339              : 
     340            0 :   Widget _buildTitle(GoggleCastMediaStatus mediaStatus) {
     341            0 :     final title = mediaStatus.mediaInformation?.metadata?.extractedTitle ?? '';
     342            0 :     final theme = widget.theme;
     343              : 
     344            0 :     return Hero(
     345              :       tag: 'com.felnanuke.google_cast.controller.title',
     346            0 :       child: _buildScrollingText(
     347              :         text: title,
     348            0 :         style: theme?.titleTextStyle ??
     349            0 :             TextStyle(
     350              :               fontSize: 16,
     351              :               fontWeight: FontWeight.w600,
     352            0 :               color: Colors.grey[900],
     353              :             ),
     354              :       ),
     355              :     );
     356              :   }
     357              : 
     358            0 :   Widget _buildSubtitle(GoggleCastMediaStatus mediaStatus) {
     359              :     final subtitle =
     360            0 :         mediaStatus.mediaInformation?.metadata?.extractedSubtitle ?? '';
     361            0 :     final theme = widget.theme;
     362              : 
     363              :     // Only show subtitle if it exists and is not empty
     364            0 :     if (subtitle.isEmpty) {
     365              :       return const SizedBox.shrink();
     366              :     }
     367              : 
     368            0 :     return _buildScrollingText(
     369              :       text: subtitle,
     370            0 :       style: (theme?.deviceTextStyle ??
     371            0 :               TextStyle(
     372              :                 fontSize: 12,
     373            0 :                 color: Colors.grey[600],
     374              :                 fontWeight: FontWeight.w400,
     375              :               ))
     376            0 :           .copyWith(
     377              :         fontSize: 11,
     378              :         fontWeight: FontWeight.w300, // Slightly lighter weight for subtitle
     379              :       ),
     380              :     );
     381              :   }
     382              : 
     383            0 :   void _togglePlayAndPause(CastMediaPlayerState playerState) {
     384              :     switch (playerState) {
     385            0 :       case CastMediaPlayerState.playing:
     386            0 :         GoogleCastRemoteMediaClient.instance.pause();
     387              :         break;
     388            0 :       case CastMediaPlayerState.paused:
     389            0 :         GoogleCastRemoteMediaClient.instance.play();
     390              :         break;
     391              :       default:
     392              :     }
     393              :   }
     394              : 
     395            0 :   Widget _getIconFromPlayerState(CastMediaPlayerState playerState) {
     396            0 :     final theme = widget.theme;
     397              :     IconData iconData;
     398              :     switch (playerState) {
     399            0 :       case CastMediaPlayerState.playing:
     400              :         iconData = Icons.pause_rounded;
     401              :         break;
     402            0 :       case CastMediaPlayerState.paused:
     403              :         iconData = Icons.play_arrow_rounded;
     404              :         break;
     405            0 :       case CastMediaPlayerState.loading:
     406            0 :         return SizedBox(
     407            0 :           width: theme?.iconSize ?? 28,
     408            0 :           height: theme?.iconSize ?? 28,
     409            0 :           child: CircularProgressIndicator(
     410              :             strokeWidth: 2,
     411            0 :             valueColor: AlwaysStoppedAnimation<Color>(
     412            0 :               theme?.iconColor ?? Colors.grey[800]!,
     413              :             ),
     414              :           ),
     415              :         );
     416              :       default:
     417              :         return const SizedBox.shrink();
     418              :     }
     419            0 :     return Icon(
     420              :       iconData,
     421            0 :       color: theme?.iconColor ?? Colors.grey[800],
     422            0 :       size: theme?.iconSize ?? 28,
     423              :     );
     424              :   }
     425              : 
     426            0 :   Widget _buildProgressIndicator(GoggleCastMediaStatus mediaStatus) {
     427            0 :     final theme = widget.theme;
     428              : 
     429            0 :     return StreamBuilder<Duration?>(
     430            0 :       stream: GoogleCastRemoteMediaClient.instance.playerPositionStream,
     431            0 :       builder: (_, snapshot) => Container(
     432              :         height: 3,
     433            0 :         decoration: BoxDecoration(
     434            0 :           borderRadius: BorderRadius.circular(1.5),
     435            0 :           color: Colors.grey[300],
     436              :         ),
     437            0 :         child: ClipRRect(
     438            0 :           borderRadius: BorderRadius.circular(1.5),
     439            0 :           child: LinearProgressIndicator(
     440            0 :             value: getPlayerPercentage(mediaStatus, snapshot.data),
     441              :             backgroundColor: Colors.transparent,
     442            0 :             valueColor: AlwaysStoppedAnimation<Color>(
     443            0 :               theme?.iconColor ?? Colors.blue,
     444              :             ),
     445              :             minHeight: 3,
     446              :           ),
     447              :         ),
     448              :       ),
     449              :     );
     450              :   }
     451              : 
     452            0 :   void _toggleExpand() {
     453            0 :     setState(() {
     454            0 :       isExpanded = !isExpanded;
     455              :     });
     456              :   }
     457              : 
     458            0 :   Size get size => MediaQuery.of(context).size;
     459              : }
        

Generated by: LCOV version 2.3.1-1