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