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

            Line data    Source code
       1              : import 'dart:ui';
       2              : import 'package:flutter/cupertino.dart';
       3              : import 'package:flutter/material.dart';
       4              : import 'package:marquee/marquee.dart';
       5              : import 'package:flutter_chrome_cast/lib.dart' hide GoogleCastPlayerTexts;
       6              : import 'package:flutter_chrome_cast/themes.dart';
       7              : 
       8              : /// A full-screen cast player controller widget that displays media information,
       9              : /// playback controls, and cast session details.
      10              : ///
      11              : /// This widget provides a rich media player interface with customizable theming
      12              : /// and text content. It supports drag-to-dismiss gestures and displays:
      13              : /// - Media artwork and title
      14              : /// - Playback progress and controls
      15              : /// - Caption/subtitle selection
      16              : /// - Volume control
      17              : /// - Cast device information
      18              : ///
      19              : /// Example usage:
      20              : /// ```dart
      21              : /// ExpandedGoogleCastPlayerController(
      22              : ///   toggleExpand: () => Navigator.pop(context),
      23              : ///   theme: ExpandedGoogleCastPlayerTheme(
      24              : ///     backgroundColor: Colors.black,
      25              : ///     titleTextStyle: TextStyle(fontSize: 24, color: Colors.white),
      26              : ///   ),
      27              : ///   texts: ExpandedGoogleCastPlayerTexts(
      28              : ///     nowPlaying: 'Now Playing',
      29              : ///     unknownTitle: 'Unknown Media',
      30              : ///     castingToDevice: (device) => 'Playing on $device',
      31              : ///   ),
      32              : /// )
      33              : /// ```
      34              : class ExpandedGoogleCastPlayerController extends StatefulWidget {
      35              :   /// Callback function called when the user wants to collapse the expanded player
      36              :   final void Function()? toggleExpand;
      37              : 
      38              :   /// Theme configuration for customizing the visual appearance
      39              :   final GoogleCastPlayerTheme? theme;
      40              : 
      41              :   /// Text configuration for customizing all displayed text content
      42              :   final GoogleCastPlayerTexts? texts;
      43              : 
      44              :   /// Creates an expanded Google Cast player controller.
      45            0 :   const ExpandedGoogleCastPlayerController(
      46              :       {super.key, this.toggleExpand, this.theme, this.texts});
      47              : 
      48            0 :   @override
      49              :   State<ExpandedGoogleCastPlayerController> createState() =>
      50            0 :       _ExpandedGoogleCastPlayerControllerState();
      51              : }
      52              : 
      53              : class _ExpandedGoogleCastPlayerControllerState
      54              :     extends State<ExpandedGoogleCastPlayerController>
      55              :     with TickerProviderStateMixin {
      56              :   bool _isSliding = false;
      57              :   double _sliderPercentage = 0;
      58              :   late AnimationController _playPauseController;
      59              :   late AnimationController _dragController;
      60              :   double _dragOffset = 0;
      61              :   bool _isDragging = false;
      62              : 
      63            0 :   @override
      64              :   void initState() {
      65            0 :     super.initState();
      66            0 :     _playPauseController = AnimationController(
      67              :       vsync: this,
      68              :       duration: const Duration(milliseconds: 300),
      69              :       value: 1.0,
      70              :     );
      71            0 :     _dragController = AnimationController(
      72              :       vsync: this,
      73              :       duration: const Duration(milliseconds: 250),
      74              :     );
      75              :   }
      76              : 
      77            0 :   @override
      78              :   void dispose() {
      79            0 :     _playPauseController.dispose();
      80            0 :     _dragController.dispose();
      81            0 :     super.dispose();
      82              :   }
      83              : 
      84            0 :   @override
      85              :   Widget build(BuildContext context) {
      86            0 :     final theme = widget.theme;
      87            0 :     final texts = widget.texts ?? const GoogleCastPlayerTexts();
      88            0 :     return StreamBuilder<GoggleCastMediaStatus?>(
      89            0 :         stream: GoogleCastRemoteMediaClient.instance.mediaStatusStream,
      90            0 :         builder: (context, snapshot) {
      91            0 :           final mediaStatus = GoogleCastRemoteMediaClient.instance.mediaStatus;
      92              :           final deviceName = GoogleCastSessionManager
      93            0 :               .instance.currentSession?.device?.friendlyName;
      94              :           if (mediaStatus == null) return const SizedBox.shrink();
      95            0 :           return GestureDetector(
      96            0 :             onVerticalDragStart: (details) {
      97            0 :               _dragController.stop();
      98            0 :               setState(() {
      99            0 :                 _isDragging = true;
     100              :               });
     101              :             },
     102            0 :             onVerticalDragUpdate: (details) {
     103            0 :               setState(() {
     104            0 :                 _dragOffset += details.delta.dy;
     105              :                 // Apply resistance when dragging upward
     106            0 :                 if (_dragOffset < 0) {
     107            0 :                   _dragOffset *= 0.2; // More resistance upward
     108              :                 }
     109              :                 // Add some velocity-based smoothing
     110            0 :                 final velocity = details.delta.dy.abs();
     111            0 :                 if (velocity > 5) {
     112            0 :                   _dragOffset *= 0.95; // Slight smoothing for fast drags
     113              :                 }
     114            0 :                 _dragOffset = _dragOffset.clamp(-50.0, 300.0);
     115              :               });
     116              :             },
     117            0 :             onVerticalDragEnd: (details) {
     118            0 :               final velocity = details.velocity.pixelsPerSecond.dy;
     119            0 :               final shouldDismiss = _dragOffset > 60 ||
     120            0 :                   velocity > 600; // More responsive thresholds
     121              : 
     122              :               if (shouldDismiss) {
     123            0 :                 widget.toggleExpand?.call();
     124              :               } else {
     125              :                 // Smooth animation back to original position
     126            0 :                 final startOffset = _dragOffset;
     127            0 :                 _dragController.reset();
     128              : 
     129              :                 // Create a temporary listener for this animation
     130            0 :                 void animationListener() {
     131            0 :                   setState(() {
     132            0 :                     _dragOffset = startOffset * (1 - _dragController.value);
     133              :                   });
     134              :                 }
     135              : 
     136            0 :                 _dragController.addListener(animationListener);
     137              : 
     138            0 :                 _dragController
     139            0 :                     .animateTo(
     140              :                   1.0,
     141              :                   duration: const Duration(milliseconds: 400),
     142              :                   curve: Curves.elasticOut,
     143              :                 )
     144            0 :                     .then((_) {
     145            0 :                   _dragController.removeListener(animationListener);
     146            0 :                   setState(() {
     147            0 :                     _dragOffset = 0;
     148            0 :                     _isDragging = false;
     149              :                   });
     150              :                 });
     151              :               }
     152              :             },
     153            0 :             child: Transform.translate(
     154            0 :               offset: Offset(0, _dragOffset),
     155            0 :               child: Opacity(
     156            0 :                 opacity: _isDragging
     157            0 :                     ? (1 - (_dragOffset / 300))
     158            0 :                         .clamp(0.4, 1.0) // Slower opacity fade for better UX
     159              :                     : 1.0,
     160            0 :                 child: Scaffold(
     161              :                   extendBodyBehindAppBar: true,
     162            0 :                   appBar: AppBar(
     163              :                     elevation: 0,
     164              :                     backgroundColor: Colors.transparent,
     165            0 :                     leading: Container(
     166            0 :                       decoration: BoxDecoration(
     167            0 :                         color: Colors.black.withValues(alpha: 0.7),
     168            0 :                         borderRadius: BorderRadius.circular(16),
     169            0 :                         border: Border.all(
     170            0 :                           color: Colors.white.withValues(alpha: 0.2),
     171              :                           width: 1,
     172              :                         ),
     173              :                       ),
     174              :                       margin: const EdgeInsets.all(8),
     175            0 :                       child: IconButton(
     176            0 :                         onPressed: widget.toggleExpand,
     177              :                         icon: const Icon(
     178              :                           CupertinoIcons.chevron_down,
     179              :                           color: Colors.white,
     180              :                         ),
     181              :                       ),
     182              :                     ),
     183            0 :                     title: Hero(
     184              :                       tag: 'com.felnanuke.google_cast.controller.title',
     185            0 :                       child: Text(
     186            0 :                         texts.nowPlaying,
     187            0 :                         style: theme?.titleTextStyle ??
     188            0 :                             Theme.of(context).textTheme.titleLarge,
     189              :                       ),
     190              :                     ),
     191            0 :                     actions: [
     192            0 :                       Container(
     193            0 :                         decoration: BoxDecoration(
     194            0 :                           color: Colors.black.withValues(alpha: 0.7),
     195            0 :                           borderRadius: BorderRadius.circular(16),
     196            0 :                           border: Border.all(
     197            0 :                             color: Colors.white.withValues(alpha: 0.2),
     198              :                             width: 1,
     199              :                           ),
     200              :                         ),
     201              :                         margin: const EdgeInsets.all(8),
     202            0 :                         child: IconButton(
     203            0 :                           onPressed: _onCastPressed,
     204              :                           icon: const Icon(
     205              :                             Icons.cast,
     206              :                             color: Colors.white,
     207              :                           ),
     208              :                         ),
     209              :                       )
     210              :                     ],
     211              :                   ),
     212            0 :                   body: Stack(
     213              :                     fit: StackFit.expand,
     214            0 :                     children: [
     215            0 :                       if (theme?.backgroundWidget != null)
     216            0 :                         theme!.backgroundWidget!
     217              :                       else
     218            0 :                         Hero(
     219              :                           tag:
     220              :                               'com.felnanuke.google_cast.controller.background',
     221            0 :                           child: _buildBackgroundImage(mediaStatus),
     222              :                         ),
     223              :                       // Blurred overlay for better contrast
     224            0 :                       BackdropFilter(
     225            0 :                         filter: ImageFilter.blur(sigmaX: 16, sigmaY: 16),
     226            0 :                         child: Container(
     227            0 :                           color: Colors.black.withValues(alpha: 0.3),
     228              :                         ),
     229              :                       ),
     230              :                       // Drag handle indicator
     231            0 :                       Positioned(
     232              :                         top: 8,
     233              :                         left: 0,
     234              :                         right: 0,
     235            0 :                         child: Center(
     236            0 :                           child: AnimatedContainer(
     237              :                             duration: const Duration(milliseconds: 150),
     238            0 :                             width: _isDragging ? 70 : 40,
     239            0 :                             height: _isDragging ? 5 : 4,
     240            0 :                             decoration: BoxDecoration(
     241            0 :                               color: _isDragging
     242            0 :                                   ? Colors.white.withValues(alpha: 0.9)
     243            0 :                                   : Colors.white.withValues(alpha: 0.5),
     244            0 :                               borderRadius: BorderRadius.circular(3),
     245            0 :                               boxShadow: _isDragging
     246            0 :                                   ? [
     247            0 :                                       BoxShadow(
     248              :                                         color:
     249            0 :                                             Colors.white.withValues(alpha: 0.4),
     250              :                                         blurRadius: 12,
     251              :                                         spreadRadius: 2,
     252              :                                       ),
     253              :                                     ]
     254            0 :                                   : [
     255            0 :                                       BoxShadow(
     256              :                                         color:
     257            0 :                                             Colors.black.withValues(alpha: 0.3),
     258              :                                         blurRadius: 4,
     259              :                                         offset: const Offset(0, 1),
     260              :                                       ),
     261              :                                     ],
     262              :                             ),
     263              :                           ),
     264              :                         ),
     265              :                       ),
     266            0 :                       Container(
     267            0 :                         decoration: BoxDecoration(
     268            0 :                           borderRadius: BorderRadius.circular(32),
     269            0 :                           gradient: theme?.backgroundGradient ??
     270            0 :                               LinearGradient(
     271              :                                   tileMode: TileMode.clamp,
     272              :                                   begin: Alignment.topCenter,
     273              :                                   end: Alignment.bottomCenter,
     274            0 :                                   colors: [
     275              :                                     const Color(0xFF232526),
     276              :                                     const Color(0xFF414345),
     277            0 :                                     Colors.black.withValues(alpha: 0.7),
     278              :                                   ]),
     279              :                         ),
     280              :                       ),
     281              :                       // Media Image Display
     282            0 :                       Center(
     283            0 :                         child: Padding(
     284              :                           padding: const EdgeInsets.symmetric(horizontal: 32),
     285            0 :                           child: Column(
     286              :                             mainAxisAlignment: MainAxisAlignment.center,
     287            0 :                             children: [
     288              :                               // Media Image
     289            0 :                               Container(
     290            0 :                                 constraints: BoxConstraints(
     291            0 :                                   maxWidth: theme?.imageMaxWidth ?? 300,
     292            0 :                                   maxHeight: theme?.imageMaxHeight ?? 300,
     293              :                                 ),
     294            0 :                                 decoration: BoxDecoration(
     295            0 :                                   borderRadius: theme?.imageBorderRadius ??
     296            0 :                                       BorderRadius.circular(16),
     297            0 :                                   boxShadow: theme?.imageShadow ??
     298            0 :                                       [
     299            0 :                                         BoxShadow(
     300              :                                           color: Colors.black
     301            0 :                                               .withValues(alpha: 0.4),
     302              :                                           blurRadius: 20,
     303              :                                           offset: const Offset(0, 8),
     304              :                                         ),
     305              :                                       ],
     306              :                                 ),
     307            0 :                                 child: ClipRRect(
     308            0 :                                   borderRadius: theme?.imageBorderRadius ??
     309            0 :                                       BorderRadius.circular(16),
     310            0 :                                   child: Hero(
     311              :                                     tag:
     312              :                                         'com.felnanuke.google_cast.controller.image',
     313            0 :                                     child: AspectRatio(
     314              :                                       aspectRatio: 1.0,
     315            0 :                                       child: _buildMediaImage(mediaStatus),
     316              :                                     ),
     317              :                                   ),
     318              :                                 ),
     319              :                               ),
     320              :                               const SizedBox(height: 24),
     321              :                               // Media Title and Subtitle with marquee scrolling
     322            0 :                               Container(
     323              :                                 width: double.infinity,
     324              :                                 padding:
     325              :                                     const EdgeInsets.symmetric(horizontal: 16),
     326            0 :                                 child: _buildTitleAndSubtitle(
     327              :                                     mediaStatus, texts, theme, context),
     328              :                               ),
     329              :                               const SizedBox(
     330              :                                   height:
     331              :                                       160), // Increased space for controls and device name positioning
     332              :                             ],
     333              :                           ),
     334              :                         ),
     335              :                       ),
     336            0 :                       Positioned(
     337              :                         bottom:
     338              :                             220, // Moved even higher to avoid slider overlap
     339              :                         left: 16,
     340              :                         right: 16,
     341            0 :                         child: Center(
     342            0 :                           child: Container(
     343              :                             margin: const EdgeInsets.symmetric(horizontal: 32),
     344              :                             padding: const EdgeInsets.symmetric(
     345              :                                 horizontal: 16, vertical: 8),
     346            0 :                             decoration: BoxDecoration(
     347            0 :                               color: Colors.black.withValues(alpha: 0.4),
     348            0 :                               borderRadius: BorderRadius.circular(16),
     349            0 :                               boxShadow: [
     350            0 :                                 BoxShadow(
     351            0 :                                   color: Colors.black.withValues(alpha: 0.2),
     352              :                                   blurRadius: 8,
     353              :                                   offset: const Offset(0, 2),
     354              :                                 ),
     355              :                               ],
     356              :                             ),
     357            0 :                             child: Text(
     358            0 :                               texts.castingToDevice(
     359              :                                   deviceName ?? 'Unknown Device'),
     360            0 :                               style: theme?.deviceTextStyle ??
     361            0 :                                   Theme.of(context)
     362            0 :                                       .textTheme
     363            0 :                                       .titleSmall
     364            0 :                                       ?.copyWith(
     365              :                                         color: Colors.white,
     366              :                                       ),
     367              :                               textAlign: TextAlign.center,
     368              :                               maxLines: 1,
     369              :                               overflow: TextOverflow.ellipsis,
     370              :                             ),
     371              :                           ),
     372              :                         ),
     373              :                       ),
     374            0 :                       Positioned(
     375              :                         bottom: 24,
     376              :                         left: 0,
     377              :                         right: 0,
     378            0 :                         child: Column(
     379              :                           mainAxisSize: MainAxisSize.min,
     380            0 :                           children: [
     381            0 :                             Padding(
     382              :                               padding:
     383              :                                   const EdgeInsets.symmetric(horizontal: 24),
     384            0 :                               child: Row(
     385              :                                 mainAxisAlignment:
     386              :                                     MainAxisAlignment.spaceEvenly,
     387            0 :                                 children: [
     388            0 :                                   StreamBuilder<Duration?>(
     389              :                                     stream: GoogleCastRemoteMediaClient
     390            0 :                                         .instance.playerPositionStream,
     391            0 :                                     builder: (context, snapshot) {
     392            0 :                                       return SizedBox(
     393              :                                         width: 48,
     394            0 :                                         child: Text(
     395            0 :                                           _isSliding
     396            0 :                                               ? _getDurationToSeek(
     397            0 :                                                       _sliderPercentage,
     398              :                                                       mediaStatus)
     399            0 :                                                   .formatted
     400              :                                               : GoogleCastRemoteMediaClient
     401            0 :                                                   .instance
     402            0 :                                                   .playerPosition
     403            0 :                                                   .formatted,
     404            0 :                                           style: theme?.timeTextStyle ??
     405            0 :                                               Theme.of(context)
     406            0 :                                                   .textTheme
     407            0 :                                                   .bodySmall
     408            0 :                                                   ?.copyWith(
     409              :                                                     color: Colors.white,
     410              :                                                   ),
     411              :                                         ),
     412              :                                       );
     413              :                                     },
     414              :                                   ),
     415            0 :                                   _buildSlider(mediaStatus, theme),
     416            0 :                                   SizedBox(
     417              :                                     width: 48,
     418            0 :                                     child: Text(
     419            0 :                                       mediaStatus.mediaInformation?.duration
     420            0 :                                               ?.formatted ??
     421              :                                           '-',
     422            0 :                                       style: theme?.timeTextStyle ??
     423            0 :                                           Theme.of(context)
     424            0 :                                               .textTheme
     425            0 :                                               .bodySmall
     426            0 :                                               ?.copyWith(
     427              :                                                 color: Colors.white,
     428              :                                               ),
     429              :                                     ),
     430              :                                   ),
     431              :                                 ],
     432              :                               ),
     433              :                             ),
     434              :                             const SizedBox(height: 12),
     435            0 :                             Container(
     436              :                               padding: const EdgeInsets.symmetric(
     437              :                                   horizontal: 16, vertical: 12),
     438            0 :                               decoration: BoxDecoration(
     439            0 :                                 color: Colors.black.withValues(alpha: 0.4),
     440            0 :                                 borderRadius: BorderRadius.circular(32),
     441            0 :                                 boxShadow: [
     442            0 :                                   BoxShadow(
     443            0 :                                     color: Colors.black.withValues(alpha: 0.15),
     444              :                                     blurRadius: 12,
     445              :                                     offset: const Offset(0, 4),
     446              :                                   ),
     447              :                                 ],
     448              :                               ),
     449            0 :                               child: Column(
     450              :                                 mainAxisSize: MainAxisSize.min,
     451            0 :                                 children: [
     452              :                                   // Main playback controls row
     453            0 :                                   Row(
     454              :                                     mainAxisAlignment:
     455              :                                         MainAxisAlignment.spaceEvenly,
     456            0 :                                     children: [
     457            0 :                                       IconButton(
     458            0 :                                         onPressed: _previous,
     459              :                                         icon: const Icon(Icons.skip_previous),
     460            0 :                                         iconSize: theme?.iconSize ?? 40,
     461            0 :                                         color: theme?.iconColor ?? Colors.white,
     462              :                                       ),
     463            0 :                                       IconButton(
     464            0 :                                         onPressed: _seekBackward30,
     465              :                                         icon: const Icon(Icons.replay_30),
     466            0 :                                         iconSize: theme?.iconSize ?? 40,
     467            0 :                                         color: theme?.iconColor ?? Colors.white,
     468              :                                       ),
     469            0 :                                       GestureDetector(
     470            0 :                                         onTap: () {
     471            0 :                                           _togglePlayAndPause(
     472            0 :                                               mediaStatus.playerState);
     473            0 :                                           if (mediaStatus.playerState ==
     474              :                                               CastMediaPlayerState.playing) {
     475            0 :                                             _playPauseController.reverse();
     476              :                                           } else {
     477            0 :                                             _playPauseController.forward();
     478              :                                           }
     479              :                                         },
     480            0 :                                         child: AnimatedIcon(
     481              :                                           icon: AnimatedIcons.play_pause,
     482            0 :                                           progress: _playPauseController,
     483              :                                           color:
     484            0 :                                               theme?.iconColor ?? Colors.white,
     485            0 :                                           size: theme?.iconSize != null
     486            0 :                                               ? theme!.iconSize! + 16
     487              :                                               : 56,
     488              :                                         ),
     489              :                                       ),
     490            0 :                                       IconButton(
     491            0 :                                         onPressed: _seekForward30,
     492              :                                         icon: const Icon(Icons.forward_30),
     493            0 :                                         iconSize: theme?.iconSize ?? 40,
     494            0 :                                         color: theme?.iconColor ?? Colors.white,
     495              :                                       ),
     496            0 :                                       IconButton(
     497            0 :                                         color: theme?.iconColor ?? Colors.white,
     498              :                                         onPressed: GoogleCastRemoteMediaClient
     499            0 :                                                 .instance.queueHasNextItem
     500            0 :                                             ? _next
     501              :                                             : null,
     502              :                                         disabledColor:
     503            0 :                                             theme?.disabledIconColor ??
     504              :                                                 Colors.grey,
     505              :                                         icon: const Icon(Icons.skip_next),
     506            0 :                                         iconSize: theme?.iconSize ?? 40,
     507              :                                       ),
     508              :                                     ],
     509              :                                   ),
     510              :                                   const SizedBox(height: 8),
     511              :                                   // Additional controls row
     512            0 :                                   Row(
     513              :                                     mainAxisAlignment:
     514              :                                         MainAxisAlignment.spaceEvenly,
     515            0 :                                     children: [
     516            0 :                                       PopupMenuButton<int>(
     517            0 :                                         itemBuilder: (context) =>
     518            0 :                                             _buildCaptionMenuItems(
     519              :                                                 mediaStatus, texts, theme),
     520            0 :                                         onSelected: (trackId) =>
     521            0 :                                             _toggleTextTrack(
     522              :                                                 trackId, mediaStatus),
     523            0 :                                         icon: Icon(
     524              :                                           Icons.closed_caption,
     525              :                                           color:
     526            0 :                                               theme?.iconColor ?? Colors.white,
     527              :                                         ),
     528            0 :                                         iconSize: theme?.iconSize ?? 40,
     529            0 :                                         color: theme?.popupBackgroundColor ??
     530              :                                             const Color(0xFF333333),
     531            0 :                                         shape: RoundedRectangleBorder(
     532              :                                           borderRadius:
     533            0 :                                               BorderRadius.circular(8),
     534              :                                         ),
     535              :                                       ),
     536            0 :                                       GoogleCastVolume(
     537              :                                         iconColor:
     538            0 :                                             theme?.iconColor ?? Colors.white,
     539            0 :                                         iconSize: theme?.iconSize ?? 40,
     540              :                                         popupBackgroundColor:
     541            0 :                                             theme?.popupBackgroundColor ??
     542              :                                                 const Color(0xFF333333),
     543              :                                         sliderActiveColor:
     544            0 :                                             theme?.volumeSliderActiveColor,
     545              :                                         sliderInactiveColor:
     546            0 :                                             theme?.volumeSliderInactiveColor,
     547              :                                         sliderThumbColor:
     548            0 :                                             theme?.volumeSliderThumbColor,
     549              :                                       ),
     550              :                                     ],
     551              :                                   ),
     552              :                                 ],
     553              :                               ),
     554              :                             )
     555              :                           ],
     556              :                         ),
     557              :                       ),
     558              :                     ],
     559              :                   ),
     560              :                 ),
     561              :               ),
     562              :             ),
     563              :           );
     564              :         });
     565              :   }
     566              : 
     567            0 :   Expanded _buildSlider(
     568              :       GoggleCastMediaStatus? mediaStatus, GoogleCastPlayerTheme? theme) {
     569            0 :     return Expanded(
     570            0 :       child: StreamBuilder<Duration>(
     571            0 :           stream: GoogleCastRemoteMediaClient.instance.playerPositionStream,
     572            0 :           builder: (context, snapshot) {
     573            0 :             return SliderTheme(
     574            0 :               data: SliderTheme.of(context).copyWith(
     575              :                 thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 10),
     576              :                 overlayShape: const RoundSliderOverlayShape(overlayRadius: 18),
     577              :                 trackHeight: 4,
     578              :                 activeTrackColor:
     579            0 :                     theme?.iconColor ?? Theme.of(context).colorScheme.primary,
     580              :                 inactiveTrackColor:
     581            0 :                     theme?.iconColor?.withValues(alpha: 0.3) ?? Colors.white30,
     582            0 :                 thumbColor: theme?.iconColor ?? Colors.white,
     583              :                 overlayColor:
     584            0 :                     (theme?.iconColor ?? Colors.white).withValues(alpha: 0.2),
     585              :               ),
     586            0 :               child: Slider(
     587            0 :                 value: _isSliding
     588            0 :                     ? _sliderPercentage
     589            0 :                     : _getProgressPercentage(
     590              :                         mediaStatus,
     591            0 :                         GoogleCastRemoteMediaClient.instance.playerPosition,
     592              :                       ),
     593            0 :                 onChanged: _onSliderChanged,
     594            0 :                 onChangeStart: _onSliderStarts,
     595            0 :                 onChangeEnd: (value) => _onSliderEnd.call(value, mediaStatus!),
     596              :               ),
     597              :             );
     598              :           }),
     599              :     );
     600              :   }
     601              : 
     602            0 :   void _onCastPressed() async {
     603              :     try {
     604            0 :       await GoogleCastSessionManager.instance.endSession();
     605              :     } catch (e) {
     606              :       // Handle potential errors silently
     607              :       // In a production app, you might want to show a snackbar or log the error
     608            0 :       debugPrint('Failed to end cast session: $e');
     609              :     }
     610              :   }
     611              : 
     612            0 :   void _previous() => GoogleCastRemoteMediaClient.instance.queuePrevItem();
     613              : 
     614            0 :   void _togglePlayAndPause(CastMediaPlayerState playerState) {
     615              :     switch (playerState) {
     616            0 :       case CastMediaPlayerState.playing:
     617            0 :         GoogleCastRemoteMediaClient.instance.pause();
     618              :         break;
     619            0 :       case CastMediaPlayerState.paused:
     620            0 :         GoogleCastRemoteMediaClient.instance.play();
     621              :         break;
     622              :       default:
     623              :     }
     624              :   }
     625              : 
     626            0 :   void _next() => GoogleCastRemoteMediaClient.instance.queueNextItem();
     627              : 
     628            0 :   void _seekBackward30() {
     629            0 :     GoogleCastRemoteMediaClient.instance.seek(
     630            0 :       GoogleCastMediaSeekOption(
     631              :         position: const Duration(seconds: -30),
     632              :         relative: true,
     633              :         resumeState: GoogleCastMediaResumeState.play,
     634              :       ),
     635              :     );
     636              :   }
     637              : 
     638            0 :   void _seekForward30() {
     639            0 :     GoogleCastRemoteMediaClient.instance.seek(
     640            0 :       GoogleCastMediaSeekOption(
     641              :         position: const Duration(seconds: 30),
     642              :         relative: true,
     643              :         resumeState: GoogleCastMediaResumeState.play,
     644              :       ),
     645              :     );
     646              :   }
     647              : 
     648            0 :   double _getProgressPercentage(
     649              :       GoggleCastMediaStatus? mediaStatus, Duration playerPosition) {
     650              :     final mediaDuration =
     651            0 :         mediaStatus?.mediaInformation?.duration ?? Duration.zero;
     652              : 
     653            0 :     if (mediaDuration.inSeconds == 0) return 0;
     654              : 
     655            0 :     if (playerPosition.inSeconds == 0) return 0;
     656              : 
     657            0 :     return playerPosition.inSeconds / mediaDuration.inSeconds;
     658              :   }
     659              : 
     660            0 :   void _onSliderStarts(double value) {
     661            0 :     setState(() {
     662            0 :       _isSliding = true;
     663            0 :       _sliderPercentage = value;
     664              :     });
     665              :   }
     666              : 
     667            0 :   void _onSliderChanged(double value) {
     668            0 :     setState(() {
     669            0 :       _sliderPercentage = value;
     670              :     });
     671              :   }
     672              : 
     673            0 :   void _onSliderEnd(double value, GoggleCastMediaStatus mediaStatus) {
     674            0 :     setState(() {
     675            0 :       _isSliding = false;
     676              :     });
     677            0 :     final durationToSeek = _getDurationToSeek(value, mediaStatus);
     678            0 :     GoogleCastRemoteMediaClient.instance.seek(
     679            0 :       GoogleCastMediaSeekOption(position: durationToSeek),
     680              :     );
     681              :   }
     682              : 
     683            0 :   Duration _getDurationToSeek(double value, GoggleCastMediaStatus mediaStatus) {
     684            0 :     final duration = mediaStatus.mediaInformation?.duration ?? Duration.zero;
     685            0 :     final secondsToSeek = duration.inSeconds * value;
     686            0 :     final durationToSeek = Duration(seconds: secondsToSeek.round());
     687              :     return durationToSeek;
     688              :   }
     689              : 
     690            0 :   Widget _buildMediaImage(GoggleCastMediaStatus? mediaStatus) {
     691            0 :     final theme = widget.theme;
     692            0 :     final images = mediaStatus?.mediaInformation?.metadata?.images;
     693              : 
     694              :     // Try to find the best image (largest one or first available)
     695              :     String? imageUrl;
     696            0 :     if (images != null && images.isNotEmpty) {
     697              :       // Sort by size and pick the largest, or just use the first one
     698            0 :       final sortedImages = List<GoogleCastImage>.from(images);
     699            0 :       sortedImages.sort((a, b) {
     700            0 :         final aSize = (a.width ?? 0) * (a.height ?? 0);
     701            0 :         final bSize = (b.width ?? 0) * (b.height ?? 0);
     702            0 :         return bSize.compareTo(aSize); // Descending order
     703              :       });
     704            0 :       imageUrl = sortedImages.first.url.toString();
     705              :     }
     706              : 
     707            0 :     if (imageUrl != null && imageUrl.isNotEmpty) {
     708            0 :       return Image.network(
     709              :         imageUrl,
     710            0 :         fit: theme?.imageFit ?? BoxFit.cover,
     711            0 :         loadingBuilder: (context, child, loadingProgress) {
     712              :           if (loadingProgress == null) return child;
     713            0 :           return Container(
     714            0 :             color: Colors.grey[800],
     715            0 :             child: Center(
     716            0 :               child: CircularProgressIndicator(
     717            0 :                 value: loadingProgress.expectedTotalBytes != null
     718            0 :                     ? loadingProgress.cumulativeBytesLoaded /
     719            0 :                         loadingProgress.expectedTotalBytes!
     720              :                     : null,
     721              :                 color: Colors.white54,
     722              :               ),
     723              :             ),
     724              :           );
     725              :         },
     726            0 :         errorBuilder: (context, error, stackTrace) =>
     727            0 :             theme?.noImageFallback ??
     728            0 :             Container(
     729            0 :               color: Colors.grey[800],
     730              :               child: const Icon(
     731              :                 Icons.music_note,
     732              :                 size: 80,
     733              :                 color: Colors.white54,
     734              :               ),
     735              :             ),
     736              :       );
     737              :     }
     738              : 
     739              :     // Use custom fallback or default
     740            0 :     return theme?.noImageFallback ??
     741            0 :         Container(
     742            0 :           color: Colors.grey[800],
     743            0 :           child: Icon(
     744            0 :             _getMediaIcon(
     745            0 :                 mediaStatus?.mediaInformation?.metadata?.metadataType),
     746              :             size: 80,
     747              :             color: Colors.white54,
     748              :           ),
     749              :         );
     750              :   }
     751              : 
     752            0 :   IconData _getMediaIcon(GoogleCastMediaMetadataType? metadataType) {
     753              :     switch (metadataType) {
     754            0 :       case GoogleCastMediaMetadataType.movieMediaMetadata:
     755              :         return Icons.movie;
     756            0 :       case GoogleCastMediaMetadataType.musicTrackMediaMetadata:
     757              :         return Icons.music_note;
     758            0 :       case GoogleCastMediaMetadataType.tvShowMediaMetadata:
     759              :         return Icons.tv;
     760            0 :       case GoogleCastMediaMetadataType.photoMediaMetadata:
     761              :         return Icons.photo;
     762              :       case GoogleCastMediaMetadataType.genericMediaMetadata:
     763              :       default:
     764              :         return Icons.play_circle_filled;
     765              :     }
     766              :   }
     767              : 
     768            0 :   Widget _buildBackgroundImage(GoggleCastMediaStatus? mediaStatus) {
     769            0 :     final images = mediaStatus?.mediaInformation?.metadata?.images;
     770              : 
     771              :     // Try to find the best image for background
     772              :     String? imageUrl;
     773            0 :     if (images != null && images.isNotEmpty) {
     774            0 :       final sortedImages = List<GoogleCastImage>.from(images);
     775            0 :       sortedImages.sort((a, b) {
     776            0 :         final aSize = (a.width ?? 0) * (a.height ?? 0);
     777            0 :         final bSize = (b.width ?? 0) * (b.height ?? 0);
     778            0 :         return bSize.compareTo(aSize); // Descending order
     779              :       });
     780            0 :       imageUrl = sortedImages.first.url.toString();
     781              :     }
     782              : 
     783            0 :     if (imageUrl != null && imageUrl.isNotEmpty) {
     784            0 :       return Image.network(
     785              :         imageUrl,
     786              :         fit: BoxFit.cover,
     787            0 :         errorBuilder: (context, error, stackTrace) => Container(
     788            0 :           color: Colors.grey[900],
     789              :           child: const Icon(
     790              :             Icons.music_note,
     791              :             size: 100,
     792              :             color: Colors.white54,
     793              :           ),
     794              :         ),
     795              :       );
     796              :     }
     797              : 
     798            0 :     return Container(
     799            0 :       color: Colors.grey[900],
     800            0 :       child: Icon(
     801            0 :         _getMediaIcon(mediaStatus?.mediaInformation?.metadata?.metadataType),
     802              :         size: 100,
     803              :         color: Colors.white54,
     804              :       ),
     805              :     );
     806              :   }
     807              : 
     808              :   /// Build menu items for available text tracks
     809            0 :   List<PopupMenuEntry<int>> _buildCaptionMenuItems(
     810              :       GoggleCastMediaStatus? mediaStatus,
     811              :       GoogleCastPlayerTexts texts,
     812              :       GoogleCastPlayerTheme? theme) {
     813            0 :     final tracks = mediaStatus?.mediaInformation?.tracks;
     814            0 :     final activeTrackIds = mediaStatus?.activeTrackIds ?? [];
     815              : 
     816            0 :     if (tracks == null || tracks.isEmpty) {
     817            0 :       return [
     818            0 :         PopupMenuItem<int>(
     819              :           enabled: false,
     820            0 :           child: Text(
     821            0 :             texts.noCaptionsAvailable,
     822            0 :             style: theme?.popupTextStyle ??
     823            0 :                 TextStyle(
     824            0 :                   color: theme?.popupTextColor ?? Colors.white,
     825              :                 ),
     826              :           ),
     827              :         ),
     828              :       ];
     829              :     }
     830              : 
     831              :     final textTracks = tracks
     832            0 :         .where((track) =>
     833            0 :             track.type == TrackType.text ||
     834            0 :             track.subtype == TextTrackType.subtitles ||
     835            0 :             track.subtype == TextTrackType.captions)
     836            0 :         .toList();
     837              : 
     838            0 :     if (textTracks.isEmpty) {
     839            0 :       return [];
     840              :     }
     841              : 
     842            0 :     final menuItems = <PopupMenuEntry<int>>[];
     843              : 
     844              :     // Add "Off" option
     845            0 :     menuItems.add(
     846            0 :       PopupMenuItem<int>(
     847            0 :         value: -1,
     848            0 :         child: Row(
     849            0 :           children: [
     850            0 :             Icon(
     851            0 :               activeTrackIds.isEmpty ? Icons.check : null,
     852            0 :               color: theme?.popupTextColor ?? Colors.white,
     853              :               size: 20,
     854              :             ),
     855              :             const SizedBox(width: 8),
     856            0 :             Text(
     857            0 :               texts.captionsOff,
     858            0 :               style: theme?.popupTextStyle ??
     859            0 :                   TextStyle(
     860            0 :                     color: theme?.popupTextColor ?? Colors.white,
     861              :                   ),
     862              :             ),
     863              :           ],
     864              :         ),
     865              :       ),
     866              :     );
     867              : 
     868              :     // Add available text tracks
     869            0 :     for (final track in textTracks) {
     870            0 :       final isActive = activeTrackIds.contains(track.trackId);
     871            0 :       menuItems.add(
     872            0 :         PopupMenuItem<int>(
     873            0 :           value: track.trackId,
     874            0 :           child: Row(
     875            0 :             children: [
     876            0 :               Icon(
     877              :                 isActive ? Icons.check : null,
     878            0 :                 color: theme?.popupTextColor ?? Colors.white,
     879              :                 size: 20,
     880              :               ),
     881              :               const SizedBox(width: 8),
     882            0 :               Expanded(
     883            0 :                 child: Text(
     884            0 :                   track.name ??
     885            0 :                       track.language?.toString() ??
     886            0 :                       texts.trackFallback(track.trackId),
     887              :                   overflow: TextOverflow.ellipsis,
     888            0 :                   style: theme?.popupTextStyle ??
     889            0 :                       TextStyle(
     890            0 :                         color: theme?.popupTextColor ?? Colors.white,
     891              :                       ),
     892              :                 ),
     893              :               ),
     894              :             ],
     895              :           ),
     896              :         ),
     897              :       );
     898              :     }
     899              : 
     900              :     return menuItems;
     901              :   }
     902              : 
     903              :   /// Toggle text track on/off
     904            0 :   void _toggleTextTrack(int trackId, GoggleCastMediaStatus? mediaStatus) {
     905            0 :     final activeTrackIds = List<int>.from(mediaStatus?.activeTrackIds ?? []);
     906              : 
     907            0 :     if (trackId == -1) {
     908              :       // Turn off all text tracks
     909            0 :       final tracks = mediaStatus?.mediaInformation?.tracks;
     910              :       if (tracks != null) {
     911              :         final textTrackIds = tracks
     912            0 :             .where((track) =>
     913            0 :                 track.type == TrackType.text ||
     914            0 :                 track.subtype == TextTrackType.subtitles ||
     915            0 :                 track.subtype == TextTrackType.captions)
     916            0 :             .map((track) => track.trackId)
     917            0 :             .toList();
     918              : 
     919            0 :         activeTrackIds.removeWhere((id) => textTrackIds.contains(id));
     920              :       }
     921              :     } else {
     922              :       // Toggle specific track
     923            0 :       if (activeTrackIds.contains(trackId)) {
     924            0 :         activeTrackIds.remove(trackId);
     925              :       } else {
     926              :         // Remove other text tracks first (only one text track at a time)
     927            0 :         final tracks = mediaStatus?.mediaInformation?.tracks;
     928              :         if (tracks != null) {
     929              :           final textTrackIds = tracks
     930            0 :               .where((track) =>
     931            0 :                   track.type == TrackType.text ||
     932            0 :                   track.subtype == TextTrackType.subtitles ||
     933            0 :                   track.subtype == TextTrackType.captions)
     934            0 :               .map((track) => track.trackId)
     935            0 :               .toList();
     936              : 
     937            0 :           activeTrackIds.removeWhere((id) => textTrackIds.contains(id));
     938              :         }
     939            0 :         activeTrackIds.add(trackId);
     940              :       }
     941              :     }
     942              : 
     943              :     // Update active tracks
     944            0 :     GoogleCastRemoteMediaClient.instance.setActiveTrackIDs(activeTrackIds);
     945              :   }
     946              : 
     947              :   /// Build title and subtitle with marquee scrolling, displayed vertically
     948            0 :   Widget _buildTitleAndSubtitle(
     949              :       GoggleCastMediaStatus mediaStatus,
     950              :       GoogleCastPlayerTexts texts,
     951              :       GoogleCastPlayerTheme? theme,
     952              :       BuildContext context) {
     953            0 :     final title = mediaStatus.mediaInformation?.metadata?.extractedTitle ??
     954            0 :         texts.unknownTitle;
     955            0 :     final subtitle = mediaStatus.mediaInformation?.metadata?.extractedSubtitle;
     956              : 
     957            0 :     final titleStyle = theme?.titleTextStyle ??
     958            0 :         Theme.of(context).textTheme.headlineSmall?.copyWith(
     959              :               color: Colors.white,
     960              :               fontWeight: FontWeight.bold,
     961              :             );
     962              : 
     963            0 :     final subtitleStyle = theme?.titleTextStyle?.copyWith(
     964            0 :           fontSize: (theme.titleTextStyle?.fontSize ?? 20) * 0.8,
     965              :           fontWeight: FontWeight.normal,
     966              :           color: Colors.white70,
     967              :         ) ??
     968            0 :         Theme.of(context).textTheme.titleMedium?.copyWith(
     969              :               color: Colors.white70,
     970              :               fontWeight: FontWeight.normal,
     971              :             );
     972              : 
     973            0 :     return Column(
     974              :       mainAxisSize: MainAxisSize.min,
     975            0 :       children: [
     976              :         // Title with marquee
     977            0 :         SizedBox(
     978              :           height: 40,
     979            0 :           child: _buildMarqueeText(title, titleStyle),
     980              :         ),
     981            0 :         if (subtitle != null && subtitle.isNotEmpty) ...[
     982              :           const SizedBox(height: 8),
     983              :           // Subtitle with marquee - constrained height to prevent overlap
     984            0 :           ConstrainedBox(
     985              :             constraints: const BoxConstraints(
     986              :               maxHeight: 30,
     987              :               minHeight: 30,
     988              :             ),
     989            0 :             child: _buildMarqueeText(subtitle, subtitleStyle),
     990              :           ),
     991              :         ],
     992              :       ],
     993              :     );
     994              :   }
     995              : 
     996              :   /// Build a marquee text widget
     997            0 :   Widget _buildMarqueeText(String text, TextStyle? style) {
     998            0 :     return Marquee(
     999              :       text: text,
    1000              :       style: style,
    1001              :       scrollAxis: Axis.horizontal,
    1002              :       crossAxisAlignment: CrossAxisAlignment.center,
    1003              :       blankSpace: 50.0,
    1004              :       velocity: 50.0,
    1005              :       pauseAfterRound: const Duration(seconds: 2),
    1006              :       startPadding: 10.0,
    1007              :       accelerationDuration: const Duration(milliseconds: 500),
    1008              :       accelerationCurve: Curves.linear,
    1009              :       decelerationDuration: const Duration(milliseconds: 500),
    1010              :       decelerationCurve: Curves.easeOut,
    1011              :       startAfter: const Duration(milliseconds: 1000),
    1012              :     );
    1013              :   }
    1014              : }
        

Generated by: LCOV version 2.3.1-1