Files
CheckTheethKids/lib/screens/video_screen.dart
Carlos Correia 824fdb8089 MVP I
2026-07-10 23:53:33 +01:00

1220 lines
39 KiB
Dart

import 'dart:math' as math;
import 'dart:ui';
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 '../watched_videos_prefs.dart';
import '../widgets/app_gradients.dart';
import '../widgets/entrance.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<VideoData> 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<String, VideoPlayerController> _videoControllerCache = {};
final Map<String, int> _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<void> 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<void>(
MaterialPageRoute(
builder: (context) => _YoutubePlayerPage(video: video, scopeId: scopeId),
),
);
}
return showDialog<void>(
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<VideoScreen> createState() => _VideoScreenState();
}
class _VideoScreenState extends State<VideoScreen> {
final TextEditingController _searchController = TextEditingController();
List<VideoData> _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: 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)),
),
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],
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<VideoThumbnail> createState() => _VideoThumbnailState();
}
class _VideoThumbnailState extends State<VideoThumbnail> {
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<void> _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<void> _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.95,
child: Material(
elevation: 10,
shadowColor: Colors.black.withValues(alpha: 0.18),
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: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
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(height: 8),
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,
),
],
),
),
),
),
);
}
}
/// 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<YoutubePlayerValue>(
valueListenable: _controller,
builder: (context, value, _) {
return PopScope(
canPop: !value.isFullScreen,
onPopInvokedWithResult: (didPop, _) {
if (!didPop) _controller.toggleFullScreenMode();
},
child: Scaffold(
backgroundColor: Colors.black,
appBar: value.isFullScreen
? null
: AppBar(
backgroundColor: VideoScreen._teal,
foregroundColor: Colors.white,
elevation: 0,
title: Text(
widget.video.title,
style: const TextStyle(fontWeight: FontWeight.w900),
),
),
body: value.isFullScreen
? _CoverYoutubePlayer(controller: _controller)
: Center(
child: AspectRatio(
aspectRatio: 16 / 9,
child: YoutubePlayer(controller: _controller),
),
),
),
);
},
);
}
}
/// 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<void> _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<Color>(
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<void> _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<Color>(
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),
),
],
),
);
}
}