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 : }
|