import 'dart:ui'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:video_player/video_player.dart'; import 'package:youtube_player_flutter/youtube_player_flutter.dart'; import '../watched_videos_prefs.dart'; import '../widgets/app_gradients.dart'; import '../widgets/entrance.dart'; import '../widgets/liquid_waves_background.dart'; import '../widgets/pill_snackbar.dart'; import '../widgets/tap_bounce.dart'; // Video data structure - easily editable for future updates. // Episódios 1-10 tocam via YouTube (não listado). Episódios 11-13 ainda não // foram subidos ao YouTube e continuam embutidos no app (assets/videos) até // lá — depois de subidos, troque videoPath por youtubeId e apague o mp4. 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: 'Qual a Influência do nariz entupido na má oclusão', youtubeId: 'PJ58CZv4ECw', ), VideoData( id: 2, title: 'Episódio 2', description: 'Qual a Influência das alergias sazionais na má oclusão', youtubeId: 'y4_kWmZtAtg', ), VideoData( id: 3, title: 'Episódio 3', description: 'Qual a Influência das Otites frequentes na má oclusão', youtubeId: 'nD75Y5PuKTo', ), VideoData( id: 4, title: 'Episódio 4', description: 'Qual a Influência das Amigdalites recorrentes na má oclusão', youtubeId: 'yvFllWYeuLw', ), VideoData( id: 5, title: 'Episódio 5', description: 'Qual a Influência das Bronquiolites recorrentes na má oclusão', youtubeId: 'DnhUa-T8_Ps', ), VideoData( id: 6, title: 'Episódio 6', description: 'Qual a Influência dos problemas respitatórios na má oclusão', youtubeId: 'zKt_iwkrjvo', ), VideoData( id: 7, title: 'Episódio 7', description: 'Qual a Influência das interrupções respiratórias na má oclusão', youtubeId: 'NpmQ2brap5A', ), VideoData( id: 8, title: 'Episódio 8', description: 'Qual a Influência do ressonar na má oclusão', youtubeId: 'Wj3KYw9pBi0', ), VideoData( id: 9, title: 'Episódio 9', description: 'Qual a Influência de acordar com saliva seca na boca ou na almofada na saúde oral', youtubeId: 'bOm9t61cT_U', ), VideoData( id: 10, title: 'Episódio 10', description: 'Qual a Influência da respiração oral na má oclusão', youtubeId: 'fAitMizbcms', ), VideoData( id: 11, title: 'Episódio 11', description: 'Qual a influência do uso exagerado da chupeta na má oclusão', youtubeId: '6sYoBUjks_I', ), VideoData( id: 12, title: 'Episódio 12', description: 'Qual a influência do uso exagerado da chupeta na má oclusão', youtubeId: 'eznKrErQbHo', ), VideoData( id: 13, title: 'Episódio 13', description: 'Qual a influência do hábito de chuchar o dedo na má oclusão', youtubeId: 'VO9CNqHRdeM', ), ]; // 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(); } } /// Regista o episódio como assistido (localmente, por criança). Chamado /// quando um player deteta que o vídeo chegou ao fim — sem [scopeId] (nenhuma /// criança selecionada) não há onde guardar, por isso não faz nada. void markVideoWatched(String? scopeId, int videoId) { final scope = (scopeId ?? '').trim(); if (scope.isEmpty) return; WatchedVideosPrefs.markWatched(scope, videoId); } Future showVideoPlayerDialog( BuildContext context, VideoData video, { String? scopeId, }) { if (video.youtubeId != null) { if (video.youtubeId!.isEmpty) { showPillSnackBar(context, 'Vídeo ainda não disponível'); return Future.value(); } return Navigator.of(context).push( MaterialPageRoute( builder: (context) => _YoutubePlayerPage(video: video, scopeId: scopeId), ), ); } return showDialog( context: context, builder: (context) => _VideoPlayerDialog(video: video, scopeId: scopeId), ); } class VideoScreen extends StatefulWidget { const VideoScreen({super.key, this.scopeId}); /// Identifica a criança selecionada (`'${uid}_${childId}'`), usado para /// guardar localmente quais episódios ela já assistiu até ao fim. final String? scopeId; 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) { return Scaffold( appBar: PreferredSize( preferredSize: const Size.fromHeight(kToolbarHeight), // O gradiente é pintado por este Container de tamanho fixo, em vez // de confiar no `flexibleSpace` do AppBar — em alguns aparelhos o // `flexibleSpace` de um AppBar comum não recebia o tamanho esperado // e a gradiente aparecia como cor sólida. child: Container( decoration: const BoxDecoration(gradient: kAppBarGradient), child: AppBar( backgroundColor: Colors.transparent, foregroundColor: Colors.white, surfaceTintColor: Colors.transparent, elevation: 0, scrolledUnderElevation: 0, title: const Text( 'Videos Educativos', style: TextStyle(fontWeight: FontWeight.w900), ), ), ), ), body: Stack( clipBehavior: Clip.none, children: [ Positioned.fill(child: Container(color: const Color(0xFFFAFAF7))), const LiquidWavesBackground(), 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), // Lista vertical (um vídeo abaixo do outro, rolando para // baixo), mas cada card em si é horizontal — miniatura à // esquerda, título/descrição à direita — em vez da antiga // grelha de 2 colunas com cards verticais. 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), ), ), ) : ListView.separated( itemCount: _filteredVideos.length, separatorBuilder: (context, index) => const SizedBox(height: 12), itemBuilder: (context, index) { return FadeSlideIn( delay: Duration(milliseconds: 40 * (index % 8)), child: _VideoButton( video: _filteredVideos[index], scopeId: widget.scopeId, ), ); }, ), ), ], ), ), ), ], ), ); } } /// 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, this.scopeId}); final VideoData video; final String? scopeId; 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, scopeId: scopeId); } @override Widget build(BuildContext context) { return TapBounce( scale: 0.97, child: Material( elevation: 8, shadowColor: Colors.black.withValues(alpha: 0.14), borderRadius: BorderRadius.circular(18), color: Colors.white, child: InkWell( borderRadius: BorderRadius.circular(18), onTap: () => _showVideoPlayer(context, video), child: Padding( padding: const EdgeInsets.all(10), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ SizedBox( width: 130, child: AspectRatio( aspectRatio: 16 / 9, child: ClipRRect( borderRadius: BorderRadius.circular(14), child: Stack( fit: StackFit.expand, children: [ ColoredBox( color: const Color(0xFFFFE6F1), child: VideoThumbnail( video: video, borderRadius: 0, ), ), DecoratedBox( decoration: BoxDecoration( gradient: LinearGradient( begin: Alignment.topCenter, end: Alignment.bottomCenter, colors: [ Colors.transparent, Colors.black.withValues(alpha: 0.32), ], stops: const [0.55, 1.0], ), ), ), Center( child: Container( width: 34, height: 34, decoration: BoxDecoration( color: Colors.white.withValues(alpha: 0.92), shape: BoxShape.circle, ), child: const Icon( Icons.play_arrow_rounded, color: VideoScreen._accentPink, size: 20, ), ), ), Positioned( left: 6, bottom: 6, child: Container( padding: const EdgeInsets.symmetric( horizontal: 6, vertical: 2, ), decoration: BoxDecoration( color: Colors.black.withValues(alpha: 0.5), borderRadius: BorderRadius.circular(999), ), child: Text( 'EP. ${video.id}', style: const TextStyle( color: Colors.white, fontWeight: FontWeight.w800, fontSize: 9.5, ), ), ), ), ], ), ), ), ), const SizedBox(width: 12), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( video.title, style: const TextStyle( fontWeight: FontWeight.w900, fontSize: 15, color: VideoScreen._teal, ), maxLines: 1, overflow: TextOverflow.ellipsis, ), const SizedBox(height: 4), Text( video.description, style: TextStyle( fontSize: 12, fontWeight: FontWeight.w600, color: Colors.black.withValues(alpha: 0.6), ), maxLines: 2, overflow: TextOverflow.ellipsis, ), ], ), ), const SizedBox(width: 4), Icon( Icons.chevron_right_rounded, color: Colors.black.withValues(alpha: 0.3), ), ], ), ), ), ), ); } } /// Página cheia (não diálogo) para o player do YouTube. O botão de tela cheia /// do próprio player força a rotação para paisagem via /// `SystemChrome.setPreferredOrientations`; um `Dialog` de largura fixa não /// se adapta a essa rotação e causa overflow gráfico. /// /// Em paisagem/tela cheia, o vídeo é recortado ("cover", como Instagram/ /// TikTok) para preencher o ecrã todo sem barras pretas — em vez de manter /// a proporção 16:9 do YouTube e sobrar espaço vazio quando o ecrã tem uma /// proporção mais larga que 16:9 (ex.: a maioria dos telemóveis atuais). class _YoutubePlayerPage extends StatefulWidget { const _YoutubePlayerPage({required this.video, this.scopeId}); final VideoData video; final String? scopeId; @override State<_YoutubePlayerPage> createState() => _YoutubePlayerPageState(); } class _YoutubePlayerPageState extends State<_YoutubePlayerPage> with WidgetsBindingObserver { late final YoutubePlayerController _controller; bool _markedWatched = false; @override void initState() { super.initState(); _controller = YoutubePlayerController( initialVideoId: widget.video.youtubeId!, flags: const YoutubePlayerFlags(autoPlay: true, mute: false), ); _controller.addListener(_onControllerValueChanged); WidgetsBinding.instance.addObserver(this); } void _onControllerValueChanged() { if (_markedWatched) return; if (_controller.value.playerState == PlayerState.ended) { _markedWatched = true; markVideoWatched(widget.scopeId, widget.video.id); } } @override void didChangeMetrics() { final isLandscape = PlatformDispatcher.instance.views.first.physicalSize.width > PlatformDispatcher.instance.views.first.physicalSize.height; if (isLandscape == _controller.value.isFullScreen) return; _controller.updateValue( _controller.value.copyWith(isFullScreen: isLandscape), ); if (isLandscape) { SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky); } else { SystemChrome.restoreSystemUIOverlays(); } } @override void dispose() { WidgetsBinding.instance.removeObserver(this); SystemChrome.restoreSystemUIOverlays(); _controller.removeListener(_onControllerValueChanged); _controller.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return ValueListenableBuilder( valueListenable: _controller, builder: (context, value, _) { return PopScope( canPop: !value.isFullScreen, onPopInvokedWithResult: (didPop, _) { if (!didPop) _controller.toggleFullScreenMode(); }, child: Scaffold( backgroundColor: value.isFullScreen ? Colors.black : const Color(0xFFFAFAF7), appBar: value.isFullScreen ? null : PreferredSize( preferredSize: const Size.fromHeight(kToolbarHeight), child: Container( decoration: const BoxDecoration( gradient: kAppBarGradient, ), child: AppBar( backgroundColor: Colors.transparent, foregroundColor: Colors.white, surfaceTintColor: Colors.transparent, elevation: 0, scrolledUnderElevation: 0, title: Text( widget.video.title, style: const TextStyle(fontWeight: FontWeight.w900), ), ), ), ), // Em vez de centrar o vídeo num quadro 16:9 no meio de um ecrã // preto (o que sobrava como barras pretas em cima/baixo no // modo retrato), o vídeo fica encostado ao topo, a preencher a // largura toda, com o título e a descrição do episódio logo // abaixo — sem nenhum espaço preto sobrando. O modo tela cheia // (paisagem) continua a recortar o vídeo para preencher tudo. body: value.isFullScreen ? _CoverYoutubePlayer(controller: _controller) : SingleChildScrollView( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ DecoratedBox( decoration: const BoxDecoration( gradient: kAppBarGradient, ), child: AspectRatio( aspectRatio: 16 / 9, child: YoutubePlayer(controller: _controller), ), ), Padding( padding: const EdgeInsets.fromLTRB(20, 14, 20, 0), child: _VideoProgressRow(controller: _controller), ), Padding( padding: const EdgeInsets.fromLTRB(20, 16, 20, 20), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( widget.video.title, style: const TextStyle( fontWeight: FontWeight.w900, fontSize: 19, color: VideoScreen._teal, ), ), const SizedBox(height: 8), Text( widget.video.description, style: TextStyle( fontSize: 14, fontWeight: FontWeight.w600, color: Colors.black.withValues(alpha: 0.65), height: 1.4, ), ), if (_nextVideos.isNotEmpty) ...[ const SizedBox(height: 24), Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ const Text( 'Próximos', style: TextStyle( fontWeight: FontWeight.w900, fontSize: 15, color: VideoScreen._accentPink, ), ), if (_hasMoreVideos) TapBounce( child: InkWell( borderRadius: BorderRadius.circular( 999, ), onTap: () => Navigator.of(context).push( MaterialPageRoute( builder: (_) => VideoScreen( scopeId: widget.scopeId, ), ), ), child: const Padding( padding: EdgeInsets.symmetric( horizontal: 4, vertical: 2, ), child: Text( 'Ver mais', style: TextStyle( fontWeight: FontWeight.w800, fontSize: 13, color: VideoScreen._teal, ), ), ), ), ), ], ), const SizedBox(height: 12), for ( var i = 0; i < _visibleNextVideos.length; i++ ) ...[ if (i > 0) const SizedBox(height: 10), _VideoButton( video: _visibleNextVideos[i], scopeId: widget.scopeId, ), ], ], ], ), ), ], ), ), ), ); }, ); } static const int _maxNextVideos = 4; List get _nextVideos => videoList.where((v) => v.id > widget.video.id).toList(); List get _visibleNextVideos => _nextVideos.take(_maxNextVideos).toList(); bool get _hasMoreVideos => _nextVideos.length > _maxNextVideos; } /// Barra de progresso do vídeo (posição atual / duração total), separada do /// player — a mesma que aparece por baixo do vídeo no design de referência. class _VideoProgressRow extends StatelessWidget { const _VideoProgressRow({required this.controller}); final YoutubePlayerController controller; String _format(Duration d) { final minutes = d.inMinutes.remainder(60).toString(); final seconds = d.inSeconds.remainder(60).toString().padLeft(2, '0'); return '$minutes:$seconds'; } @override Widget build(BuildContext context) { return ValueListenableBuilder( valueListenable: controller, builder: (context, value, _) { final duration = value.metaData.duration; final position = value.position; final progress = duration.inMilliseconds > 0 ? (position.inMilliseconds / duration.inMilliseconds).clamp( 0.0, 1.0, ) : 0.0; return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ ClipRRect( borderRadius: BorderRadius.circular(999), child: LinearProgressIndicator( value: progress, minHeight: 5, backgroundColor: Colors.black.withValues(alpha: 0.10), valueColor: const AlwaysStoppedAnimation( VideoScreen._accentPink, ), ), ), const SizedBox(height: 6), Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text( _format(position), style: TextStyle( fontSize: 12, fontWeight: FontWeight.w700, color: Colors.black.withValues(alpha: 0.55), ), ), Text( _format(duration), style: TextStyle( fontSize: 12, fontWeight: FontWeight.w700, color: Colors.black.withValues(alpha: 0.55), ), ), ], ), ], ); }, ); } } /// Preenche todo o espaço disponível recortando o vídeo (mantém a proporção /// 16:9 real do YouTube, mas amplia e corta o excesso nas laterais ou em /// cima/baixo em vez de deixar barras pretas). O player é sempre desenhado /// no seu tamanho real (nunca reduzido a uma caixa minúscula), por isso o /// recorte fica nítido, sem perda de qualidade. class _CoverYoutubePlayer extends StatelessWidget { const _CoverYoutubePlayer({required this.controller}); final YoutubePlayerController controller; static const double _videoAspectRatio = 16 / 9; @override Widget build(BuildContext context) { return LayoutBuilder( builder: (context, constraints) { final parentAspectRatio = constraints.maxWidth / constraints.maxHeight; final double boxWidth; final double boxHeight; if (parentAspectRatio > _videoAspectRatio) { boxWidth = constraints.maxWidth; boxHeight = boxWidth / _videoAspectRatio; } else { boxHeight = constraints.maxHeight; boxWidth = boxHeight * _videoAspectRatio; } return ClipRect( child: OverflowBox( maxWidth: boxWidth, maxHeight: boxHeight, child: SizedBox( width: boxWidth, height: boxHeight, child: YoutubePlayer( controller: controller, aspectRatio: _videoAspectRatio, ), ), ), ); }, ); } } class _VideoPlayerDialog extends StatefulWidget { const _VideoPlayerDialog({required this.video, this.scopeId}); final VideoData video; final String? scopeId; @override State<_VideoPlayerDialog> createState() => _VideoPlayerDialogState(); } class _VideoPlayerDialogState extends State<_VideoPlayerDialog> { late VideoPlayerController _controller; bool _isInitialized = false; bool _markedWatched = false; @override void initState() { super.initState(); _initializeVideo(); } Future _initializeVideo() async { _controller = VideoPlayerController.asset(widget.video.videoPath!); try { await _controller.initialize(); _controller.addListener(_onControllerValueChanged); if (mounted) { setState(() { _isInitialized = true; }); } } catch (e) { if (mounted) { setState(() { _isInitialized = false; }); showPillSnackBar(context, 'Erro ao carregar vídeo: $e'); } } } void _onControllerValueChanged() { if (_markedWatched) return; final value = _controller.value; if (!value.isInitialized || value.duration == Duration.zero) return; if (value.position >= value.duration - const Duration(milliseconds: 300)) { _markedWatched = true; markVideoWatched(widget.scopeId, widget.video.id); } } @override void dispose() { _controller.removeListener(_onControllerValueChanged); _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!, videoId: widget.video.id, scopeId: widget.scopeId, onClose: () => Navigator.of(context).pop(), ), ], ), ), ), ), ); } } class _VideoControls extends StatefulWidget { const _VideoControls({ required this.controller, required this.videoPath, required this.videoId, required this.onClose, this.scopeId, }); final VideoPlayerController controller; final String videoPath; final int videoId; final String? scopeId; 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, videoId: widget.videoId, scopeId: widget.scopeId, ), 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, required this.videoId, this.scopeId, }); final String videoPath; final int videoId; final String? scopeId; @override State<_FullscreenVideoPlayer> createState() => _FullscreenVideoPlayerState(); } class _FullscreenVideoPlayerState extends State<_FullscreenVideoPlayer> { late VideoPlayerController _controller; bool _isInitialized = false; bool _markedWatched = 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; }); showPillSnackBar(context, 'Erro ao carregar vídeo: $e'); } } } void _onControllerUpdate() { if (!_markedWatched) { final value = _controller.value; if (value.isInitialized && value.duration != Duration.zero && value.position >= value.duration - const Duration(milliseconds: 300)) { _markedWatched = true; markVideoWatched(widget.scopeId, widget.videoId); } } 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), ), ], ), ); } }