1282 lines
42 KiB
Dart
1282 lines
42 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),
|
|
// 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<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.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<YoutubePlayerValue>(
|
|
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: [
|
|
AspectRatio(
|
|
aspectRatio: 16 / 9,
|
|
child: YoutubePlayer(controller: _controller),
|
|
),
|
|
Padding(
|
|
padding: const EdgeInsets.all(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,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
}
|
|
|
|
/// 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),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|