rewrite: Progress on the video screen

This commit is contained in:
2026-01-19 02:04:13 +00:00
parent 4460d90c71
commit 6a815dbbd2
13 changed files with 499 additions and 180 deletions

View File

@@ -159,9 +159,11 @@ class AnimeDetailsCubit extends Cubit<AnimeDetailsState> with EffectMixin<AnimeD
_logger.i("Navigating to Video Player with selected video from ${state.selectedExtension?.name}");
_videoInfoNotifier.updateVideoInfo(
VideoInfoModel(
currentVideo: selectedVideo,
playlistIndex: episodeIndex,
)
currentVideo: selectedVideo,
alternativeVideos: state.extensionVideoResults,
videoIndex: state.extensionVideoResults.indexOf(selectedVideo),
playlistIndex: episodeIndex,
)
);
pushRouteEffect(path: "/video");
}

View File

@@ -16,6 +16,7 @@ import 'package:unyo/domain/entities/anime.dart';
import 'package:unyo/domain/entities/user.dart';
import 'package:unyo/core/di/locator.dart';
import 'package:unyo/domain/entities/video_info.dart';
import 'package:unyo/presentation/dialogs/warning_dialog.dart';
class VideoCubit extends Cubit<VideoState> with EffectMixin<VideoState> {
// Repositories
@@ -73,7 +74,15 @@ class VideoCubit extends Cubit<VideoState> with EffectMixin<VideoState> {
emit(state.copyWith(loggedUser: loggedUser));
});
_videoInfoSubscription = _videoInfoNotifier.videoInfoStream.listen((videoInfo) {
_videoService = VideoService(video: videoInfo.currentVideo, playlistIndex: videoInfo.playlistIndex, lowLatency: false);
_videoService = VideoService(
video: videoInfo.currentVideo,
alternativeVideos: videoInfo.alternativeVideos,
videoIndex: videoInfo.videoIndex,
playlistIndex: videoInfo.playlistIndex,
onErrorCallback: (errorTitle) => showWidgetDialogEffect(dialog: WarningDialog(width: 500, height: 200, title: errorTitle)),
lowLatency: false
);
_videoService.setPreventSleep(true);
_videoServiceInitialized = true;
emit(state.copyWith(videoInfo: videoInfo, isLoading: false));

View File

@@ -3,6 +3,7 @@ import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:fvp/mdk.dart' as mdk;
import 'package:video_player/video_player.dart';
import 'package:logger/logger.dart';
@@ -23,24 +24,37 @@ class VideoService {
final Logger _logger = sl<Logger>();
final HttpService _httpService = sl<HttpService>();
ext.Video _video;
final ext.Video _video;
final List<ext.Video> _alternativeVideos;
int _videoIndex;
// This will be used for getting the correct video out of a playlist from a magnet / torrent
int _playlistIndex;
late final mdk.Player _player;
late Timer seekTimer;
final List<ext.Track> captionTracks = [];
final List<ext.Track> audioTracks = [];
ClosedCaptionFile? _currentCaptionFile;
ext.Track? _currentCaptionTrack;
ext.Track? _currentAudioTrack;
bool _isBuffering = true;
final bool _lowLatency;
static const mdk.SeekFlag _seekFlags = mdk.SeekFlag(mdk.SeekFlag.fromStart | mdk.SeekFlag.inCache);
final void Function(String) _onErrorCallback;
VideoService({required ext.Video video, required int playlistIndex, bool lowLatency = false})
: _playlistIndex = playlistIndex,
_video = video,
_lowLatency = lowLatency {
final bool _lowLatency;
late Timer seekTimer;
bool _isBuffering = true;
static const mdk.SeekFlag _seekFlags = mdk.SeekFlag(mdk.SeekFlag.fromStart | mdk.SeekFlag.inCache | mdk.SeekFlag.fast);
VideoService({
required ext.Video video,
required List<ext.Video> alternativeVideos,
required int videoIndex,
required int playlistIndex,
required void Function(String) onErrorCallback,
bool lowLatency = false,
}) : _playlistIndex = playlistIndex,
_videoIndex = videoIndex,
_video = video,
_alternativeVideos = alternativeVideos,
_onErrorCallback = onErrorCallback,
_lowLatency = lowLatency {
_player = mdk.Player();
// Set player ffmpeg properties
_configureDecoder();
@@ -61,7 +75,6 @@ class VideoService {
_initCaptionsAndAudiotracks();
_player.state = mdk.PlaybackState.playing;
setVolume(1.0);
return false;
}
if (newStatus.test(mdk.MediaStatus.buffering)) {
_isBuffering = true;
@@ -69,8 +82,9 @@ class VideoService {
if (newStatus.test(mdk.MediaStatus.buffered)) {
_isBuffering = false;
}
if(newStatus.test(mdk.MediaStatus.invalid)) {
// TODO Warn user about invalid media and stop playback / leave
if (newStatus.test(mdk.MediaStatus.invalid)) {
_onErrorCallback("Failed to load media. Please try again later.");
return false;
}
return true;
});
@@ -99,10 +113,9 @@ class VideoService {
final streams = _player.mediaInfo.video;
if (streams != null && streams.isNotEmpty) {
final codec = streams.first.codec;
// codec.width and codec.height are ints
return codec.width / codec.height;
}
return 16 / 9;
return -1;
}
// Setters
@@ -134,10 +147,23 @@ class VideoService {
}
bool seekTo(Duration newDuration) {
_player.seek(position: newDuration.inMilliseconds, flags: _seekFlags);
if (isPlaying) {
_player.seek(position: newDuration.inMilliseconds, flags: _seekFlags);
} else {
_player.seek(position: newDuration.inMilliseconds, flags: _seekFlags);
_player.state = mdk.PlaybackState.paused;
}
return true;
}
bool reverse(Duration reverseDuration) {
return seekTo(Duration(milliseconds: position.inMilliseconds - reverseDuration.inMilliseconds));
}
bool forward(Duration forwardDuration) {
return seekTo(Duration(milliseconds: position.inMilliseconds + forwardDuration.inMilliseconds));
}
bool setCaptionOffset(Duration duration) {
return false;
}
@@ -149,9 +175,7 @@ class VideoService {
}
_currentCaptionTrack = captionTracks[captionIndex];
if (_currentCaptionTrack!.embedded) {
_player.activeSubtitleTracks = [
_currentCaptionTrack?.embeddedIndex ?? 0
];
_player.activeSubtitleTracks = [_currentCaptionTrack?.embeddedIndex ?? 0];
} else {
_currentCaptionFile = await _loadExternalCaption(_currentCaptionTrack!);
_player.setMedia(_currentCaptionTrack!.url, mdk.MediaType.subtitle);
@@ -166,9 +190,7 @@ class VideoService {
}
_currentAudioTrack = audioTracks[audioTrackIndex];
if (_currentAudioTrack!.embedded) {
_player.activeAudioTracks = [
_currentAudioTrack?.embeddedIndex ?? 0
];
_player.activeAudioTracks = [_currentAudioTrack?.embeddedIndex ?? 0];
} else {
_player.setMedia(_currentAudioTrack!.url, mdk.MediaType.audio);
}
@@ -181,7 +203,7 @@ class VideoService {
}
bool setPreventSleep(bool preventSleep) {
WakelockPlus.enable();
WakelockPlus.toggle(enable: preventSleep);
return true;
}
@@ -198,25 +220,19 @@ class VideoService {
// Utilities
void _configureDecoder() {
final vd = {
'windows': [
'MFT:d3d=11',
"D3D11",
"DXVA",
'CUDA',
'hap',
'FFmpeg',
'dav1d'
],
'windows': ['MFT:d3d=11', "D3D11", "DXVA", 'CUDA', 'hap', 'FFmpeg', 'dav1d'],
'macos': ['VT', 'hap', 'FFmpeg', 'dav1d'],
'linux': ['VAAPI', 'CUDA', 'VDPAU', 'hap', 'FFmpeg', 'dav1d'],
};
_player.setDecoders(mdk.MediaType.video, vd[Platform.operatingSystem]!);
}
void _configurePlayer() {
_player.setProperty(
'avio.protocol_whitelist',
'file,ftp,rtmp,http,https,tls,rtp,tcp,udp,crypto,httpproxy,data,concatf,concat,subfile'
'avio.protocol_whitelist',
'file,ftp,rtmp,http,https,tls,rtp,tcp,udp,crypto,httpproxy,data,concatf,concat,subfile',
);
// Not sure about this flag
_player.setProperty('video.decoder', 'shader_resource=0');
_player.setProperty('avformat.strict', 'experimental');
_player.setProperty('avformat.safe', '0');
@@ -231,49 +247,44 @@ class VideoService {
_player.setProperty('avformat.analyzeduration', '100000');
_player.setBufferRange(min: 0, max: 1000, drop: true);
} else {
_player.setBufferRange(min: 0, max: 4000, drop: false);
_player.setBufferRange(min: 0, max: 5000, drop: false);
}
}
}
void _setPlayerHttpHeaders(ext.Headers? headers) {
if (headers == null || headers.headersMap.isEmpty) return;
final userAgent = headers.headersMap.entries
.firstWhere(
(e) => e.key.toLowerCase() == 'user-agent',
orElse: () => const MapEntry('', '')
)
.value;
if (headers == null || headers.headersMap.isEmpty) return;
final userAgent = headers.headersMap.entries
.firstWhere((e) => e.key.toLowerCase() == 'user-agent', orElse: () => const MapEntry('', ''))
.value;
if (userAgent.isNotEmpty) {
_player.setProperty('user_agent', userAgent);
}
final formattedHeaders = headers.headersMap.entries
.where((e) => e.key.toLowerCase() != 'user-agent') // Filter out UA
.map((e) {
// Fix cookie separator logic (HTTP spec requires '; ' not ',')
final value = e.key.toLowerCase() == 'cookie'
? e.value.replaceAll(',', '; ')
: e.value;
return '${e.key}: $value';
})
.join('\r\n'); // Join with CRLF
if (userAgent.isNotEmpty) {
_player.setProperty('user_agent', userAgent);
}
final formattedHeaders = headers.headersMap.entries
// .where((e) => e.key.toLowerCase() != 'user-agent') // Filter out UA
.map((e) {
// Fix cookie separator logic (HTTP spec requires '; ' not ',')
final value = e.key.toLowerCase() == 'cookie' ? e.value.replaceAll(',', '; ') : e.value;
return '${e.key}: $value';
})
.join('\r\n'); // Join with CRLF
if (formattedHeaders.isNotEmpty) {
_player.setProperty('headers', formattedHeaders);
_player.setProperty('avio.headers', formattedHeaders);
if (formattedHeaders.isNotEmpty) {
_player.setProperty('headers', formattedHeaders);
_player.setProperty('avio.headers', formattedHeaders);
}
}
}
void _setPlayerLogsHandler() {
mdk.setLogHandler((mdk.LogLevel level, String message) {
if (!message.contains("unloaded media's position")) {
switch (level) {
case mdk.LogLevel.debug:
// _logger.d("MDK Log: $message");
// _logger.d("MDK Log: $message");
case mdk.LogLevel.info:
// _logger.i("MDK Log: $message");
// _logger.i("MDK Log: $message");
case mdk.LogLevel.warning:
// _logger.w("MDK Log: $message");
// _logger.w("MDK Log: $message");
case mdk.LogLevel.error:
_logger.e("MDK Log: $message");
case mdk.LogLevel.off:
@@ -299,20 +310,24 @@ class VideoService {
}
}
captionTracks.addAll(_video.subtitleTracks);
setCaption(0);
if (_player.mediaInfo.audio != null && _player.mediaInfo.audio!.length > 1) {
for (mdk.AudioStreamInfo audioStreamInfo in _player.mediaInfo.audio!) {
audioTracks.add(
ext.Track(
url: "",
lang:
"${audioStreamInfo.metadata["title"] ?? ""} (${audioStreamInfo.metadata["language"]} - Embedded)",
"${audioStreamInfo.metadata["title"] ?? ""} (${audioStreamInfo.metadata["language"]} - Embedded)",
embedded: true,
embeddedIndex: audioStreamInfo.index
embeddedIndex: audioStreamInfo.index,
),
);
}
}
audioTracks.addAll(_video.audioTracks);
if (audioTracks.isNotEmpty) {
setAudioTrack(0);
}
}
Future<ClosedCaptionFile> _loadExternalCaption(ext.Track captionTrack) async {

View File

@@ -7,20 +7,26 @@ part 'video_info.g.dart';
abstract class VideoInfo {
final ext.Video currentVideo;
final List<ext.Video> alternativeVideos;
final int videoIndex;
final int playlistIndex;
const VideoInfo({required this.currentVideo, required this.playlistIndex});
const VideoInfo({required this.currentVideo, required this.alternativeVideos, required this.videoIndex, required this.playlistIndex});
}
@freezed
abstract class VideoInfoModel with _$VideoInfoModel implements VideoInfo {
const factory VideoInfoModel({
@ext.VideoConverter() required ext.Video currentVideo,
@ext.VideoConverter() required List<ext.Video> alternativeVideos,
required int videoIndex,
required int playlistIndex,
}) = _VideoInfoModel;
factory VideoInfoModel.empty() => VideoInfoModel(
currentVideo: ext.Video.empty(),
alternativeVideos: [],
videoIndex: -1,
playlistIndex: -1
);

View File

@@ -15,7 +15,7 @@ T _$identity<T>(T value) => value;
/// @nodoc
mixin _$VideoInfoModel {
@ext.VideoConverter() ext.Video get currentVideo; int get playlistIndex;
@ext.VideoConverter() ext.Video get currentVideo;@ext.VideoConverter() List<ext.Video> get alternativeVideos; int get videoIndex; int get playlistIndex;
/// Create a copy of VideoInfoModel
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@@ -28,16 +28,16 @@ $VideoInfoModelCopyWith<VideoInfoModel> get copyWith => _$VideoInfoModelCopyWith
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is VideoInfoModel&&(identical(other.currentVideo, currentVideo) || other.currentVideo == currentVideo)&&(identical(other.playlistIndex, playlistIndex) || other.playlistIndex == playlistIndex));
return identical(this, other) || (other.runtimeType == runtimeType&&other is VideoInfoModel&&(identical(other.currentVideo, currentVideo) || other.currentVideo == currentVideo)&&const DeepCollectionEquality().equals(other.alternativeVideos, alternativeVideos)&&(identical(other.videoIndex, videoIndex) || other.videoIndex == videoIndex)&&(identical(other.playlistIndex, playlistIndex) || other.playlistIndex == playlistIndex));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(runtimeType,currentVideo,playlistIndex);
int get hashCode => Object.hash(runtimeType,currentVideo,const DeepCollectionEquality().hash(alternativeVideos),videoIndex,playlistIndex);
@override
String toString() {
return 'VideoInfoModel(currentVideo: $currentVideo, playlistIndex: $playlistIndex)';
return 'VideoInfoModel(currentVideo: $currentVideo, alternativeVideos: $alternativeVideos, videoIndex: $videoIndex, playlistIndex: $playlistIndex)';
}
@@ -48,7 +48,7 @@ abstract mixin class $VideoInfoModelCopyWith<$Res> {
factory $VideoInfoModelCopyWith(VideoInfoModel value, $Res Function(VideoInfoModel) _then) = _$VideoInfoModelCopyWithImpl;
@useResult
$Res call({
@ext.VideoConverter() ext.Video currentVideo, int playlistIndex
@ext.VideoConverter() ext.Video currentVideo,@ext.VideoConverter() List<ext.Video> alternativeVideos, int videoIndex, int playlistIndex
});
@@ -65,10 +65,12 @@ class _$VideoInfoModelCopyWithImpl<$Res>
/// Create a copy of VideoInfoModel
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline') @override $Res call({Object? currentVideo = null,Object? playlistIndex = null,}) {
@pragma('vm:prefer-inline') @override $Res call({Object? currentVideo = null,Object? alternativeVideos = null,Object? videoIndex = null,Object? playlistIndex = null,}) {
return _then(_self.copyWith(
currentVideo: null == currentVideo ? _self.currentVideo : currentVideo // ignore: cast_nullable_to_non_nullable
as ext.Video,playlistIndex: null == playlistIndex ? _self.playlistIndex : playlistIndex // ignore: cast_nullable_to_non_nullable
as ext.Video,alternativeVideos: null == alternativeVideos ? _self.alternativeVideos : alternativeVideos // ignore: cast_nullable_to_non_nullable
as List<ext.Video>,videoIndex: null == videoIndex ? _self.videoIndex : videoIndex // ignore: cast_nullable_to_non_nullable
as int,playlistIndex: null == playlistIndex ? _self.playlistIndex : playlistIndex // ignore: cast_nullable_to_non_nullable
as int,
));
}
@@ -154,10 +156,10 @@ return $default(_that);case _:
/// }
/// ```
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function(@ext.VideoConverter() ext.Video currentVideo, int playlistIndex)? $default,{required TResult orElse(),}) {final _that = this;
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function(@ext.VideoConverter() ext.Video currentVideo, @ext.VideoConverter() List<ext.Video> alternativeVideos, int videoIndex, int playlistIndex)? $default,{required TResult orElse(),}) {final _that = this;
switch (_that) {
case _VideoInfoModel() when $default != null:
return $default(_that.currentVideo,_that.playlistIndex);case _:
return $default(_that.currentVideo,_that.alternativeVideos,_that.videoIndex,_that.playlistIndex);case _:
return orElse();
}
@@ -175,10 +177,10 @@ return $default(_that.currentVideo,_that.playlistIndex);case _:
/// }
/// ```
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function(@ext.VideoConverter() ext.Video currentVideo, int playlistIndex) $default,) {final _that = this;
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function(@ext.VideoConverter() ext.Video currentVideo, @ext.VideoConverter() List<ext.Video> alternativeVideos, int videoIndex, int playlistIndex) $default,) {final _that = this;
switch (_that) {
case _VideoInfoModel():
return $default(_that.currentVideo,_that.playlistIndex);case _:
return $default(_that.currentVideo,_that.alternativeVideos,_that.videoIndex,_that.playlistIndex);case _:
throw StateError('Unexpected subclass');
}
@@ -195,10 +197,10 @@ return $default(_that.currentVideo,_that.playlistIndex);case _:
/// }
/// ```
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function(@ext.VideoConverter() ext.Video currentVideo, int playlistIndex)? $default,) {final _that = this;
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function(@ext.VideoConverter() ext.Video currentVideo, @ext.VideoConverter() List<ext.Video> alternativeVideos, int videoIndex, int playlistIndex)? $default,) {final _that = this;
switch (_that) {
case _VideoInfoModel() when $default != null:
return $default(_that.currentVideo,_that.playlistIndex);case _:
return $default(_that.currentVideo,_that.alternativeVideos,_that.videoIndex,_that.playlistIndex);case _:
return null;
}
@@ -210,10 +212,18 @@ return $default(_that.currentVideo,_that.playlistIndex);case _:
@JsonSerializable()
class _VideoInfoModel implements VideoInfoModel {
const _VideoInfoModel({@ext.VideoConverter() required this.currentVideo, required this.playlistIndex});
const _VideoInfoModel({@ext.VideoConverter() required this.currentVideo, @ext.VideoConverter() required final List<ext.Video> alternativeVideos, required this.videoIndex, required this.playlistIndex}): _alternativeVideos = alternativeVideos;
factory _VideoInfoModel.fromJson(Map<String, dynamic> json) => _$VideoInfoModelFromJson(json);
@override@ext.VideoConverter() final ext.Video currentVideo;
final List<ext.Video> _alternativeVideos;
@override@ext.VideoConverter() List<ext.Video> get alternativeVideos {
if (_alternativeVideos is EqualUnmodifiableListView) return _alternativeVideos;
// ignore: implicit_dynamic_type
return EqualUnmodifiableListView(_alternativeVideos);
}
@override final int videoIndex;
@override final int playlistIndex;
/// Create a copy of VideoInfoModel
@@ -229,16 +239,16 @@ Map<String, dynamic> toJson() {
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is _VideoInfoModel&&(identical(other.currentVideo, currentVideo) || other.currentVideo == currentVideo)&&(identical(other.playlistIndex, playlistIndex) || other.playlistIndex == playlistIndex));
return identical(this, other) || (other.runtimeType == runtimeType&&other is _VideoInfoModel&&(identical(other.currentVideo, currentVideo) || other.currentVideo == currentVideo)&&const DeepCollectionEquality().equals(other._alternativeVideos, _alternativeVideos)&&(identical(other.videoIndex, videoIndex) || other.videoIndex == videoIndex)&&(identical(other.playlistIndex, playlistIndex) || other.playlistIndex == playlistIndex));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(runtimeType,currentVideo,playlistIndex);
int get hashCode => Object.hash(runtimeType,currentVideo,const DeepCollectionEquality().hash(_alternativeVideos),videoIndex,playlistIndex);
@override
String toString() {
return 'VideoInfoModel(currentVideo: $currentVideo, playlistIndex: $playlistIndex)';
return 'VideoInfoModel(currentVideo: $currentVideo, alternativeVideos: $alternativeVideos, videoIndex: $videoIndex, playlistIndex: $playlistIndex)';
}
@@ -249,7 +259,7 @@ abstract mixin class _$VideoInfoModelCopyWith<$Res> implements $VideoInfoModelCo
factory _$VideoInfoModelCopyWith(_VideoInfoModel value, $Res Function(_VideoInfoModel) _then) = __$VideoInfoModelCopyWithImpl;
@override @useResult
$Res call({
@ext.VideoConverter() ext.Video currentVideo, int playlistIndex
@ext.VideoConverter() ext.Video currentVideo,@ext.VideoConverter() List<ext.Video> alternativeVideos, int videoIndex, int playlistIndex
});
@@ -266,10 +276,12 @@ class __$VideoInfoModelCopyWithImpl<$Res>
/// Create a copy of VideoInfoModel
/// with the given fields replaced by the non-null parameter values.
@override @pragma('vm:prefer-inline') $Res call({Object? currentVideo = null,Object? playlistIndex = null,}) {
@override @pragma('vm:prefer-inline') $Res call({Object? currentVideo = null,Object? alternativeVideos = null,Object? videoIndex = null,Object? playlistIndex = null,}) {
return _then(_VideoInfoModel(
currentVideo: null == currentVideo ? _self.currentVideo : currentVideo // ignore: cast_nullable_to_non_nullable
as ext.Video,playlistIndex: null == playlistIndex ? _self.playlistIndex : playlistIndex // ignore: cast_nullable_to_non_nullable
as ext.Video,alternativeVideos: null == alternativeVideos ? _self._alternativeVideos : alternativeVideos // ignore: cast_nullable_to_non_nullable
as List<ext.Video>,videoIndex: null == videoIndex ? _self.videoIndex : videoIndex // ignore: cast_nullable_to_non_nullable
as int,playlistIndex: null == playlistIndex ? _self.playlistIndex : playlistIndex // ignore: cast_nullable_to_non_nullable
as int,
));
}

View File

@@ -11,11 +11,21 @@ _VideoInfoModel _$VideoInfoModelFromJson(Map<String, dynamic> json) =>
currentVideo: const VideoConverter().fromJson(
json['currentVideo'] as Map<String, dynamic>,
),
alternativeVideos: (json['alternativeVideos'] as List<dynamic>)
.map(
(e) => const VideoConverter().fromJson(e as Map<String, dynamic>),
)
.toList(),
videoIndex: (json['videoIndex'] as num).toInt(),
playlistIndex: (json['playlistIndex'] as num).toInt(),
);
Map<String, dynamic> _$VideoInfoModelToJson(_VideoInfoModel instance) =>
<String, dynamic>{
'currentVideo': const VideoConverter().toJson(instance.currentVideo),
'alternativeVideos': instance.alternativeVideos
.map(const VideoConverter().toJson)
.toList(),
'videoIndex': instance.videoIndex,
'playlistIndex': instance.playlistIndex,
};

View File

@@ -3,7 +3,6 @@ import 'package:flutter/material.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:window_manager/window_manager.dart';
import 'package:fvp/fvp.dart' as fvp;
import 'package:hive_ce/hive.dart';
import 'package:logger/logger.dart';
@@ -32,18 +31,16 @@ void main() async {
setupLocatorAfterHiveInit();
// Setup Window Manager options
WindowOptions windowOptions = const WindowOptions(
size: Size(1280, 720),
center: true,
minimumSize: Size(1280, 720),
center: false,
backgroundColor: Colors.transparent,
titleBarStyle: TitleBarStyle.normal,
title: "Unyo",
);
windowManager.waitUntilReadyToShow(windowOptions, () async {
windowManager.show();
windowManager.focus();
windowManager.setPreventClose(true);
});
//Run Flutter app with localization and screen utilities
// Run Flutter app with localization and screen utilities
runApp(
EasyLocalization(
supportedLocales: const [Locale('en', 'GB')],

View File

@@ -37,6 +37,7 @@ class _TextFieldDialogState extends State<TextFieldDialog> {
child: Column(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
SizedBox(height: 5.h,),
Text(widget.title, style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold)),
SizedBox(height: 10.h),
UnyoTextfield(

View File

@@ -0,0 +1,37 @@
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:unyo/presentation/widgets/styled/dark_unyo_button.dart';
class WarningDialog extends StatelessWidget {
final double width;
final double height;
final String title;
const WarningDialog({super.key, required this.width, required this.height, required this.title});
@override
Widget build(BuildContext context) {
return Dialog(
child: Padding(
padding: EdgeInsets.symmetric(horizontal: 8.0.w, vertical: 24.0.h),
child: SizedBox(
width: width,
height: height,
child: Column(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(title, style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold)),
SizedBox(height: 15.h),
DarkUnyoButton(
text: "Confirm",
onPressed: () {
Navigator.of(context).pop();
},
),
],
),
),
),
);
}
}

View File

@@ -1,3 +1,5 @@
import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
@@ -5,7 +7,14 @@ class DarkUnyoButton extends StatelessWidget {
final String? text;
final Widget? child;
final Color color;
final Color textColor;
final bool isEnabled;
final double? maxWidth;
final double? maxHeight;
final double? minWidth;
final double? minHeight;
final double? width;
final double? height;
final void Function() onPressed;
const DarkUnyoButton({
@@ -15,20 +24,32 @@ class DarkUnyoButton extends StatelessWidget {
this.isEnabled = true,
this.child,
this.color = const Color.fromARGB(255, 37, 37, 37),
this.textColor = Colors.white,
this.width,
this.height,
this.minWidth,
this.minHeight,
this.maxWidth,
this.maxHeight,
});
@override
Widget build(BuildContext context) {
return ElevatedButton(
style: ButtonStyle(
minimumSize: WidgetStatePropertyAll( Size(80.w, 45.h)),
backgroundColor: WidgetStatePropertyAll(
color,
return Material(
color: Colors.transparent,
child: InkWell(
onTap: isEnabled ? onPressed : null,
highlightColor: Color.lerp(Colors.white, color, 1.0),
borderRadius: BorderRadius.circular(20.0),
child: Container(
width: width ?? 80.w.clamp(minWidth ?? 0, maxWidth ?? math.max(minWidth ?? 0, 80.w)),
height: height ?? 45.h.clamp(minHeight ?? 0, maxHeight ?? math.max(minHeight ?? 0, 45.h)),
decoration: BoxDecoration(borderRadius: BorderRadius.circular(20.0), color: color.withOpacity(0.9)),
child: Center(
child: Text(text ?? "", style: TextStyle(color: textColor, fontWeight: FontWeight.w700)),
),
),
foregroundColor: const WidgetStatePropertyAll(Colors.white),
),
onPressed: isEnabled ? onPressed : null,
child: text != null ? Text(text!, style: TextStyle(fontSize: 10.sp),) : child ?? const SizedBox(),
);
}
}

View File

@@ -3,11 +3,13 @@ import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:loading_animation_widget/loading_animation_widget.dart';
import 'package:unyo/application/cubits/video_cubit.dart';
import 'package:unyo/application/states/video_state.dart';
// Internal dependencies
import 'package:unyo/core/services/video/video_service.dart';
import 'package:unyo/presentation/widgets/styled/dark_unyo_button.dart';
class UnyoVideoControls extends StatefulWidget {
final VideoCubit videoCubit;
@@ -23,25 +25,35 @@ class _UnyoVideoControlsState extends State<UnyoVideoControls> with TickerProvid
late AnimationController _controller;
late Timer _refreshTimer;
late VideoService _videoService;
late Timer _hideControlsTimer;
late bool _controlsVisible;
@override
void initState() {
super.initState();
_controlsVisible = true;
_controller = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 400), // Adjust the duration as needed
);
_controller.animateTo(1.0);
_videoService = widget.videoService;
_refreshTimer = Timer.periodic(const Duration(milliseconds: 100), (_) {
if (mounted && _videoService.isPlaying) {
setState(() {});
}
});
_hideControlsTimer = Timer(const Duration(seconds: 3), () {
if (mounted && _videoService.isPlaying) {
_controlsVisible = false;
}
});
}
@override
void dispose() {
_refreshTimer.cancel();
_hideControlsTimer.cancel();
super.dispose();
}
@@ -51,92 +63,264 @@ class _UnyoVideoControlsState extends State<UnyoVideoControls> with TickerProvid
value: widget.videoCubit,
child: BlocBuilder<VideoCubit, VideoState>(
builder: (context, state) {
return Stack(
children: [
// Header
Align(
alignment: Alignment.topCenter,
child: SizedBox(
height: 60,
child: Padding(
padding: const EdgeInsets.all(10.0),
child: Row(
return !_videoService.isBuffering
? MouseRegion(
onHover: (event) {
_hideControlsTimer.cancel();
_controlsVisible = true;
setState(() {});
_hideControlsTimer = Timer(const Duration(seconds: 3), () {
if (mounted && _videoService.isPlaying) {
_controlsVisible = false;
setState(() {});
}
});
},
cursor: _controlsVisible ? SystemMouseCursors.basic : SystemMouseCursors.none,
child: AnimatedOpacity(
duration: const Duration(milliseconds: 300),
opacity: _controlsVisible ? 1 : 0,
child: Stack(
children: [
IconButton(
onPressed: () => context.read<VideoCubit>().navigateBackToAnimeDetailsPage(context),
icon: Icon(
Icons.arrow_back_ios_new_rounded,
color: ColorScheme.of(context).tertiary,
// Screen Gestures
GestureDetector(
onTap: () {
if (_videoService.isPlaying) {
_refreshTimer.cancel();
_videoService.pause();
_controlsVisible = true;
setState(() {});
_controller.animateTo(0.0);
}
},
),
// Header
Align(
alignment: Alignment.topCenter,
child: SizedBox(
height: 60,
child: Padding(
padding: const EdgeInsets.all(10.0),
child: Row(
children: [
IconButton(
onPressed: () =>
context.read<VideoCubit>().navigateBackToAnimeDetailsPage(context),
icon: Icon(
Icons.arrow_back_ios_new_rounded,
color: ColorScheme.of(context).tertiary,
),
),
const SizedBox(width: 10),
Text(
"${state.selectedAnime.title.userPreferred} - Episode ${state.videoInfo.playlistIndex + 1}",
style: const TextStyle(fontWeight: FontWeight.w700, fontSize: 14),
),
],
),
),
),
),
const SizedBox(width: 10),
Text(
"${state.selectedAnime.title.userPreferred} - Episode ${state.videoInfo.playlistIndex + 1}",
style: const TextStyle(fontWeight: FontWeight.w700, fontSize: 14),
// Video Controls Overlay
AnimatedOpacity(
duration: const Duration(milliseconds: 500),
opacity: _videoService.isPlaying ? 0 : 1,
child: Align(
alignment: Alignment.center,
child: IgnorePointer(
ignoring: _videoService.isPlaying,
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
IconButton(
onPressed: () {
_videoService.reverse(const Duration(seconds: 15));
setState(() {});
},
icon: Tooltip(
waitDuration: const Duration(milliseconds: 2000),
textAlign: TextAlign.center,
message: "Reverse 15s",
child: Icon(Icons.fast_rewind_rounded, color: Colors.white, size: 80.w),
),
),
SizedBox(width: 20.w),
Material(
color: Colors.transparent,
child: Tooltip(
waitDuration: const Duration(milliseconds: 2000),
textAlign: TextAlign.center,
message: "Resume Play",
child: InkWell(
onTap: () {
if (!_videoService.isPlaying) {
_videoService.play();
setState(() {});
_refreshTimer = Timer.periodic(
const Duration(milliseconds: 100),
(_) {
if (mounted && _videoService.isPlaying) {
setState(() {});
}
},
);
_hideControlsTimer = Timer(const Duration(seconds: 3), () {
if (mounted && _videoService.isPlaying) {
_controlsVisible = false;
}
});
_controller.animateTo(1.0);
}
},
// highlightColor: Colors.white12,
hoverColor: Colors.white10,
splashColor: Colors.white12,
borderRadius: BorderRadius.circular(60.w),
child: AnimatedIcon(
icon: AnimatedIcons.play_pause,
size: 100.w,
color: Colors.white,
progress: _controller,
),
),
),
),
SizedBox(width: 20.w),
Tooltip(
waitDuration: const Duration(milliseconds: 2000),
textAlign: TextAlign.center,
message: "Forward 15s",
child: IconButton(
onPressed: () {
_videoService.forward(const Duration(seconds: 15));
setState(() {});
},
icon: Icon(Icons.fast_forward_rounded, color: Colors.white, size: 80.w),
),
),
],
),
),
),
),
// Video Slider And Controls
Align(
alignment: Alignment.bottomCenter,
child: SizedBox(
height: 135,
child: Column(
children: [
// Video Slider
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
// TODO - AniSkip
Opacity(
opacity: 0.7,
child: DarkUnyoButton(
text: "${state.loggedUser.settings.manualSkipTime.toString()}s",
color: ColorScheme.of(context).primary,
width: 80.w,
maxWidth: 90,
maxHeight: 45,
onPressed: () {},
),
),
SizedBox(width: 20.w),
],
),
const SizedBox(height: 15,),
Row(
children: [
SizedBox(width: 20.w),
SizedBox(
width: 75,
child: Text(
_videoService.position.toString().substring(0, 7),
textAlign: TextAlign.center,
style: const TextStyle(color: Colors.white, fontSize: 16, fontWeight: FontWeight.w400),
),
),
Expanded(
child: Slider(
min: 0,
max: _videoService.duration.inMilliseconds.toDouble(),
value: _videoService.position.inMilliseconds.toDouble(),
label: _videoService.formattedPosition,
divisions: _videoService.duration.inMilliseconds.toDouble() > 0
? _videoService.duration.inMilliseconds
: null,
onChanged: (value) {
if (!mounted) return;
setState(() {});
_videoService.seekTo(Duration(milliseconds: value.toInt()));
},
),
),
SizedBox(
width: 75,
child: Text(
_videoService.duration.toString().substring(0, 7),
style: const TextStyle(color: Colors.white, fontSize: 16, fontWeight: FontWeight.w400),
),
),
SizedBox(width: 15.w),
],
),
// Video Controls
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
// Left Aligned
Row(mainAxisAlignment: MainAxisAlignment.start, children: []),
// Right Aligned
Row(mainAxisAlignment: MainAxisAlignment.end, children: []),
],
),
],
),
),
),
],
),
),
),
),
// Video Controls Overlay
const Align(
alignment: Alignment.center,
child: Row(children: []),
),
// Video Slider And Controls
Align(
alignment: Alignment.bottomCenter,
child: SizedBox(
height: 90,
child: Column(
children: [
// Video Slider
Row(
children: [
SizedBox(width: 20.w),
SizedBox(
width: 75,
child: Text(
_videoService.position.toString().substring(0, 7),
textAlign: TextAlign.center,
style: const TextStyle(color: Colors.white, fontSize: 16),
),
)
: Stack(
children: [
Align(
alignment: Alignment.topCenter,
child: SizedBox(
height: 60,
child: Padding(
padding: const EdgeInsets.all(10.0),
child: Row(
children: [
IconButton(
onPressed: () =>
context.read<VideoCubit>().navigateBackToAnimeDetailsPage(context),
icon: Icon(
Icons.arrow_back_ios_new_rounded,
color: ColorScheme.of(context).tertiary,
),
),
const SizedBox(width: 10),
Text(
"${state.selectedAnime.title.userPreferred} - Episode ${state.videoInfo.playlistIndex + 1}",
style: const TextStyle(fontWeight: FontWeight.w700, fontSize: 14),
),
],
),
Expanded(
child: Slider(
min: 0,
max: _videoService.duration.inMilliseconds.toDouble(),
value: _videoService.position.inMilliseconds.toDouble(),
label: _videoService.formattedPosition,
divisions: _videoService.duration.inMilliseconds.toDouble() > 0
? _videoService.duration.inMilliseconds
: null,
onChanged: (value) {
if (!mounted) return;
setState(() {});
_videoService.seekTo(Duration(milliseconds: value.toInt()));
},
),
),
SizedBox(
width: 75,
child: Text(
_videoService.duration.toString().substring(0, 7),
style: const TextStyle(color: Colors.white, fontSize: 16),
),
),
SizedBox(width: 15.w),
],
),
),
// Video Controls
Row(children: []),
],
),
),
),
],
);
),
Align(
alignment: Alignment.center,
child: Center(
child: LoadingAnimationWidget.fourRotatingDots(color: Colors.white, size: 100.w),
),
),
],
);
},
),
);

View File

@@ -1,3 +1,5 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:unyo/core/services/video/video_service.dart';
@@ -19,6 +21,17 @@ class _UnyoVideoTextureState extends State<UnyoVideoTexture> {
super.initState();
_videoService = widget.videoService;
aspectRatio = _videoService.aspectRatio;
if (aspectRatio <= 0) {
aspectRatio = 16 / 9; // Default aspect ratio
Timer.periodic(const Duration(milliseconds: 200), (timer) {
if (_videoService.aspectRatio > 0) {
setState(() {
aspectRatio = _videoService.aspectRatio;
});
timer.cancel();
}
});
}
_videoService.updateTexture();
}

View File

@@ -12,6 +12,8 @@ PODS:
- FlutterMacOS
- mdk (~> 0.33.1)
- mdk (0.33.1)
- package_info_plus (0.0.1):
- FlutterMacOS
- path_provider_foundation (0.0.1):
- Flutter
- FlutterMacOS
@@ -25,6 +27,8 @@ PODS:
- video_player_avfoundation (0.0.1):
- Flutter
- FlutterMacOS
- wakelock_plus (0.0.1):
- FlutterMacOS
- window_manager (0.5.0):
- FlutterMacOS
@@ -34,11 +38,13 @@ DEPENDENCIES:
- flutter_discord_rpc (from `Flutter/ephemeral/.symlinks/plugins/flutter_discord_rpc/macos`)
- FlutterMacOS (from `Flutter/ephemeral`)
- fvp (from `Flutter/ephemeral/.symlinks/plugins/fvp/darwin`)
- package_info_plus (from `Flutter/ephemeral/.symlinks/plugins/package_info_plus/macos`)
- path_provider_foundation (from `Flutter/ephemeral/.symlinks/plugins/path_provider_foundation/darwin`)
- screen_retriever_macos (from `Flutter/ephemeral/.symlinks/plugins/screen_retriever_macos/macos`)
- shared_preferences_foundation (from `Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin`)
- url_launcher_macos (from `Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos`)
- video_player_avfoundation (from `Flutter/ephemeral/.symlinks/plugins/video_player_avfoundation/darwin`)
- wakelock_plus (from `Flutter/ephemeral/.symlinks/plugins/wakelock_plus/macos`)
- window_manager (from `Flutter/ephemeral/.symlinks/plugins/window_manager/macos`)
SPEC REPOS:
@@ -56,6 +62,8 @@ EXTERNAL SOURCES:
:path: Flutter/ephemeral
fvp:
:path: Flutter/ephemeral/.symlinks/plugins/fvp/darwin
package_info_plus:
:path: Flutter/ephemeral/.symlinks/plugins/package_info_plus/macos
path_provider_foundation:
:path: Flutter/ephemeral/.symlinks/plugins/path_provider_foundation/darwin
screen_retriever_macos:
@@ -66,6 +74,8 @@ EXTERNAL SOURCES:
:path: Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos
video_player_avfoundation:
:path: Flutter/ephemeral/.symlinks/plugins/video_player_avfoundation/darwin
wakelock_plus:
:path: Flutter/ephemeral/.symlinks/plugins/wakelock_plus/macos
window_manager:
:path: Flutter/ephemeral/.symlinks/plugins/window_manager/macos
@@ -76,11 +86,13 @@ SPEC CHECKSUMS:
FlutterMacOS: d0db08ddef1a9af05a5ec4b724367152bb0500b1
fvp: ed100b827d10aff53789fb9cb9dfdd85a87d037d
mdk: 622e3452cea55a982c0712ec9ce931d8ec718b22
package_info_plus: f0052d280d17aa382b932f399edf32507174e870
path_provider_foundation: 080d55be775b7414fd5a5ef3ac137b97b097e564
screen_retriever_macos: 452e51764a9e1cdb74b3c541238795849f21557f
shared_preferences_foundation: 9e1978ff2562383bd5676f64ec4e9aa8fa06a6f7
url_launcher_macos: 0fba8ddabfc33ce0a9afe7c5fef5aab3d8d2d673
video_player_avfoundation: 2cef49524dd1f16c5300b9cd6efd9611ce03639b
wakelock_plus: 917609be14d812ddd9e9528876538b2263aaa03b
window_manager: b729e31d38fb04905235df9ea896128991cad99e
PODFILE CHECKSUM: 54d867c82ac51cbd61b565781b9fada492027009