import 'dart:math' as math; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:lottie/lottie.dart'; import 'package:video_player/video_player.dart'; import 'package:youtube_player_flutter/youtube_player_flutter.dart'; import '../widgets/app_gradients.dart'; import '../widgets/entrance.dart'; import '../widgets/tap_bounce.dart'; // Video data structure - easily editable for future updates. // Episódios 1-7 tocam via YouTube (não listado); preencha youtubeId ao subir // cada vídeo. Episódios 8-13 continuam embutidos no app (assets/videos). class VideoData { final int id; final String title; final String description; final String? videoPath; final String? youtubeId; VideoData({ required this.id, required this.title, required this.description, this.videoPath, this.youtubeId, }); } // List of all videos - edit titles and descriptions here final List videoList = [ VideoData( id: 1, title: 'Episódio 1', description: 'Aprenda sobre saúde bucal neste episódio', youtubeId: '', // TODO: colar o ID do vídeo do YouTube (não listado) ), VideoData( id: 2, title: 'Episódio 2', description: 'Aprenda sobre saúde bucal neste episódio', youtubeId: '', // TODO: colar o ID do vídeo do YouTube (não listado) ), VideoData( id: 3, title: 'Episódio 3', description: 'Aprenda sobre saúde bucal neste episódio', youtubeId: '', // TODO: colar o ID do vídeo do YouTube (não listado) ), VideoData( id: 4, title: 'Episódio 4', description: 'Aprenda sobre saúde bucal neste episódio', youtubeId: '', // TODO: colar o ID do vídeo do YouTube (não listado) ), VideoData( id: 5, title: 'Episódio 5', description: 'Aprenda sobre saúde bucal neste episódio', youtubeId: '', // TODO: colar o ID do vídeo do YouTube (não listado) ), VideoData( id: 6, title: 'Episódio 6', description: 'Aprenda sobre saúde bucal neste episódio', youtubeId: '', // TODO: colar o ID do vídeo do YouTube (não listado) ), VideoData( id: 7, title: 'Episódio 7', description: 'Aprenda sobre saúde bucal neste episódio', youtubeId: '', // TODO: colar o ID do vídeo do YouTube (não listado) ), VideoData( id: 8, title: 'Episódio 8', description: 'Aprenda sobre saúde bucal neste episódio', videoPath: 'assets/videos/episodio_08.mp4', ), VideoData( id: 9, title: 'Episódio 9', description: 'Aprenda sobre saúde bucal neste episódio', videoPath: 'assets/videos/episodio_09.mp4', ), VideoData( id: 10, title: 'Episódio 10', description: 'Aprenda sobre saúde bucal neste episódio', videoPath: 'assets/videos/episodio_10.mp4', ), VideoData( id: 11, title: 'Episódio 11', description: 'Aprenda sobre saúde bucal neste episódio', videoPath: 'assets/videos/episodio_11.mp4', ), VideoData( id: 12, title: 'Episódio 12', description: 'Aprenda sobre saúde bucal neste episódio', videoPath: 'assets/videos/episodio_12.mp4', ), VideoData( id: 13, title: 'Episódio 13', description: 'Aprenda sobre saúde bucal neste episódio', videoPath: 'assets/videos/episodio_13.mp4', ), ]; // Cache de controllers de vídeo, partilhado entre qualquer ecrã que mostre // uma prévia (grelha de vídeos e o cartão em destaque na Home). Como vários // widgets podem apontar para o mesmo controller ao mesmo tempo, mantemos uma // contagem de referências para só o libertar quando ninguém mais o usa. final Map _videoControllerCache = {}; final Map _videoControllerRefCounts = {}; // Dispositivos (sobretudo emuladores) só suportam um número pequeno de // decodificadores de vídeo/áudio simultâneos. A grelha de vídeos pode ter // dezenas de prévias e o GridView constrói várias de uma vez ao rolar; sem // este limite, cada thumbnail tenta abrir o seu próprio decoder e o sistema // trava (era exatamente isto que causava o congelamento e perda de som ao // navegar por muitos vídeos). Acima do limite, a prévia simplesmente mostra // o ícone de play em vez do frame real do vídeo. const int _maxConcurrentThumbnailDecoders = 1; bool get _hasThumbnailDecoderSlot => _videoControllerCache.length < _maxConcurrentThumbnailDecoders; void _releaseVideoController(String path) { final remaining = (_videoControllerRefCounts[path] ?? 0) - 1; if (remaining > 0) { _videoControllerRefCounts[path] = remaining; return; } _videoControllerRefCounts.remove(path); _videoControllerCache.remove(path)?.dispose(); } /// Descarta TODOS os controllers de prévia em cache. Chamado antes de abrir /// um player real (dialog/fullscreen), que já precisa dos seus próprios /// decodificadores de vídeo e áudio — em dispositivos com pouca capacidade /// (sobretudo emuladores), manter qualquer prévia viva ao mesmo tempo é /// suficiente para esgotar os decodificadores e travar o aparelho. void _evictAllVideoControllers() { _videoControllerRefCounts.clear(); final controllers = _videoControllerCache.values.toList(); _videoControllerCache.clear(); for (final controller in controllers) { controller.dispose(); } } Future showVideoPlayerDialog(BuildContext context, VideoData video) { if (video.youtubeId != null) { if (video.youtubeId!.isEmpty) { ScaffoldMessenger.of( context, ).showSnackBar(const SnackBar(content: Text('Vídeo ainda não disponível'))); return Future.value(); } return showDialog( context: context, builder: (context) => _YoutubePlayerDialog(video: video), ); } return showDialog( context: context, builder: (context) => _VideoPlayerDialog(video: video), ); } class VideoScreen extends StatefulWidget { const VideoScreen({super.key}); static const Color _teal = Color(0xFF2F9E94); static const Color _accentPink = Color(0xFFFF55A7); @override State createState() => _VideoScreenState(); } class _VideoScreenState extends State { final TextEditingController _searchController = TextEditingController(); List _filteredVideos = videoList; @override void initState() { super.initState(); _filteredVideos = videoList; _searchController.addListener(_onSearchChanged); } @override void dispose() { _searchController.dispose(); // Não descartamos o cache global aqui: outros widgets (como o cartão em // destaque na Home) podem estar a usar os mesmos controllers. Cada // [VideoThumbnail] liberta a sua própria referência ao ser desmontado. super.dispose(); } void _onSearchChanged() { final query = _searchController.text.toLowerCase(); setState(() { _filteredVideos = videoList .where((video) => video.title.toLowerCase().contains(query)) .toList(); }); } @override Widget build(BuildContext context) { final size = MediaQuery.sizeOf(context); return Scaffold( appBar: AppBar( backgroundColor: VideoScreen._teal, foregroundColor: Colors.white, surfaceTintColor: VideoScreen._teal, elevation: 0, flexibleSpace: Container( decoration: const BoxDecoration(gradient: kAppBarGradient), ), title: const Text( 'Videos Educativos', style: TextStyle(fontWeight: FontWeight.w900), ), ), body: Stack( clipBehavior: Clip.none, children: [ Positioned.fill( child: Container( decoration: const BoxDecoration( gradient: LinearGradient( begin: Alignment.topCenter, end: Alignment.bottomCenter, colors: [Color(0xFFFFE6F1), Color(0xFFFFC9DF)], ), ), ), ), Positioned( left: -size.width * 0.40, bottom: -size.width * 0.45, child: IgnorePointer( child: SizedBox( width: size.width * 1.05, height: size.width * 1.05, child: Transform.rotate( angle: 35 * math.pi / 180, child: Opacity( opacity: 0.95, child: Lottie.asset( 'lottie/Liquid waves.json', fit: BoxFit.cover, repeat: true, ), ), ), ), ), ), SafeArea( child: Padding( padding: const EdgeInsets.all(16), child: Column( children: [ // Search bar Container( decoration: BoxDecoration( color: Colors.white.withValues(alpha: 0.9), borderRadius: BorderRadius.circular(16), boxShadow: [ BoxShadow( color: Colors.black.withValues(alpha: 0.08), blurRadius: 8, offset: const Offset(0, 2), ), ], ), child: TextField( controller: _searchController, decoration: InputDecoration( hintText: 'Pesquisar vídeos...', prefixIcon: const Icon( Icons.search, color: VideoScreen._teal, ), border: InputBorder.none, contentPadding: const EdgeInsets.symmetric( horizontal: 16, vertical: 14, ), ), ), ), const SizedBox(height: 16), // Video grid Expanded( child: _filteredVideos.isEmpty ? Center( child: Text( 'Nenhum vídeo encontrado', style: TextStyle( fontSize: 16, fontWeight: FontWeight.w600, color: Colors.black.withValues(alpha: 0.6), ), ), ) : GridView.builder( gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( crossAxisCount: 2, crossAxisSpacing: 12, mainAxisSpacing: 12, childAspectRatio: 0.85, ), itemCount: _filteredVideos.length, itemBuilder: (context, index) { return FadeSlideIn( delay: Duration( milliseconds: 40 * (index % 8), ), child: _VideoButton( video: _filteredVideos[index], ), ); }, ), ), ], ), ), ), ], ), ); } } /// Preview de um vídeo (frame local ou thumbnail do YouTube), reutilizável /// em qualquer card que precise mostrar "a cara" de um episódio. class VideoThumbnail extends StatefulWidget { const VideoThumbnail({ super.key, required this.video, this.borderRadius = 12, this.iconSize = 48, }); final VideoData video; final double borderRadius; final double iconSize; @override State createState() => _VideoThumbnailState(); } class _VideoThumbnailState extends State { VideoPlayerController? _controller; bool _isInitialized = false; bool _hasOwnRef = false; bool get _isYoutube => widget.video.youtubeId != null; /// Verdadeiro quando outro widget descartou o controller que este /// thumbnail estava a usar (ex.: ao abrir o player em dialog/fullscreen). /// Nesse caso [_controller] deixou de ser o mesmo objeto guardado no /// cache partilhado — nunca é seguro tocar num controller já disposed. bool get _isControllerStale => _controller != null && _videoControllerCache[widget.video.videoPath] != _controller; @override void initState() { super.initState(); if (!_isYoutube) _initializeVideo(); } Future _initializeVideo() async { final path = widget.video.videoPath!; final cached = _videoControllerCache[path]; if (cached != null) { _controller = cached; _videoControllerRefCounts[path] = (_videoControllerRefCounts[path] ?? 0) + 1; _hasOwnRef = true; try { await cached.seekTo(const Duration(seconds: 2)); await cached.pause(); if (mounted) setState(() => _isInitialized = true); } catch (_) { // O controller partilhado foi descartado por outra tela entretanto; // larga a referência inválida e cria um novo do zero. _releaseOwnRef(); _controller = null; if (mounted) await _createFreshController(path); } return; } if (!_hasThumbnailDecoderSlot) { // Já há decoders suficientes ativos noutras prévias; fica só com o // ícone de play em vez de arriscar sobrecarregar o dispositivo. if (mounted) setState(() => _isInitialized = false); return; } await _createFreshController(path); } Future _createFreshController(String path) async { final controller = VideoPlayerController.asset(path); _controller = controller; try { await controller.initialize(); await controller.seekTo(const Duration(seconds: 2)); await controller.pause(); _videoControllerCache[path] = controller; _videoControllerRefCounts[path] = (_videoControllerRefCounts[path] ?? 0) + 1; _hasOwnRef = true; if (mounted) setState(() => _isInitialized = true); } catch (e) { if (mounted) setState(() => _isInitialized = false); } } void _releaseOwnRef() { final path = widget.video.videoPath; if (_hasOwnRef && path != null) { _releaseVideoController(path); _hasOwnRef = false; } } @override void dispose() { _releaseOwnRef(); super.dispose(); } @override Widget build(BuildContext context) { if (_isYoutube) { final youtubeId = widget.video.youtubeId!; if (youtubeId.isEmpty) { return Center( child: Icon( Icons.hourglass_top_rounded, size: widget.iconSize * 0.85, color: Colors.black26, ), ); } return ClipRRect( borderRadius: BorderRadius.circular(widget.borderRadius), child: Image.network( 'https://img.youtube.com/vi/$youtubeId/hqdefault.jpg', fit: BoxFit.cover, width: double.infinity, height: double.infinity, errorBuilder: (context, error, stackTrace) => Center( child: Icon( Icons.play_circle_fill_rounded, size: widget.iconSize, color: VideoScreen._accentPink, ), ), ), ); } if (_isControllerStale) { // Não mexemos no controller antigo (pode já estar disposed); larga a // referência e agenda uma reinicialização para o próximo frame. _hasOwnRef = false; _controller = null; _isInitialized = false; WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted) _initializeVideo(); }); } return _isInitialized && _controller != null ? ClipRRect( borderRadius: BorderRadius.circular(widget.borderRadius), child: FittedBox( fit: BoxFit.cover, child: SizedBox( width: _controller!.value.size.width, height: _controller!.value.size.height, child: VideoPlayer(_controller!), ), ), ) : Center( child: Icon( Icons.play_circle_fill_rounded, size: widget.iconSize, color: VideoScreen._accentPink, ), ); } } class _VideoButton extends StatelessWidget { const _VideoButton({required this.video}); final VideoData video; void _showVideoPlayer(BuildContext context, VideoData video) { if (video.youtubeId == null) { // Liberta todos os decodificadores de prévia antes de abrir o player // em dialog, que precisa dos seus próprios decoders de vídeo/áudio. _evictAllVideoControllers(); } showVideoPlayerDialog(context, video); } @override Widget build(BuildContext context) { return TapBounce( scale: 0.95, child: Material( elevation: 8, shadowColor: Colors.black.withValues(alpha: 0.12), borderRadius: BorderRadius.circular(16), color: Colors.white, child: InkWell( borderRadius: BorderRadius.circular(16), onTap: () => _showVideoPlayer(context, video), child: Padding( padding: const EdgeInsets.all(12), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Container( height: 80, decoration: BoxDecoration( color: const Color(0xFFFFE6F1), borderRadius: BorderRadius.circular(12), ), child: VideoThumbnail(video: video), ), const SizedBox(height: 10), Text( video.title, style: const TextStyle( fontWeight: FontWeight.w900, fontSize: 14, color: VideoScreen._teal, ), maxLines: 1, overflow: TextOverflow.ellipsis, ), const SizedBox(height: 4), Text( video.description, style: TextStyle( fontSize: 11, fontWeight: FontWeight.w600, color: Colors.black.withValues(alpha: 0.6), ), maxLines: 2, overflow: TextOverflow.ellipsis, ), ], ), ), ), ), ); } } class _YoutubePlayerDialog extends StatefulWidget { const _YoutubePlayerDialog({required this.video}); final VideoData video; @override State<_YoutubePlayerDialog> createState() => _YoutubePlayerDialogState(); } class _YoutubePlayerDialogState extends State<_YoutubePlayerDialog> { late final YoutubePlayerController _controller; @override void initState() { super.initState(); _controller = YoutubePlayerController( initialVideoId: widget.video.youtubeId!, flags: const YoutubePlayerFlags(autoPlay: true, mute: false), ); } @override void dispose() { _controller.dispose(); super.dispose(); } @override Widget build(BuildContext context) { final size = MediaQuery.sizeOf(context); return Dialog( backgroundColor: Colors.transparent, insetPadding: const EdgeInsets.all(16), child: Container( decoration: BoxDecoration( color: VideoScreen._accentPink.withValues(alpha: 0.15), borderRadius: BorderRadius.circular(24), border: Border.all(color: VideoScreen._accentPink, width: 3), ), child: ClipRRect( borderRadius: BorderRadius.circular(21), child: SizedBox( width: size.width * 0.9, child: Column( mainAxisSize: MainAxisSize.min, children: [ YoutubePlayer(controller: _controller), Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), color: VideoScreen._accentPink.withValues(alpha: 0.15), alignment: Alignment.centerRight, child: IconButton( icon: const Icon(Icons.close, color: Colors.white), onPressed: () => Navigator.of(context).pop(), ), ), ], ), ), ), ), ); } } class _VideoPlayerDialog extends StatefulWidget { const _VideoPlayerDialog({required this.video}); final VideoData video; @override State<_VideoPlayerDialog> createState() => _VideoPlayerDialogState(); } class _VideoPlayerDialogState extends State<_VideoPlayerDialog> { late VideoPlayerController _controller; bool _isInitialized = false; @override void initState() { super.initState(); _initializeVideo(); } Future _initializeVideo() async { _controller = VideoPlayerController.asset(widget.video.videoPath!); try { await _controller.initialize(); if (mounted) { setState(() { _isInitialized = true; }); } } catch (e) { if (mounted) { setState(() { _isInitialized = false; }); ScaffoldMessenger.of( context, ).showSnackBar(SnackBar(content: Text('Erro ao carregar vídeo: $e'))); } } } @override void dispose() { _controller.dispose(); super.dispose(); } @override Widget build(BuildContext context) { final size = MediaQuery.sizeOf(context); return Dialog( backgroundColor: Colors.transparent, insetPadding: const EdgeInsets.all(16), child: Container( decoration: BoxDecoration( color: VideoScreen._accentPink.withValues(alpha: 0.15), borderRadius: BorderRadius.circular(24), border: Border.all(color: VideoScreen._accentPink, width: 3), ), child: ClipRRect( borderRadius: BorderRadius.circular(21), child: SizedBox( width: size.width * 0.9, child: Column( mainAxisSize: MainAxisSize.min, children: [ // Video player if (_isInitialized) AspectRatio( aspectRatio: _controller.value.aspectRatio, child: VideoPlayer(_controller), ) else const AspectRatio( aspectRatio: 16 / 9, child: Center( child: CircularProgressIndicator( valueColor: AlwaysStoppedAnimation( VideoScreen._accentPink, ), ), ), ), // Video controls with close button if (_isInitialized) _VideoControls( controller: _controller, videoPath: widget.video.videoPath!, onClose: () => Navigator.of(context).pop(), ), ], ), ), ), ), ); } } class _VideoControls extends StatefulWidget { const _VideoControls({ required this.controller, required this.videoPath, required this.onClose, }); final VideoPlayerController controller; final String videoPath; final VoidCallback onClose; @override State<_VideoControls> createState() => _VideoControlsState(); } class _VideoControlsState extends State<_VideoControls> { @override void initState() { super.initState(); widget.controller.addListener(_onControllerUpdate); } @override void dispose() { widget.controller.removeListener(_onControllerUpdate); super.dispose(); } void _onControllerUpdate() { if (mounted) { setState(() {}); } } void _seekBy(Duration offset) { final position = widget.controller.value.position; final duration = widget.controller.value.duration; var target = position + offset; if (target < Duration.zero) target = Duration.zero; if (target > duration) target = duration; widget.controller.seekTo(target); } @override Widget build(BuildContext context) { return Container( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), color: VideoScreen._accentPink.withValues(alpha: 0.15), child: Row( children: [ // Control buttons Expanded( child: Column( mainAxisSize: MainAxisSize.min, children: [ // Progress bar VideoProgressIndicator( widget.controller, allowScrubbing: true, colors: const VideoProgressColors( playedColor: VideoScreen._teal, bufferedColor: Colors.white54, backgroundColor: Colors.white24, ), ), const SizedBox(height: 4), // Control buttons Row( mainAxisAlignment: MainAxisAlignment.center, children: [ IconButton( icon: const Icon( Icons.replay_10_rounded, color: Colors.white, ), tooltip: 'Retroceder 10s', onPressed: () => _seekBy(const Duration(seconds: -10)), ), IconButton( icon: Icon( widget.controller.value.isPlaying ? Icons.pause : Icons.play_arrow, color: Colors.white, ), onPressed: () { if (widget.controller.value.isPlaying) { widget.controller.pause(); } else { widget.controller.play(); } }, ), IconButton( icon: const Icon( Icons.forward_10_rounded, color: Colors.white, ), tooltip: 'Avançar 10s', onPressed: () => _seekBy(const Duration(seconds: 10)), ), IconButton( icon: const Icon(Icons.fullscreen, color: Colors.white), onPressed: () { // Liberta todos os decodificadores de prévia antes // de abrir o player em fullscreen. _evictAllVideoControllers(); Navigator.of(context).pop(); Navigator.of(context).push( MaterialPageRoute( builder: (context) => _FullscreenVideoPlayer( videoPath: widget.videoPath, ), fullscreenDialog: true, ), ); }, ), ], ), ], ), ), // Close button IconButton( icon: const Icon(Icons.close, color: Colors.white), onPressed: widget.onClose, ), ], ), ); } } class _FullscreenVideoPlayer extends StatefulWidget { const _FullscreenVideoPlayer({required this.videoPath}); final String videoPath; @override State<_FullscreenVideoPlayer> createState() => _FullscreenVideoPlayerState(); } class _FullscreenVideoPlayerState extends State<_FullscreenVideoPlayer> { late VideoPlayerController _controller; bool _isInitialized = false; @override void initState() { super.initState(); SystemChrome.setPreferredOrientations([ DeviceOrientation.landscapeLeft, DeviceOrientation.landscapeRight, ]); _initializeVideo(); } Future _initializeVideo() async { _controller = VideoPlayerController.asset(widget.videoPath); try { await _controller.initialize(); _controller.addListener(_onControllerUpdate); if (mounted) { setState(() { _isInitialized = true; }); } } catch (e) { if (mounted) { setState(() { _isInitialized = false; }); ScaffoldMessenger.of( context, ).showSnackBar(SnackBar(content: Text('Erro ao carregar vídeo: $e'))); } } } void _onControllerUpdate() { if (mounted) { setState(() {}); } } void _seekBy(Duration offset) { final position = _controller.value.position; final duration = _controller.value.duration; var target = position + offset; if (target < Duration.zero) target = Duration.zero; if (target > duration) target = duration; _controller.seekTo(target); } @override void dispose() { SystemChrome.setPreferredOrientations([DeviceOrientation.portraitUp]); _controller.removeListener(_onControllerUpdate); _controller.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return Scaffold( backgroundColor: Colors.black, body: Center( child: _isInitialized ? AspectRatio( aspectRatio: _controller.value.aspectRatio, child: VideoPlayer(_controller), ) : const CircularProgressIndicator( valueColor: AlwaysStoppedAnimation( VideoScreen._accentPink, ), ), ), floatingActionButton: Column( mainAxisSize: MainAxisSize.min, children: [ if (_isInitialized) Row( mainAxisSize: MainAxisSize.min, children: [ FloatingActionButton( heroTag: 'rewind', mini: true, backgroundColor: VideoScreen._teal, onPressed: () => _seekBy(const Duration(seconds: -10)), child: const Icon( Icons.replay_10_rounded, color: Colors.white, ), ), const SizedBox(width: 16), FloatingActionButton( heroTag: 'play_pause', backgroundColor: VideoScreen._teal, onPressed: () { if (_controller.value.isPlaying) { _controller.pause(); } else { _controller.play(); } }, child: Icon( _controller.value.isPlaying ? Icons.pause : Icons.play_arrow, color: Colors.white, ), ), const SizedBox(width: 16), FloatingActionButton( heroTag: 'forward', mini: true, backgroundColor: VideoScreen._teal, onPressed: () => _seekBy(const Duration(seconds: 10)), child: const Icon( Icons.forward_10_rounded, color: Colors.white, ), ), ], ), if (_isInitialized) const SizedBox(height: 16), FloatingActionButton( heroTag: 'exit_fullscreen', backgroundColor: VideoScreen._accentPink, onPressed: () => Navigator.of(context).pop(), child: const Icon(Icons.fullscreen_exit, color: Colors.white), ), ], ), ); } }