From 481491d1722d918f2c004c90436b2b99aa2bc687 Mon Sep 17 00:00:00 2001 From: omkar Date: Tue, 14 Jan 2025 17:02:03 +0530 Subject: [PATCH] fix: change video --- .../types/stremio/stremio_base.types.dart | 7 + .../widget/stremio/stremio_card.dart | 389 +++++++++--------- .../doc_viewer/container/video_viewer.dart | 214 +++++----- .../video_viewer/desktop_video_player.dart | 23 +- .../video_viewer/mobile_video_player.dart | 154 ------- .../container/video_viewer/season_source.dart | 222 ++++++++++ .../video_viewer/video_viewer_mobile_ui.dart | 170 +++++++- .../video_viewer/video_viewer_ui.dart | 85 +++- .../container/stremio_stream_selector.dart | 18 +- lib/features/trakt/service/trakt.service.dart | 3 + 10 files changed, 830 insertions(+), 455 deletions(-) delete mode 100644 lib/features/doc_viewer/container/video_viewer/mobile_video_player.dart create mode 100644 lib/features/doc_viewer/container/video_viewer/season_source.dart diff --git a/lib/features/connections/types/stremio/stremio_base.types.dart b/lib/features/connections/types/stremio/stremio_base.types.dart index 4a796fb..a45b06a 100644 --- a/lib/features/connections/types/stremio/stremio_base.types.dart +++ b/lib/features/connections/types/stremio/stremio_base.types.dart @@ -460,6 +460,13 @@ class Meta extends LibraryItem { } Map toJson() => _$MetaToJson(this); + + String toString() { + if (currentVideo != null) { + return "$name ${currentVideo!.name} S${currentVideo!.season} E${currentVideo!.episode}"; + } + return name ?? "No name"; + } } @JsonSerializable() diff --git a/lib/features/connections/widget/stremio/stremio_card.dart b/lib/features/connections/widget/stremio/stremio_card.dart index c7ae430..5503813 100644 --- a/lib/features/connections/widget/stremio/stremio_card.dart +++ b/lib/features/connections/widget/stremio/stremio_card.dart @@ -25,8 +25,6 @@ class StremioCard extends StatefulWidget { } class _StremioCardState extends State { - bool hasErrorWhileLoading = false; - @override Widget build(BuildContext context) { final meta = widget.item as Meta; @@ -59,189 +57,8 @@ class _StremioCardState extends State { ); } - bool get isInFuture { - final video = (widget.item as Meta).currentVideo; - return video != null && - video.firstAired != null && - video.firstAired!.isAfter(DateTime.now()); - } - _buildWideCard(BuildContext context, Meta meta) { - if (meta.background == null) { - return Container(); - } - - final video = meta.currentVideo; - - return Container( - decoration: BoxDecoration( - image: DecorationImage( - image: CachedNetworkImageProvider( - "https://proxy-image.syncws.com/insecure/plain/${Uri.encodeQueryComponent( - hasErrorWhileLoading - ? meta.background! - : (meta.currentVideo?.thumbnail ?? meta.background!), - )}@webp", - errorListener: (error) { - setState(() { - hasErrorWhileLoading = true; - }); - }, - imageRenderMethodForWeb: ImageRenderMethodForWeb.HttpGet, - ), - fit: BoxFit.cover, - ), - ), - child: Stack( - children: [ - if (isInFuture) - Positioned.fill( - child: Container( - decoration: const BoxDecoration( - gradient: LinearGradient( - colors: [ - Colors.black, - Colors.black54, - ], - begin: Alignment.topLeft, - end: Alignment.bottomRight, - ), - ), - ), - ), - Positioned.fill( - child: Container( - decoration: const BoxDecoration( - gradient: LinearGradient( - colors: [ - Colors.black, - Colors.transparent, - ], - begin: Alignment.bottomLeft, - end: Alignment.center, - ), - ), - ), - ), - Positioned( - bottom: 0, - child: Padding( - padding: const EdgeInsets.all(12.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisAlignment: MainAxisAlignment.start, - children: [ - Text( - "${meta.name}", - style: Theme.of(context).textTheme.titleMedium, - ), - const SizedBox( - height: 4, - ), - Container( - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(4), - ), - padding: const EdgeInsets.symmetric(horizontal: 4), - child: Text( - "S${meta.currentVideo?.season} E${meta.currentVideo?.episode}", - style: Theme.of(context).textTheme.bodyLarge?.copyWith( - color: Colors.black, - ), - ), - ), - Text( - "${meta.currentVideo?.name ?? meta.currentVideo?.title}" - .trim(), - style: Theme.of(context).textTheme.headlineSmall, - ), - ], - ), - ), - ), - if (isInFuture) - Padding( - padding: const EdgeInsets.all(12.0), - child: Text( - getRelativeDate(video!.firstAired!), - style: Theme.of(context).textTheme.bodyLarge, - ), - ), - if (isInFuture) - const Positioned( - bottom: 0, - right: 0, - child: Padding( - padding: EdgeInsets.symmetric( - horizontal: 8, - vertical: 4, - ), - child: Column( - children: [ - Padding( - padding: EdgeInsets.symmetric( - horizontal: 4, - vertical: 10, - ), - child: Icon( - Icons.calendar_month, - ), - ), - ], - ), - ), - ), - const Positioned( - child: Center( - child: IconButton.filled( - onPressed: null, - icon: Icon( - Icons.play_arrow, - size: 24, - ), - ), - ), - ), - meta.imdbRating != "" - ? Align( - alignment: Alignment.topRight, - child: Padding( - padding: const EdgeInsets.all(8.0), - child: Container( - decoration: BoxDecoration( - color: Colors.black54, - borderRadius: BorderRadius.circular(8), - ), - padding: const EdgeInsets.symmetric( - horizontal: 8, - vertical: 4, - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - const Icon( - Icons.star, - color: Colors.amber, - size: 16, - ), - const SizedBox(width: 4), - Text( - meta.imdbRating, - style: const TextStyle( - color: Colors.white, - fontSize: 12, - ), - ), - ], - ), - ), - ), - ) - : const SizedBox.shrink(), - ], - ), - ); + return WideCardStremio(meta: meta); } String? getBackgroundImage(Meta meta) { @@ -419,6 +236,210 @@ class _StremioCardState extends State { } } +class WideCardStremio extends StatefulWidget { + final Meta meta; + final Video? video; + + const WideCardStremio({ + super.key, + required this.meta, + this.video, + }); + + @override + State createState() => _WideCardStremioState(); +} + +class _WideCardStremioState extends State { + bool hasErrorWhileLoading = false; + + bool get isInFuture { + final video = widget.video ?? widget.meta.currentVideo; + return video != null && + video.firstAired != null && + video.firstAired!.isAfter(DateTime.now()); + } + + @override + Widget build(BuildContext context) { + if (widget.meta.background == null) { + return Container(); + } + + final video = widget.video ?? widget.meta.currentVideo; + + return Container( + decoration: BoxDecoration( + image: DecorationImage( + image: CachedNetworkImageProvider( + "https://proxy-image.syncws.com/insecure/plain/${Uri.encodeQueryComponent( + hasErrorWhileLoading + ? widget.meta.background! + : (widget.meta.currentVideo?.thumbnail ?? + widget.meta.background!), + )}@webp", + errorListener: (error) { + setState(() { + hasErrorWhileLoading = true; + }); + }, + imageRenderMethodForWeb: ImageRenderMethodForWeb.HttpGet, + ), + fit: BoxFit.cover, + ), + ), + child: Stack( + children: [ + if (isInFuture) + Positioned.fill( + child: Container( + decoration: const BoxDecoration( + gradient: LinearGradient( + colors: [ + Colors.black, + Colors.black54, + ], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + ), + ), + ), + Positioned.fill( + child: Container( + decoration: const BoxDecoration( + gradient: LinearGradient( + colors: [ + Colors.black, + Colors.transparent, + ], + begin: Alignment.bottomLeft, + end: Alignment.center, + ), + ), + ), + ), + Positioned( + bottom: 0, + child: Padding( + padding: const EdgeInsets.all(12.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.start, + children: [ + Text( + "${widget.meta.name}", + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox( + height: 4, + ), + Container( + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(4), + ), + padding: const EdgeInsets.symmetric(horizontal: 4), + child: Text( + "S${video?.season} E${video?.episode}", + style: Theme.of(context).textTheme.bodyLarge?.copyWith( + color: Colors.black, + ), + ), + ), + Text( + "${video?.name ?? video?.title}".trim(), + style: Theme.of(context).textTheme.headlineSmall, + ), + ], + ), + ), + ), + if (isInFuture) + Padding( + padding: const EdgeInsets.all(12.0), + child: Text( + getRelativeDate(video!.firstAired!), + style: Theme.of(context).textTheme.bodyLarge, + ), + ), + if (isInFuture) + const Positioned( + bottom: 0, + right: 0, + child: Padding( + padding: EdgeInsets.symmetric( + horizontal: 8, + vertical: 4, + ), + child: Column( + children: [ + Padding( + padding: EdgeInsets.symmetric( + horizontal: 4, + vertical: 10, + ), + child: Icon( + Icons.calendar_month, + ), + ), + ], + ), + ), + ), + const Positioned( + child: Center( + child: IconButton.filled( + onPressed: null, + icon: Icon( + Icons.play_arrow, + size: 24, + ), + ), + ), + ), + widget.meta.imdbRating != "" && widget.video == null + ? Align( + alignment: Alignment.topRight, + child: Padding( + padding: const EdgeInsets.all(8.0), + child: Container( + decoration: BoxDecoration( + color: Colors.black54, + borderRadius: BorderRadius.circular(8), + ), + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 4, + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.star, + color: Colors.amber, + size: 16, + ), + const SizedBox(width: 4), + Text( + widget.meta.imdbRating, + style: const TextStyle( + color: Colors.white, + fontSize: 12, + ), + ), + ], + ), + ), + ), + ) + : const SizedBox.shrink(), + ], + ), + ); + } +} + String getRelativeDate(DateTime date) { final now = DateTime.now(); final today = DateTime(now.year, now.month, now.day); diff --git a/lib/features/doc_viewer/container/video_viewer.dart b/lib/features/doc_viewer/container/video_viewer.dart index a03bf32..3b383c3 100644 --- a/lib/features/doc_viewer/container/video_viewer.dart +++ b/lib/features/doc_viewer/container/video_viewer.dart @@ -1,6 +1,5 @@ import 'dart:async'; -import 'package:flutter/cupertino.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; @@ -12,7 +11,6 @@ import 'package:media_kit_video/media_kit_video.dart'; import '../../../utils/load_language.dart'; import '../../connections/types/stremio/stremio_base.types.dart' as types; -import '../../connections/widget/stremio/stremio_season_selector.dart'; import '../../trakt/service/trakt.service.dart'; import '../../watch_history/service/zeee_watch_history.dart'; import '../types/doc_source.dart'; @@ -39,6 +37,8 @@ class VideoViewer extends StatefulWidget { } class _VideoViewerState extends State { + late LibraryItem? meta = widget.meta; + final zeeeWatchHistory = ZeeeWatchHistoryStatic.service; Timer? _timer; late final Player player = Player( @@ -81,18 +81,18 @@ class _VideoViewerState extends State { return; } - if (widget.meta is types.Meta && TraktService.instance != null) { + if (meta is types.Meta && TraktService.instance != null) { try { if (player.state.playing) { _logger.info('Starting scrobbling...'); await TraktService.instance!.startScrobbling( - meta: widget.meta as types.Meta, + meta: meta as types.Meta, progress: currentProgressInPercentage, ); } else { _logger.info('Stopping scrobbling...'); await TraktService.instance!.stopScrobbling( - meta: widget.meta as types.Meta, + meta: meta as types.Meta, progress: currentProgressInPercentage, ); } @@ -147,11 +147,11 @@ class _VideoViewerState extends State { final progress = await traktProgress; - if (widget.meta is! types.Meta) { + if (this.meta is! types.Meta) { return; } - final meta = (progress ?? widget.meta) as types.Meta; + final meta = (progress ?? this.meta) as types.Meta; final duration = Duration( seconds: calculateSecondsFromProgress( @@ -174,6 +174,54 @@ class _VideoViewerState extends State { PlaybackConfig config = getPlaybackConfig(); + Future setupVideoThings() async { + _duration = player.stream.duration.listen((item) async { + if (item.inSeconds != 0) { + await saveWatchHistory(); + } + }); + + _timer = Timer.periodic(const Duration(seconds: 30), (timer) { + saveWatchHistory(); + }); + + _streamListen = player.stream.playing.listen((playing) { + saveWatchHistory(); + }); + + return loadFile(); + } + + destroyVideoThing() async { + timeLoaded = false; + gotFromTraktDuration = false; + traktProgress = null; + + for (final item in listener) { + item.cancel(); + } + _timer?.cancel(); + _streamListen?.cancel(); + _duration?.cancel(); + + if (meta is types.Meta && player.state.duration.inSeconds > 30) { + await TraktService.instance!.stopScrobbling( + meta: meta as types.Meta, + progress: currentProgressInPercentage, + shouldClearCache: true, + traktId: traktId, + ); + } + } + + GlobalKey videoKey = GlobalKey(); + + generateNewKey() { + videoKey = GlobalKey(); + + setState(() {}); + } + @override void initState() { super.initState(); @@ -184,58 +232,50 @@ class _VideoViewerState extends State { overlays: [], ); - _duration = player.stream.duration.listen((item) async { - if (item.inSeconds != 0) { - await setDurationFromTrakt(); - await saveWatchHistory(); - } - }); - - loadFile(); - if (player.platform is NativePlayer && !kIsWeb) { Future.microtask(() async { await (player.platform as dynamic).setProperty('network-timeout', '60'); }); } - _timer = Timer.periodic(const Duration(seconds: 30), (timer) { - saveWatchHistory(); - }); - - _streamListen = player.stream.playing.listen((playing) { - saveWatchHistory(); - }); - - if (widget.meta is types.Meta && TraktService.isEnabled()) { - traktProgress = TraktService.instance!.getProgress( - widget.meta as types.Meta, - ); - } + onVideoChange( + _source, + widget.meta!, + ); } - loadFile() async { + Future loadFile() async { + Duration duration = const Duration(seconds: 0); + + if (meta is types.Meta && TraktService.isEnabled()) { + _logger.info("Playing video ${(meta as types.Meta).selectedVideoIndex}"); + + traktProgress = TraktService.instance!.getProgress( + meta as types.Meta, + ); + } else { + final item = await zeeeWatchHistory!.getItemWatchHistory( + ids: [ + WatchHistoryGetRequest( + id: _source.id, + season: _source.season, + episode: _source.episode, + ), + ], + ); + + duration = Duration( + seconds: item.isEmpty + ? 0 + : calculateSecondsFromProgress( + item.first.duration, + item.first.progress.toDouble(), + ), + ); + } + _logger.info('Loading file for source: ${_source.id}'); - final item = await zeeeWatchHistory!.getItemWatchHistory( - ids: [ - WatchHistoryGetRequest( - id: _source.id, - season: _source.season, - episode: _source.episode, - ), - ], - ); - - final duration = Duration( - seconds: item.isEmpty - ? 0 - : calculateSecondsFromProgress( - item.first.duration, - item.first.progress.toDouble(), - ), - ); - switch (_source.runtimeType) { case const (FileSource): if (kIsWeb) { @@ -262,41 +302,8 @@ class _VideoViewerState extends State { } } - late StreamSubscription _streamListen; - late StreamSubscription _duration; - - onLibrarySelect() async { - _logger.info('Library selection triggered.'); - - controller.player.pause(); - - final result = await showCupertinoDialog( - context: context, - builder: (context) { - return Scaffold( - appBar: AppBar( - title: const Text("Seasons"), - ), - body: CustomScrollView( - slivers: [ - StremioItemSeasonSelector( - service: widget.service, - meta: widget.meta as types.Meta, - shouldPop: true, - season: int.tryParse(widget.currentSeason!), - ), - ], - ), - ); - }, - ); - - if (result is MediaURLSource) { - _source = result; - - loadFile(); - } - } + late StreamSubscription? _streamListen; + late StreamSubscription? _duration; @override void dispose() { @@ -308,43 +315,42 @@ class _VideoViewerState extends State { DeviceOrientation.landscapeLeft, DeviceOrientation.landscapeRight, ]); - for (final item in listener) { - item.cancel(); - } SystemChrome.setEnabledSystemUIMode( SystemUiMode.edgeToEdge, overlays: [], ); - _timer?.cancel(); - _streamListen.cancel(); - _duration.cancel(); - - if (widget.meta is types.Meta && player.state.duration.inSeconds > 30) { - TraktService.instance!.stopScrobbling( - meta: widget.meta as types.Meta, - progress: currentProgressInPercentage, - shouldClearCache: true, - traktId: traktId, - ); - } - + destroyVideoThing(); player.dispose(); - super.dispose(); } + onVideoChange(DocSource source, LibraryItem item) async { + _source = source; + meta = item; + + setState(() {}); + await destroyVideoThing(); + setState(() {}); + traktProgress = null; + await setupVideoThings(); + await setDurationFromTrakt(); + setState(() {}); + generateNewKey(); + } + @override Widget build(BuildContext context) { return Scaffold( body: VideoViewerUi( + key: videoKey, controller: controller, player: player, config: config, source: _source, - onLibrarySelect: onLibrarySelect, - title: _source.title, + onLibrarySelect: () {}, service: widget.service, - meta: widget.meta, + meta: meta, + onSourceChange: (source, meta) => onVideoChange(source, meta), ), ); } diff --git a/lib/features/doc_viewer/container/video_viewer/desktop_video_player.dart b/lib/features/doc_viewer/container/video_viewer/desktop_video_player.dart index 2b2b942..e9f8bc9 100644 --- a/lib/features/doc_viewer/container/video_viewer/desktop_video_player.dart +++ b/lib/features/doc_viewer/container/video_viewer/desktop_video_player.dart @@ -3,12 +3,16 @@ import 'dart:io'; import 'package:flutter/cupertino.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import 'package:madari_client/features/connections/service/base_connection_service.dart'; +import 'package:madari_client/features/doc_viewer/container/video_viewer/season_source.dart'; import 'package:madari_client/features/doc_viewer/container/video_viewer/torrent_stat.dart'; import 'package:madari_client/features/doc_viewer/types/doc_source.dart'; import 'package:media_kit/media_kit.dart'; import 'package:media_kit_video/media_kit_video.dart'; import 'package:window_manager/window_manager.dart'; +import '../../../connections/types/stremio/stremio_base.types.dart'; + MaterialDesktopVideoControlsThemeData getDesktopControls( BuildContext context, { required DocSource source, @@ -16,6 +20,8 @@ MaterialDesktopVideoControlsThemeData getDesktopControls( Widget? library, required Function() onSubtitleSelect, required Function() onAudioSelect, + LibraryItem? meta, + required Function(int index) onVideoChange, }) { return MaterialDesktopVideoControlsThemeData( toggleFullscreenOnDoublePress: true, @@ -34,14 +40,25 @@ MaterialDesktopVideoControlsThemeData getDesktopControls( child: SizedBox( width: MediaQuery.of(context).size.width - 120, child: Text( - source.title.endsWith(".mp4") - ? source.title.substring(0, source.title.length - 4) - : source.title, + (meta is Meta && meta.currentVideo != null) + ? "${meta.name ?? ""} S${meta.currentVideo?.season} E${meta.currentVideo?.episode}" + : source.title.endsWith(".mp4") + ? source.title.substring(0, source.title.length - 4) + : source.title, style: Theme.of(context).textTheme.bodyLarge, ), ), ), ), + const Spacer(), + if (meta is Meta) + if (meta.type == "series") + SeasonSource( + meta: meta, + isMobile: false, + player: player, + onVideoChange: onVideoChange, + ), ], bufferingIndicatorBuilder: source is TorrentSource ? (ctx) { diff --git a/lib/features/doc_viewer/container/video_viewer/mobile_video_player.dart b/lib/features/doc_viewer/container/video_viewer/mobile_video_player.dart deleted file mode 100644 index d8d2cb9..0000000 --- a/lib/features/doc_viewer/container/video_viewer/mobile_video_player.dart +++ /dev/null @@ -1,154 +0,0 @@ -import 'package:flutter/cupertino.dart'; -import 'package:flutter/material.dart'; -import 'package:madari_client/features/doc_viewer/container/video_viewer/torrent_stat.dart'; -import 'package:media_kit/media_kit.dart'; -import 'package:media_kit_video/media_kit_video.dart'; - -import '../../types/doc_source.dart'; - -MaterialVideoControlsThemeData getMobileVideoPlayer( - BuildContext context, { - required DocSource source, - required Player player, - required VoidCallback onSubtitleClick, - required VoidCallback onAudioClick, - required VoidCallback toggleScale, - required VoidCallback onLibrarySelect, -}) { - final mediaQuery = MediaQuery.of(context); - - return MaterialVideoControlsThemeData( - topButtonBar: [ - MaterialCustomButton( - onPressed: () { - Navigator.of(context).pop(); - }, - icon: const Icon( - Icons.arrow_back, - ), - ), - Text( - source.title.endsWith(".mp4") - ? source.title.substring(0, source.title.length - 4) - : source.title, - style: Theme.of(context).textTheme.bodyLarge, - ), - const Spacer(), - ], - bufferingIndicatorBuilder: (source is TorrentSource) - ? (ctx) { - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 10.0), - child: TorrentStats( - torrentHash: (source).infoHash, - ), - ); - } - : null, - brightnessGesture: true, - seekGesture: true, - seekOnDoubleTap: true, - gesturesEnabledWhileControlsVisible: true, - shiftSubtitlesOnControlsVisibilityChange: true, - seekBarMargin: const EdgeInsets.only(bottom: 54), - speedUpOnLongPress: true, - speedUpFactor: 2, - volumeGesture: true, - bottomButtonBar: [ - const MaterialPlayOrPauseButton(), - const MaterialPositionIndicator(), - const Spacer(), - MaterialCustomButton( - onPressed: () { - final speeds = [ - 0.5, - 0.75, - 1.0, - 1.25, - 1.5, - 1.75, - 2.0, - 2.25, - 2.5, - 3.0, - 3.25, - 3.5, - 3.75, - 4.0, - 4.25, - 4.5, - 4.75, - 5.0 - ]; - showCupertinoModalPopup( - context: context, - builder: (ctx) => Card( - child: Container( - height: MediaQuery.of(context).size.height * 0.4, - decoration: BoxDecoration( - color: Theme.of(context).dialogBackgroundColor, - borderRadius: - const BorderRadius.vertical(top: Radius.circular(12)), - ), - child: Column( - children: [ - Padding( - padding: const EdgeInsets.all(8.0), - child: Text( - 'Select Playback Speed', - style: Theme.of(context).textTheme.titleMedium, - ), - ), - Expanded( - child: ListView.builder( - itemCount: speeds.length, - itemBuilder: (context, index) { - final speed = speeds[index]; - return ListTile( - title: Text('${speed}x'), - selected: player.state.rate == speed, - onTap: () { - player.setRate(speed); - Navigator.pop(context); - }, - ); - }, - ), - ), - ], - ), - ), - ), - ); - }, - icon: const Icon(Icons.speed), - ), - MaterialCustomButton( - onPressed: () { - onSubtitleClick(); - }, - icon: const Icon(Icons.subtitles), - ), - MaterialCustomButton( - onPressed: () { - onAudioClick(); - }, - icon: const Icon(Icons.audio_file), - ), - MaterialCustomButton( - onPressed: () { - toggleScale(); - }, - icon: const Icon(Icons.fit_screen_outlined), - ), - ], - topButtonBarMargin: EdgeInsets.only( - top: mediaQuery.padding.top, - ), - bottomButtonBarMargin: EdgeInsets.only( - bottom: mediaQuery.viewInsets.bottom, - left: 4.0, - right: 4.0, - ), - ); -} diff --git a/lib/features/doc_viewer/container/video_viewer/season_source.dart b/lib/features/doc_viewer/container/video_viewer/season_source.dart new file mode 100644 index 0000000..de0ad30 --- /dev/null +++ b/lib/features/doc_viewer/container/video_viewer/season_source.dart @@ -0,0 +1,222 @@ +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:media_kit/media_kit.dart'; +import 'package:media_kit_video/media_kit_video.dart'; + +import '../../../connections/types/stremio/stremio_base.types.dart'; + +class SeasonSource extends StatelessWidget { + final Meta meta; + final bool isMobile; + final Player player; + final Function(int index) onVideoChange; + + const SeasonSource({ + super.key, + required this.meta, + required this.isMobile, + required this.player, + required this.onVideoChange, + }); + + @override + Widget build(BuildContext context) { + return MaterialCustomButton( + onPressed: () => onSelectMobile(context), + icon: const Icon(Icons.list_alt), + ); + } + + onSelectDesktop(BuildContext context) { + showCupertinoDialog( + context: context, + builder: (context) { + return VideoSelectView( + meta: meta, + onVideoChange: onVideoChange, + ); + }, + ); + } + + onSelectMobile(BuildContext context) { + showCupertinoDialog( + context: context, + builder: (context) { + return VideoSelectView( + meta: meta, + onVideoChange: onVideoChange, + ); + }, + ); + } +} + +class VideoSelectView extends StatefulWidget { + final Meta meta; + final Function(int index) onVideoChange; + + const VideoSelectView({ + super.key, + required this.meta, + required this.onVideoChange, + }); + + @override + State createState() => _VideoSelectViewState(); +} + +class _VideoSelectViewState extends State { + final ScrollController controller = ScrollController(); + + @override + void initState() { + super.initState(); + + if (widget.meta.selectedVideoIndex != null) { + WidgetsBinding.instance.addPostFrameCallback((_) { + const itemWidth = 240.0 + 16.0; + final offset = widget.meta.selectedVideoIndex! * itemWidth; + + controller.jumpTo(offset); + }); + } + } + + @override + void dispose() { + super.dispose(); + controller.dispose(); + } + + @override + Widget build(BuildContext context) { + return GestureDetector( + onVerticalDragEnd: (details) { + if (details.primaryVelocity! > 0) { + Navigator.of(context).pop(); + } + }, + child: Scaffold( + backgroundColor: Colors.black38, + appBar: AppBar( + backgroundColor: Colors.transparent, + title: const Text("Episodes"), + ), + body: SafeArea( + child: Column( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + SizedBox( + height: 150, + child: ListView.builder( + controller: controller, + scrollDirection: Axis.horizontal, + itemBuilder: (context, index) { + final video = widget.meta.videos![index]; + + return Padding( + padding: const EdgeInsets.all(8.0), + child: InkWell( + onTap: () { + widget.onVideoChange(index); + }, + child: Stack( + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(12), + child: Container( + decoration: BoxDecoration( + image: DecorationImage( + fit: BoxFit.fill, + image: CachedNetworkImageProvider( + video.thumbnail ?? + widget.meta.poster ?? + widget.meta.background ?? + ""), + ), + ), + child: SizedBox( + width: 240, + child: Column( + mainAxisAlignment: MainAxisAlignment.end, + crossAxisAlignment: + CrossAxisAlignment.stretch, + mainAxisSize: MainAxisSize.max, + children: [ + Container( + decoration: const BoxDecoration( + gradient: LinearGradient( + colors: [ + Colors.black, + Colors.black54, + Colors.black38, + ], + ), + ), + child: Padding( + padding: const EdgeInsets.all(8.0), + child: Column( + crossAxisAlignment: + CrossAxisAlignment.start, + children: [ + Text( + "S${video.season} E${video.episode}", + style: Theme.of(context) + .textTheme + .bodyLarge, + ), + Text( + video.name ?? video.title ?? "", + style: Theme.of(context) + .textTheme + .bodyLarge, + ), + ], + ), + ), + ), + ], + ), + ), + ), + ), + if (widget.meta.selectedVideoIndex == index) + Positioned( + child: Container( + decoration: const BoxDecoration( + gradient: LinearGradient( + colors: [ + Colors.black, + Colors.black54, + Colors.black38, + ], + ), + ), + child: const Padding( + padding: EdgeInsets.all(8.0), + child: Row( + children: [ + Text("Playing"), + Icon(Icons.play_arrow), + ], + ), + ), + ), + ), + ], + ), + ), + ); + }, + itemCount: (widget.meta.videos ?? []).length, + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/features/doc_viewer/container/video_viewer/video_viewer_mobile_ui.dart b/lib/features/doc_viewer/container/video_viewer/video_viewer_mobile_ui.dart index 69f21ed..087a3dd 100644 --- a/lib/features/doc_viewer/container/video_viewer/video_viewer_mobile_ui.dart +++ b/lib/features/doc_viewer/container/video_viewer/video_viewer_mobile_ui.dart @@ -1,11 +1,15 @@ +import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:logging/logging.dart'; +import 'package:madari_client/features/connections/service/base_connection_service.dart'; +import 'package:madari_client/features/doc_viewer/container/video_viewer/season_source.dart'; +import 'package:madari_client/features/doc_viewer/container/video_viewer/torrent_stat.dart'; import 'package:madari_client/features/doc_viewer/types/doc_source.dart'; import 'package:madari_client/utils/load_language.dart'; import 'package:media_kit/media_kit.dart'; import 'package:media_kit_video/media_kit_video.dart'; -import 'mobile_video_player.dart'; +import '../../../connections/types/stremio/stremio_base.types.dart' as types; class VideoViewerMobile extends StatefulWidget { final VoidCallback onSubtitleSelect; @@ -16,6 +20,8 @@ class VideoViewerMobile extends StatefulWidget { final VoidCallback onAudioSelect; final PlaybackConfig config; final GlobalKey videoKey; + final LibraryItem? meta; + final Future Function(int index) onVideoChange; const VideoViewerMobile({ super.key, @@ -27,6 +33,8 @@ class VideoViewerMobile extends StatefulWidget { required this.onAudioSelect, required this.config, required this.videoKey, + required this.meta, + required this.onVideoChange, }); @override @@ -39,7 +47,7 @@ class _VideoViewerMobileState extends State { @override build(BuildContext context) { - final mobile = getMobileVideoPlayer( + final mobile = _getMobileControls( context, onLibrarySelect: widget.onLibrarySelect, player: widget.player, @@ -52,6 +60,7 @@ class _VideoViewerMobileState extends State { }); }, ); + String subtitleStyleName = widget.config.subtitleStyle ?? 'Normal'; String subtitleStyleColor = widget.config.subtitleColor ?? 'white'; double subtitleSize = widget.config.subtitleSize; @@ -100,4 +109,161 @@ class _VideoViewerMobileState extends State { ), ); } + + _getMobileControls( + BuildContext context, { + required DocSource source, + required Player player, + required VoidCallback onSubtitleClick, + required VoidCallback onAudioClick, + required VoidCallback toggleScale, + required VoidCallback onLibrarySelect, + }) { + final mediaQuery = MediaQuery.of(context); + final meta = widget.meta; + + return MaterialVideoControlsThemeData( + topButtonBar: [ + MaterialCustomButton( + onPressed: () { + Navigator.of(context).pop(); + }, + icon: const Icon( + Icons.arrow_back, + ), + ), + Text( + meta.toString(), + style: Theme.of(context).textTheme.bodyLarge, + ), + const Spacer(), + if (meta is types.Meta) + if (meta.type == "series") + SeasonSource( + meta: meta, + isMobile: true, + player: player, + onVideoChange: (index) async { + await widget.onVideoChange(index); + setState(() {}); + }, + ), + ], + bufferingIndicatorBuilder: (source is TorrentSource) + ? (ctx) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 10.0), + child: TorrentStats( + torrentHash: (source).infoHash, + ), + ); + } + : null, + brightnessGesture: true, + seekGesture: true, + seekOnDoubleTap: true, + gesturesEnabledWhileControlsVisible: true, + shiftSubtitlesOnControlsVisibilityChange: true, + seekBarMargin: const EdgeInsets.only(bottom: 54), + speedUpOnLongPress: true, + speedUpFactor: 2, + volumeGesture: true, + bottomButtonBar: [ + const MaterialPlayOrPauseButton(), + const MaterialPositionIndicator(), + const Spacer(), + MaterialCustomButton( + onPressed: () { + final speeds = [ + 0.5, + 0.75, + 1.0, + 1.25, + 1.5, + 1.75, + 2.0, + 2.25, + 2.5, + 3.0, + 3.25, + 3.5, + 3.75, + 4.0, + 4.25, + 4.5, + 4.75, + 5.0 + ]; + showCupertinoModalPopup( + context: context, + builder: (ctx) => Card( + child: Container( + height: MediaQuery.of(context).size.height * 0.4, + decoration: BoxDecoration( + color: Theme.of(context).dialogBackgroundColor, + borderRadius: + const BorderRadius.vertical(top: Radius.circular(12)), + ), + child: Column( + children: [ + Padding( + padding: const EdgeInsets.all(8.0), + child: Text( + 'Select Playback Speed', + style: Theme.of(context).textTheme.titleMedium, + ), + ), + Expanded( + child: ListView.builder( + itemCount: speeds.length, + itemBuilder: (context, index) { + final speed = speeds[index]; + return ListTile( + title: Text('${speed}x'), + selected: player.state.rate == speed, + onTap: () { + player.setRate(speed); + Navigator.pop(context); + }, + ); + }, + ), + ), + ], + ), + ), + ), + ); + }, + icon: const Icon(Icons.speed), + ), + MaterialCustomButton( + onPressed: () { + onSubtitleClick(); + }, + icon: const Icon(Icons.subtitles), + ), + MaterialCustomButton( + onPressed: () { + onAudioClick(); + }, + icon: const Icon(Icons.audio_file), + ), + MaterialCustomButton( + onPressed: () { + toggleScale(); + }, + icon: const Icon(Icons.fit_screen_outlined), + ), + ], + topButtonBarMargin: EdgeInsets.only( + top: mediaQuery.padding.top, + ), + bottomButtonBarMargin: EdgeInsets.only( + bottom: mediaQuery.viewInsets.bottom, + left: 4.0, + right: 4.0, + ), + ); + } } diff --git a/lib/features/doc_viewer/container/video_viewer/video_viewer_ui.dart b/lib/features/doc_viewer/container/video_viewer/video_viewer_ui.dart index d0b52dc..ff3ef80 100644 --- a/lib/features/doc_viewer/container/video_viewer/video_viewer_ui.dart +++ b/lib/features/doc_viewer/container/video_viewer/video_viewer_ui.dart @@ -15,6 +15,7 @@ import 'package:media_kit/media_kit.dart'; import 'package:media_kit_video/media_kit_video.dart'; import '../../../../utils/tv_detector.dart'; +import '../../../connections/types/stremio/stremio_base.types.dart' as types; import '../../../connections/widget/base/render_stream_list.dart'; import 'desktop_video_player.dart'; @@ -24,9 +25,12 @@ class VideoViewerUi extends StatefulWidget { final PlaybackConfig config; final DocSource source; final VoidCallback onLibrarySelect; - final String title; final BaseConnectionService? service; final LibraryItem? meta; + final Function( + DocSource source, + LibraryItem item, + ) onSourceChange; const VideoViewerUi({ super.key, @@ -35,9 +39,9 @@ class VideoViewerUi extends StatefulWidget { required this.config, required this.source, required this.onLibrarySelect, - required this.title, required this.service, this.meta, + required this.onSourceChange, }); @override @@ -131,6 +135,13 @@ class _VideoViewerUiState extends State { listeners.add(listener); } + @override + void didChangeDependencies() { + super.didChangeDependencies(); + + print(widget.meta.toString()); + } + @override void dispose() { super.dispose(); @@ -171,6 +182,41 @@ class _VideoViewerUiState extends State { onAudioSelect: onAudioSelect, config: widget.config, videoKey: key, + meta: widget.meta, + onVideoChange: (index) async { + Navigator.of(context).pop(); + + widget.player.pause(); + + final result = await showModalBottomSheet( + context: context, + isScrollControlled: true, + constraints: BoxConstraints( + maxHeight: MediaQuery.of(context).size.height, + ), + builder: (context) { + return Scaffold( + appBar: AppBar(), + body: RenderStreamList( + service: widget.service!, + id: (widget.meta as types.Meta).copyWith( + selectedVideoIndex: index, + ), + shouldPop: true, + ), + ); + }, + ); + + if (result != null) { + widget.onSourceChange( + result, + (widget.meta as types.Meta).copyWith( + selectedVideoIndex: index, + ), + ); + } + }, ); default: return _buildDesktop(context); @@ -184,6 +230,41 @@ class _VideoViewerUiState extends State { source: widget.source, onAudioSelect: onAudioSelect, onSubtitleSelect: onSubtitleSelect, + meta: widget.meta, + onVideoChange: (index) async { + Navigator.of(context).pop(); + + widget.player.pause(); + + final result = await showModalBottomSheet( + context: context, + isScrollControlled: true, + constraints: BoxConstraints( + maxHeight: MediaQuery.of(context).size.height, + ), + builder: (context) { + return Scaffold( + appBar: AppBar(), + body: RenderStreamList( + service: widget.service!, + id: (widget.meta as types.Meta).copyWith( + selectedVideoIndex: index, + ), + shouldPop: true, + ), + ); + }, + ); + + if (result != null) { + widget.onSourceChange( + result, + (widget.meta as types.Meta).copyWith( + selectedVideoIndex: index, + ), + ); + } + }, ); return MaterialDesktopVideoControlsTheme( diff --git a/lib/features/library_item/container/stremio_stream_selector.dart b/lib/features/library_item/container/stremio_stream_selector.dart index 9627485..948544f 100644 --- a/lib/features/library_item/container/stremio_stream_selector.dart +++ b/lib/features/library_item/container/stremio_stream_selector.dart @@ -246,8 +246,10 @@ class _StremioStreamSelectorState extends State { url: url, title: widget.item.name!, id: widget.item.id, - season: widget.season, - episode: widget.episode, + season: widget.item.currentVideo?.season.toString() ?? + widget.season, + episode: widget.item.currentVideo?.episode.toString() ?? + widget.episode, ); } @@ -258,8 +260,10 @@ class _StremioStreamSelectorState extends State { infoHash: item.infoHash!, fileName: "${item.behaviorHints?["filename"] as String}.mp4", - season: widget.season, - episode: widget.episode, + season: widget.item.currentVideo?.season.toString() ?? + widget.season, + episode: widget.item.currentVideo?.episode.toString() ?? + widget.episode, ); } @@ -271,8 +275,10 @@ class _StremioStreamSelectorState extends State { url: item.url!, id: widget.item.id, fileName: "${_getFileName(item)}.mp4", - season: widget.season, - episode: widget.episode, + season: widget.item.currentVideo?.season.toString() ?? + widget.season, + episode: widget.item.currentVideo?.episode.toString() ?? + widget.episode, ); } diff --git a/lib/features/trakt/service/trakt.service.dart b/lib/features/trakt/service/trakt.service.dart index 65049d5..b66729f 100644 --- a/lib/features/trakt/service/trakt.service.dart +++ b/lib/features/trakt/service/trakt.service.dart @@ -459,6 +459,9 @@ class TraktService { final List continueWatching = await _makeRequest('$_baseUrl/sync/playback'); + continueWatching.sort((v2, v1) => DateTime.parse(v1["paused_at"]) + .compareTo(DateTime.parse(v2["paused_at"]))); + final startIndex = (page - 1) * itemsPerPage; final endIndex = startIndex + itemsPerPage;