feat(extensions): Youtube Video Player Support Mode (#1916)

* chore: update comment for packages section in pubspec.yaml

* chore: add youtube_explode_dart package

* feat: add YoutubeVideoSupportMode

* docs: add a link to the 'flutter_inappwebview' plugin desktop support issue in YoutubeVideoSupportMode

* chore: add TODO in QuillToolbarVideoButton
pull/1962/head v9.5.1
Ellet 10 months ago committed by GitHub
parent a87b8cbdfa
commit c8a99c943f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 11
      example/lib/screens/quill/my_quill_editor.dart
  2. 2
      example/pubspec.yaml
  3. 2
      flutter_quill_extensions/lib/embeds/video/editor/video_embed.dart
  4. 28
      flutter_quill_extensions/lib/embeds/video/toolbar/video_button.dart
  5. 44
      flutter_quill_extensions/lib/embeds/widgets/video_app.dart
  6. 141
      flutter_quill_extensions/lib/embeds/widgets/youtube_video_app.dart
  7. 7
      flutter_quill_extensions/lib/models/config/video/editor/video_configurations.dart
  8. 19
      flutter_quill_extensions/lib/models/config/video/editor/youtube_video_support_mode.dart
  9. 5
      flutter_quill_extensions/pubspec.yaml
  10. 2
      pubspec.yaml

@ -4,11 +4,13 @@ import 'package:cached_network_image/cached_network_image.dart'
show CachedNetworkImageProvider; show CachedNetworkImageProvider;
import 'package:desktop_drop/desktop_drop.dart' show DropTarget; import 'package:desktop_drop/desktop_drop.dart' show DropTarget;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_quill/extensions.dart' show isAndroid, isIOS, isWeb; import 'package:flutter_quill/extensions.dart'
show isAndroid, isDesktop, isIOS, isWeb;
import 'package:flutter_quill/flutter_quill.dart'; import 'package:flutter_quill/flutter_quill.dart';
import 'package:flutter_quill_extensions/embeds/widgets/image.dart' import 'package:flutter_quill_extensions/embeds/widgets/image.dart'
show getImageProviderByImageSource, imageFileExtensions; show getImageProviderByImageSource, imageFileExtensions;
import 'package:flutter_quill_extensions/flutter_quill_extensions.dart'; import 'package:flutter_quill_extensions/flutter_quill_extensions.dart';
import 'package:flutter_quill_extensions/models/config/video/editor/youtube_video_support_mode.dart';
import 'package:path/path.dart' as path; import 'package:path/path.dart' as path;
import '../../extensions/scaffold_messenger.dart'; import '../../extensions/scaffold_messenger.dart';
@ -128,6 +130,13 @@ class MyQuillEditor extends StatelessWidget {
); );
}, },
), ),
videoEmbedConfigurations: QuillEditorVideoEmbedConfigurations(
// Loading YouTube videos on Desktop is not supported yet
// when using iframe platform view
youtubeVideoSupportMode: isDesktop(supportWeb: false)
? YoutubeVideoSupportMode.customPlayerWithDownloadUrl
: YoutubeVideoSupportMode.iframeView,
),
)), )),
TimeStampEmbedBuilderWidget(), TimeStampEmbedBuilderWidget(),
], ],

@ -19,7 +19,7 @@ dependencies:
flutter_quill_test: ^9.3.4 flutter_quill_test: ^9.3.4
quill_html_converter: ^9.3.4 quill_html_converter: ^9.3.4
quill_pdf_converter: ^9.3.4 quill_pdf_converter: ^9.3.4
# Normal packages # Dart Packages
path: ^1.8.3 path: ^1.8.3
equatable: ^2.0.5 equatable: ^2.0.5
cross_file: ^0.3.4 cross_file: ^0.3.4

@ -37,6 +37,7 @@ class QuillEditorVideoEmbedBuilder extends EmbedBuilder {
return YoutubeVideoApp( return YoutubeVideoApp(
videoUrl: videoUrl, videoUrl: videoUrl,
readOnly: readOnly, readOnly: readOnly,
youtubeVideoSupportMode: configurations.youtubeVideoSupportMode,
); );
} }
final ((elementSize), margin, alignment) = getElementAttributes( final ((elementSize), margin, alignment) = getElementAttributes(
@ -53,7 +54,6 @@ class QuillEditorVideoEmbedBuilder extends EmbedBuilder {
alignment: alignment, alignment: alignment,
child: VideoApp( child: VideoApp(
videoUrl: videoUrl, videoUrl: videoUrl,
context: context,
readOnly: readOnly, readOnly: readOnly,
onVideoInit: configurations.onVideoInit, onVideoInit: configurations.onVideoInit,
), ),

@ -9,6 +9,8 @@ import '../../others/image_video_utils.dart';
import '../video.dart'; import '../video.dart';
import 'select_video_source.dart'; import 'select_video_source.dart';
// TODO: Add custom callback to validate the video link input
class QuillToolbarVideoButton extends StatelessWidget { class QuillToolbarVideoButton extends StatelessWidget {
const QuillToolbarVideoButton({ const QuillToolbarVideoButton({
required this.controller, required this.controller,
@ -72,11 +74,6 @@ class QuillToolbarVideoButton extends StatelessWidget {
final childBuilder = final childBuilder =
options.childBuilder ?? baseButtonExtraOptions(context)?.childBuilder; options.childBuilder ?? baseButtonExtraOptions(context)?.childBuilder;
// final iconColor =
// iconTheme?.iconUnselectedFillColor ?? theme.iconTheme.color;
// final iconFillColor = iconTheme?.iconUnselectedFillColor ??
// (options.fillColor ?? theme.canvasColor);
if (childBuilder != null) { if (childBuilder != null) {
return childBuilder( return childBuilder(
QuillToolbarVideoButtonOptions( QuillToolbarVideoButtonOptions(
@ -150,18 +147,6 @@ class QuillToolbarVideoButton extends StatelessWidget {
.onVideoInsertCallback(videoUrl, controller); .onVideoInsertCallback(videoUrl, controller);
await options.videoConfigurations.onVideoInsertedCallback?.call(videoUrl); await options.videoConfigurations.onVideoInsertedCallback?.call(videoUrl);
} }
// if (options.onVideoPickCallback != null) {
// final selector = options.mediaPickSettingSelector ??
// ImageVideoUtils.selectMediaPickSetting;
// final source = await selector(context);
// if (source != null) {
// if (source == MediaPickSetting.gallery) {
// } else {
// await _typeLink(context);
// }
// }
// } else {}
} }
Future<String?> _typeLink(BuildContext context) async { Future<String?> _typeLink(BuildContext context) async {
@ -176,13 +161,4 @@ class QuillToolbarVideoButton extends StatelessWidget {
); );
return value; return value;
} }
// void _linkSubmitted(String? value) {
// if (value != null && value.isNotEmpty) {
// final index = controller.selection.baseOffset;
// final length = controller.selection.extentOffset - index;
// controller.replaceText(index, length, BlockEmbed.video(value), null);
// }
// }
} }

@ -13,14 +13,16 @@ import '../../flutter_quill_extensions.dart';
class VideoApp extends StatefulWidget { class VideoApp extends StatefulWidget {
const VideoApp({ const VideoApp({
required this.videoUrl, required this.videoUrl,
required this.context,
required this.readOnly, required this.readOnly,
@Deprecated(
'The context is no longer required and will be removed on future releases',
)
BuildContext? context,
super.key, super.key,
this.onVideoInit, this.onVideoInit,
}); });
final String videoUrl; final String videoUrl;
final BuildContext context;
final bool readOnly; final bool readOnly;
final void Function(GlobalKey videoContainerKey)? onVideoInit; final void Function(GlobalKey videoContainerKey)? onVideoInit;
@ -92,29 +94,33 @@ class VideoAppState extends State<VideoApp> {
: _controller.play(); : _controller.play();
}); });
}, },
child: Stack(alignment: Alignment.center, children: [ child: Stack(
Center( alignment: Alignment.center,
child: AspectRatio( children: [
aspectRatio: _controller.value.aspectRatio, Center(
child: VideoPlayer(_controller), child: AspectRatio(
)), aspectRatio: _controller.value.aspectRatio,
_controller.value.isPlaying child: VideoPlayer(_controller),
? const SizedBox.shrink() )),
: Container( _controller.value.isPlaying
color: const Color(0xfff5f5f5), ? const SizedBox.shrink()
child: const Icon( : Container(
Icons.play_arrow, color: const Color(0xfff5f5f5),
size: 60, child: const Icon(
color: Colors.blueGrey, Icons.play_arrow,
)) size: 60,
]), color: Colors.blueGrey,
),
)
],
),
), ),
); );
} }
@override @override
void dispose() { void dispose() {
super.dispose();
_controller.dispose(); _controller.dispose();
super.dispose();
} }
} }

@ -1,78 +1,143 @@
import 'package:flutter/gestures.dart' show TapGestureRecognizer; import 'package:flutter/gestures.dart' show TapGestureRecognizer;
import 'package:flutter/widgets.dart'; import 'package:flutter/material.dart';
import 'package:flutter_quill/flutter_quill.dart' show DefaultStyles; import 'package:flutter_quill/flutter_quill.dart' show DefaultStyles;
import 'package:url_launcher/url_launcher.dart' show launchUrl; import 'package:url_launcher/url_launcher_string.dart';
import 'package:youtube_explode_dart/youtube_explode_dart.dart';
import 'package:youtube_player_flutter/youtube_player_flutter.dart'; import 'package:youtube_player_flutter/youtube_player_flutter.dart';
import '../../models/config/video/editor/youtube_video_support_mode.dart';
import 'video_app.dart';
class YoutubeVideoApp extends StatefulWidget { class YoutubeVideoApp extends StatefulWidget {
const YoutubeVideoApp({ const YoutubeVideoApp({
required this.videoUrl, required this.videoUrl,
required this.readOnly, required this.readOnly,
required this.youtubeVideoSupportMode,
super.key, super.key,
}); });
final String videoUrl; final String videoUrl;
final bool readOnly; final bool readOnly;
final YoutubeVideoSupportMode youtubeVideoSupportMode;
@override @override
YoutubeVideoAppState createState() => YoutubeVideoAppState(); YoutubeVideoAppState createState() => YoutubeVideoAppState();
} }
class YoutubeVideoAppState extends State<YoutubeVideoApp> { class YoutubeVideoAppState extends State<YoutubeVideoApp> {
YoutubePlayerController? _youtubeController; YoutubePlayerController? _youtubeIframeController;
/// On some platforms such as desktop, Webview is not supported yet
/// as a result the youtube video player package is not supported too
/// this future will be not null and fetch the video url to load it using
/// [VideoApp]
Future<String>? _loadYoutubeVideoByDownloadUrlFuture;
/// Null if the video URL is not a YouTube video
String? get _videoId {
return YoutubePlayer.convertUrlToId(widget.videoUrl);
}
@override @override
void initState() { void initState() {
super.initState(); super.initState();
final videoId = YoutubePlayer.convertUrlToId(widget.videoUrl); final videoId = _videoId;
if (videoId != null) { if (videoId == null) {
_youtubeController = YoutubePlayerController( return;
initialVideoId: videoId,
flags: const YoutubePlayerFlags(
autoPlay: false,
),
);
} }
switch (widget.youtubeVideoSupportMode) {
case YoutubeVideoSupportMode.disabled:
break;
case YoutubeVideoSupportMode.iframeView:
_youtubeIframeController = YoutubePlayerController(
initialVideoId: videoId,
flags: const YoutubePlayerFlags(
autoPlay: false,
),
);
break;
case YoutubeVideoSupportMode.customPlayerWithDownloadUrl:
_loadYoutubeVideoByDownloadUrlFuture =
_loadYoutubeVideoWithVideoPlayerByVideoUrl();
break;
}
}
Future<String> _loadYoutubeVideoWithVideoPlayerByVideoUrl() async {
final youtubeExplode = YoutubeExplode();
final manifest =
await youtubeExplode.videos.streamsClient.getManifest(_videoId);
final streamInfo = manifest.muxed.withHighestBitrate();
final videoDownloadUri = streamInfo.url;
return videoDownloadUri.toString();
}
Widget _clickableVideoLinkText({required DefaultStyles defaultStyles}) {
return RichText(
text: TextSpan(
text: widget.videoUrl,
style: defaultStyles.link,
recognizer: TapGestureRecognizer()
..onTap = () => launchUrlString(widget.videoUrl),
),
);
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final defaultStyles = DefaultStyles.getInstance(context); final defaultStyles = DefaultStyles.getInstance(context);
final youtubeController = _youtubeController;
switch (widget.youtubeVideoSupportMode) {
if (youtubeController == null) { case YoutubeVideoSupportMode.disabled:
if (widget.readOnly) { throw UnsupportedError('YouTube video links are not supported');
return RichText( case YoutubeVideoSupportMode.iframeView:
text: TextSpan( final youtubeController = _youtubeIframeController;
text: widget.videoUrl,
style: defaultStyles.link, if (youtubeController == null) {
recognizer: TapGestureRecognizer() if (widget.readOnly) {
..onTap = () => launchUrl( return _clickableVideoLinkText(defaultStyles: defaultStyles);
Uri.parse(widget.videoUrl), }
),
return RichText(
text: TextSpan(text: widget.videoUrl, style: defaultStyles.link),
);
}
return YoutubePlayerBuilder(
player: YoutubePlayer(
controller: youtubeController,
showVideoProgressIndicator: true,
), ),
builder: (context, player) {
return player;
},
);
case YoutubeVideoSupportMode.customPlayerWithDownloadUrl:
assert(
_loadYoutubeVideoByDownloadUrlFuture != null,
'The load youtube video future should not null for "${widget.youtubeVideoSupportMode}" mode',
); );
}
return RichText( return FutureBuilder<String>(
text: TextSpan(text: widget.videoUrl, style: defaultStyles.link), future: _loadYoutubeVideoByDownloadUrlFuture,
); builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator.adaptive());
}
if (snapshot.hasError) {
return _clickableVideoLinkText(defaultStyles: defaultStyles);
}
return VideoApp(
videoUrl: snapshot.requireData,
readOnly: widget.readOnly,
);
},
);
} }
return YoutubePlayerBuilder(
player: YoutubePlayer(
controller: youtubeController,
showVideoProgressIndicator: true,
),
builder: (context, player) {
return player;
},
);
} }
@override @override
void dispose() { void dispose() {
_youtubeController?.dispose(); _youtubeIframeController?.dispose();
super.dispose(); super.dispose();
} }
} }

@ -1,10 +1,13 @@
import 'package:flutter/widgets.dart' show GlobalKey; import 'package:flutter/widgets.dart' show GlobalKey;
import 'package:meta/meta.dart' show immutable; import 'package:meta/meta.dart' show immutable;
import 'youtube_video_support_mode.dart';
@immutable @immutable
class QuillEditorVideoEmbedConfigurations { class QuillEditorVideoEmbedConfigurations {
const QuillEditorVideoEmbedConfigurations({ const QuillEditorVideoEmbedConfigurations({
this.onVideoInit, this.onVideoInit,
this.youtubeVideoSupportMode = YoutubeVideoSupportMode.iframeView,
}); });
/// [onVideoInit] is a callback function that gets triggered when /// [onVideoInit] is a callback function that gets triggered when
@ -21,4 +24,8 @@ class QuillEditorVideoEmbedConfigurations {
/// // Customize other callback functions as needed /// // Customize other callback functions as needed
/// ``` /// ```
final void Function(GlobalKey videoContainerKey)? onVideoInit; final void Function(GlobalKey videoContainerKey)? onVideoInit;
/// Specifies how YouTube videos should be loaded if the video URL
/// is YouTube video.
final YoutubeVideoSupportMode youtubeVideoSupportMode;
} }

@ -0,0 +1,19 @@
/// Enum represents the different modes for handling YouTube video support.
enum YoutubeVideoSupportMode {
/// Disable loading of YouTube videos.
disabled,
/// Load the video using the official YouTube IFrame API.
/// See [YouTube IFrame API](https://developers.google.com/youtube/iframe_api_reference) for more details.
///
/// This will use Platform View on native platforms to use WebView
/// The WebView might not be supported on Desktop and will throw an exception
///
/// See [Flutter InAppWebview Support for Flutter Desktop](https://github.com/pichillilorenzo/flutter_inappwebview/issues/460)
iframeView,
/// Load the video using a custom video player by fetching the YouTube video URL.
/// Note: This might violate YouTube's terms of service.
/// See [YouTube Terms of Service](https://www.youtube.com/static?template=terms) for more details.
customPlayerWithDownloadUrl,
}

@ -28,7 +28,7 @@ dependencies:
flutter: flutter:
sdk: flutter sdk: flutter
# Normal packages # Dart Packages
http: ^1.2.0 http: ^1.2.0
path: ^1.8.3 path: ^1.8.3
meta: ^1.10.0 meta: ^1.10.0
@ -37,10 +37,11 @@ dependencies:
flutter_quill: ^9.3.12 flutter_quill: ^9.3.12
photo_view: ^0.15.0 photo_view: ^0.15.0
youtube_explode_dart: ^2.2.1
# Plugins # Plugins
video_player: ^2.8.1 video_player: ^2.8.1
youtube_player_flutter: ^9.0.0 youtube_player_flutter: ^9.0.1
url_launcher: ^6.2.1 url_launcher: ^6.2.1
super_clipboard: ^0.8.15 super_clipboard: ^0.8.15
gal: ^2.3.0 gal: ^2.3.0

@ -42,7 +42,7 @@ dependencies:
flutter_localizations: flutter_localizations:
sdk: flutter sdk: flutter
# Normal packages # Dart Packages
intl: ^0.19.0 intl: ^0.19.0
dart_quill_delta: ^9.3.3 dart_quill_delta: ^9.3.3
collection: ^1.17.0 collection: ^1.17.0

Loading…
Cancel
Save