จากตอนที่แล้ว เราได้รู้จักกับการนำ video มาแสดงใน flutter
และได้เพิ่มเติมเนื้อหาเกี่ยวกับการใช้จาก file และจาก asset ใน
ตอนท้ายของเนื้อหาเพิ่มเติม เนื้อหานี้เราจะมาลองประยุกต์การแสดง
video ให้คล้ายกับ tiktok คือสามารถเลื่อนขึ้นและเลื่อนลงดูคลิปได้ง่าย
รวมถึงการประยุกต์อื่นๆ เช่น โหลดอัตโนมัติเพื่อให้การแสดงผลดูไหลลื่นขึ้น
การให้สามารถซูมคลิปวิดีโอได้ หรืออื่นๆ เพิ่มเติมที่อาจจะมีในอนาคต
*ท้ายบทความมีตัวอย่างโค้ดให้ดาวน์โหลด
ทบทวนตอนที่แล้วได้ที่
การจัดการและใช้งาน เกี่ยวกับ Video ใน Flutter ตอนที่ 1 http://niik.in/1113
เกี่ยวกับการใช้งาน video ในตอนนี้ สิ่งที่เราควรจะได้เรียนรู้คือ
- การแสดง video คล้ายรูปแบบ tiktok
- รู้จักการ preload video เพื่อให้การแสดงลื่นไหลขึ้น
- สามารถทำการ zoom video ได้
- และอื่นๆ ในอนาคตในเนื้อหาเพิ่มเติม
แนวทางการแสดง video คล้ายรูปแบบ Tiktok
ในที่นี้เราจะตั้งชื่อหัวข้อว่า loopster คล้ายกับรูปแบบการแสดงที่วิดีโอจะเล่นและวนซ้ำไปเรื่อยๆ
อย่างที่เราทราบกันว่า ในการเลื่อนคลิปใน tiktok จะมีช่วงจังหวะของคลิปที่เล่นอยู่และกำลังถูกเลื่อน
และปิดไปจากหน้าจอ กับจังหวะของคลิปที่กำลังถูกเลื่อนขึ้นมาแสดงแทน ซึ่งการจัดการในส่วนนี้เราจะ
ใช้ plugin ที่ชื่อว่า visibility_detector ดังนั้นก่อนใช้งาน เราควรติดตั้งส่วนนี้ก่อน
visibility_detector: ^0.4.0+2
เราใช้ VisibilityDetector ในการทำงานเช่น จังหวะเลื่อนปิดคลิปเดิมและเปิดคลิปใหม่ เราจะทำการ
หยุดการเล่นคลิปเดิม และกลับมาเล่นอีกครั้งถ้าเลื่อนกลับมาแสดง ซึ่งอาศัยความสามารถของ plugin
ตัวนี้ที่มีมาให้มาช่วย
และอีกส่วนที่สำคัญหลัก สำหรับการแสดงรายการหรือจะเรียกว่า หลายๆ เพจ และให้สามารถเลื่อน
รายการไปในทิศทางที่ต้องการได้ ก็คือการใช้งาน PageView.builder โดยจะทำการวนลูปนำราย
การ url ของคลิปวิดีโอไปแสดง
มาดูตัวอย่างโค้ดรูปแบบแรก
ไฟล์ loopster.dart
import 'package:flutter/material.dart'; import 'package:video_player/video_player.dart'; import 'package:visibility_detector/visibility_detector.dart'; class Loopster extends StatefulWidget { static const routeName = '/loopster'; const Loopster({Key? key}) : super(key: key); @override State<StatefulWidget> createState() { return _LoopsterState(); } } class _LoopsterState extends State<Loopster> { // ลิสรายการคลิปสำหรับทดสอบ สามารถประยุก๖ืดึงจาก api ได้ final List<String> videoUrls = [ 'https://cdn.pixabay.com/video/2024/07/27/223461_tiny.mp4', 'https://cdn.pixabay.com/video/2023/10/15/185096-874643413_tiny.mp4', 'https://cdn.pixabay.com/video/2022/11/22/140111-774507949_tiny.mp4', ]; @override Widget build(BuildContext context) { return Scaffold( backgroundColor: Colors.black, extendBodyBehindAppBar: true, appBar: AppBar( backgroundColor: Colors.transparent, foregroundColor: Colors.white, title: const Text('Loopster'), ), body: PageView.builder( scrollDirection: Axis.vertical, // เลื่อนแนวตั้ง itemCount: videoUrls.length, itemBuilder: (context, index) { // วนลูปสร้างหน้ารายการคลิปสำหรับแสดง โดยส่ง url เข้าไปใช้งาน return VideoPlayerItem(videoUrl: videoUrls[index]); }, ), ); } } // สร้าง class สำหรับแสดงรายการวิดีโอ จาก url ที่ส่งมา โดยใช้งาน video_player class VideoPlayerItem extends StatefulWidget { final String videoUrl; const VideoPlayerItem({Key? key, required this.videoUrl}) : super(key: key); @override _VideoPlayerItemState createState() => _VideoPlayerItemState(); } class _VideoPlayerItemState extends State<VideoPlayerItem> { // กำหนด controller สำหรับควบคุม video_player late VideoPlayerController _controller; // สำหรับกำหนดว่าส่วนควบคุมวิดีโอนั้น ถูกลบออกไปแล้วหรือไม่ bool _isControllerDisposed = false; @override void initState() { super.initState(); _initializeVideoPlayer(); } void _initializeVideoPlayer() { _controller = VideoPlayerController.networkUrl(Uri.parse(widget.videoUrl)) ..initialize().then((_) { if (mounted) { setState(() { _controller.play(); _controller.setLooping(true); }); } }); } @override void dispose() { _isControllerDisposed = true; _controller.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return VisibilityDetector( key: Key(widget.videoUrl), onVisibilityChanged: (VisibilityInfo info) { if (_isControllerDisposed) return; if (info.visibleFraction == 0) { _controller.pause(); // หยุดวิดีโอเมื่อถูกซ่อน } else { _controller.play(); // เล่นวิดีโอเมื่อแสดง } }, child: Stack( alignment: Alignment.center, children: [ _controller.value.isInitialized ? AspectRatio( aspectRatio: _controller.value.aspectRatio, child: VideoPlayer(_controller), ) : Center(child: CircularProgressIndicator()), Positioned( bottom: 50, left: 20, child: Text( 'Video Description Here', style: TextStyle(color: Colors.white, fontSize: 16), ), ), ], ), ); } }
ผลลัพธ์ที่ได้
รูปแบบแรกมีการทำงานอย่างง่าย คือเมื่อเปิดเข้ามาครั้งแรก หรือเปลี่ยนไปยังคลิปถัดไป ก็จะมีการโหลด
คลิปวิดีโอใหม่ทุกครั้ง ถึงแม้การแสดงผลจะไม่ไหลลื่นเมื่อสลับไปมาระหว่างคลิป แต่ก็เหมาะสำหรับกรณี
เราต้องการแสดงรายการคลิปวิดีโอธรรมดา เลื่อนดูได้ปกติ ในตัวอย่าง เราตั้งให้คลิปเล่นทันที และมี
การวนลูป
การเพิ่มการ Zoom ให้กับคลิปวิดีโอ
สมมติเราต้องการให้ video สามารถ zoom ได้ สามารถปรับส่วนของการแสดง video เล็กน้อย
โดยการใช้งาน InteractiveViewer เพิ่มเข้าไปในส่วนนี้
_controller.value.isInitialized /* ? AspectRatio( aspectRatio: _controller.value.aspectRatio, child: VideoPlayer(_controller), ) */ ? InteractiveViewer( panEnabled: true, // Enable panning boundaryMargin: EdgeInsets.all(0), minScale: 1.0, // Minimum zoom scale maxScale: 4.0, // Maximum zoom scale child: AspectRatio( aspectRatio: _controller!.value.aspectRatio, child: VideoPlayer(_controller!), ), ) : Center(child: CircularProgressIndicator()),
ส่วนที่ปิดคอมเม้น คือตัวเดิมที่ไม่มีการ zoom ดังนั้น หากต้องการให้มีการ zoom ก็ใช้รูปแบบดัง
ต่อไปในแทนหรือแทรกเข้าไปได้ โดยสามารถทำได้ทั้งการ zoom และ การ pan หรือเลื่อนตำแหน่ง
แนวทางการแสดง video แบบ Preload
ต่อไปเราจะมาประยุกต์ต่อ ให้คล้ายกับ tiktok มากขึ้น นั้นก็คือการโหลดวิดีโอถัดไปไว้ล่วงหน้า
ทำให้การแสดงวิดีโอดูลื่นไหลมากขึ้น ในกรณีนี้ เราจะต้องมีการจัดการกับ pageview เพิ่มขึ้น
ไฟล์ loopster.dart
import 'package:flutter/material.dart'; import 'package:video_player/video_player.dart'; import 'package:visibility_detector/visibility_detector.dart'; class Loopster extends StatefulWidget { static const routeName = '/loopster'; const Loopster({Key? key}) : super(key: key); @override State<StatefulWidget> createState() { return _LoopsterState(); } } class _LoopsterState extends State<Loopster> { // ลิสรายการคลิปสำหรับทดสอบ สามารถประยุก๖ืดึงจาก api ได้ final List<String> videoUrls = [ 'https://cdn.pixabay.com/video/2024/07/27/223461_tiny.mp4', 'https://cdn.pixabay.com/video/2023/10/15/185096-874643413_tiny.mp4', 'https://cdn.pixabay.com/video/2022/11/22/140111-774507949_tiny.mp4', 'https://cdn.pixabay.com/video/2024/08/30/228847_tiny.mp4', 'https://cdn.pixabay.com/video/2023/07/28/173530-849610807_tiny.mp4', 'https://cdn.pixabay.com/video/2024/03/31/206294_tiny.mp4', 'https://cdn.pixabay.com/video/2023/10/27/186714-878826932_tiny.mp4', 'https://cdn.pixabay.com/video/2024/07/28/223551_tiny.mp4', 'https://cdn.pixabay.com/video/2024/08/18/227174_tiny.mp4', ]; // จัดการตัวควบคุม pageview late PageController _pageController; // ใช้งาน controller ของ video แบบ array หรือ list Map<int, VideoPlayerController> _videoControllers = {}; @override void initState() { super.initState(); _pageController = PageController(); _initializeVideo(0); // Preload the first video _initializeVideo(1); // Preload the second video } @override void dispose() { _pageController.dispose(); // Dispose all VideoPlayerControllers for (var controller in _videoControllers.values) { controller.dispose(); } super.dispose(); } // ฟังก์ชั่นควบคุม controler ของ video play void _initializeVideo(int index) { // ถ้ามีแล้วไม่ต้องทำอะไร if (_videoControllers.containsKey(index)) return; // สร้างและ กำหนดค่าเริ่มต้น ของ video player สำหรับแต่ละ url ของ video final controller = VideoPlayerController.networkUrl(Uri.parse(videoUrls[index])); // เริ่มต้นการควบคุมการแสดงวิดีโอในห้นาที่กำลังทำงาน controller.initialize().then((_) { if (mounted) { setState(() {}); if (index == _pageController.page?.toInt()) { controller.play(); } controller.setLooping(true); } }); // แยกแต่ละ controller สำหรับแต่ละคลิป _videoControllers[index] = controller; } void _preloadVideosAround(int index) { // Preload previous and next videos if within bounds if (index > 0) { _initializeVideo(index - 1); } if (index < videoUrls.length - 1) { _initializeVideo(index + 1); } } @override Widget build(BuildContext context) { return Scaffold( backgroundColor: Colors.black, extendBodyBehindAppBar: true, appBar: AppBar( backgroundColor: Colors.transparent, foregroundColor: Colors.white, title: const Text('Loopster'), ), body: PageView.builder( controller: _pageController, //ควบคุมด้วย controller scrollDirection: Axis.vertical, // เลื่อนแนวตั้ง itemCount: videoUrls.length, onPageChanged: (index) { // เพิ่มส่วนจัดการสำหรับ preload เข้ามา // preload video เมื่อมีการเปลี่ยนหน้า _preloadVideosAround(index); }, itemBuilder: (context, index) { // วนลูปสร้างหน้ารายการคลิปสำหรับแสดง โดยส่ง url เข้าไปใช้งาน // รูปแบบนี้เพิ่มส่วนของ controller แต่ละ video player เข้าไปด้วย return VideoPlayerItem( videoController: _videoControllers[index]!, videoUrl: videoUrls[index], ); }, ), ); } } // สร้าง class สำหรับแสดงรายการวิดีโอ จาก url ที่ส่งมา โดยใช้งาน video_player // รองรับ videoController เพิ่มเข้ามา class VideoPlayerItem extends StatefulWidget { final VideoPlayerController videoController; final String videoUrl; const VideoPlayerItem({ Key? key, required this.videoController, required this.videoUrl, }) : super(key: key); @override _VideoPlayerItemState createState() => _VideoPlayerItemState(); } class _VideoPlayerItemState extends State<VideoPlayerItem> { // สำหรับกำหนดว่าส่วนควบคุมวิดีโอนั้น ถูกลบออกไปแล้วหรือไม่ bool _isControllerDisposed = false; @override void dispose() { _isControllerDisposed = true; widget.videoController.pause(); super.dispose(); } @override Widget build(BuildContext context) { return VisibilityDetector( key: Key(widget.videoUrl), onVisibilityChanged: (VisibilityInfo info) { if (_isControllerDisposed) return; if (info.visibleFraction == 0) { widget.videoController.pause(); // หยุดวิดีโอเมื่อถูกซ่อน } else { widget.videoController.play(); // เล่นวิดีโอเมื่อแสดง } }, child: Stack( alignment: Alignment.center, children: [ widget.videoController.value.isInitialized ? InteractiveViewer( panEnabled: true, // Enable panning boundaryMargin: EdgeInsets.all(0), minScale: 1.0, // Minimum zoom scale maxScale: 4.0, // Maximum zoom scale child: AspectRatio( aspectRatio: widget.videoController.value.aspectRatio, child: VideoPlayer(widget.videoController), ), ) : const Center(child: CircularProgressIndicator()), Positioned( bottom: 50, left: 20, child: const Text( 'Video Description Here', style: TextStyle(color: Colors.white, fontSize: 16), ), ), ], ), ); } }
ผลลัพธ์ที่ได้
รูปแบบที่สอง เราแยกการจัดการส่วนของ controller แต่ละตัว และมีการ preload video ทำให้
การแสดงผลดูเนียนคล้าย tiktok มากขึ้น จังหวะเลื่อนระหว่างคลิป แทบจะไม่ค่อยเห็นการโหลดคลิป
ตัวถัดไป นั่นคือคลิปแสดงรอ แต่ยังไม่เล่น พอเลื่อนเข้าไปจะเริ่มเล่นคลิปนั้น อีกทั้งคลิปก่อนหน้าที่เล่น
ไปแล้ว และย้อนกลับไป ก็ไม่ต้องทำการโหลดใหม่ สามารถแสดงและเล่นได้ทันที
ในโค้ดไม่ได้ตกแต่ง หรือเพิ่มฟังก์ชั่นอะไรมาก อย่างไรก็ดี เราสามารถนำไปประยุกต์ได้ตามต้องการ เช่น
อาจจะสามารถกดเพื่อหยุดคลิปชั่วคราวได้ เราจะขอพาประยุกต์กัน
ดังนี้คือ ถ้าคลิปกำลังเล่นอยู่ เมื่อเรากดหนึ่งครั้ง จะหยุดชั่วคราว ให้เราสามารถกดซ้ำอีกครั้งเพื่อเล่นได้
แบบนี้เป็นต้น
เดิม เล่นอย่างเดียว ไม่มีหยุด
child: AspectRatio( aspectRatio: widget.videoController.value.aspectRatio, child: VideoPlayer(widget.videoController), ),
เปลี่ยนเป็น เล่นอยู่ กดหนึ่งครั้งเพื่อหยุด และกดซ้ำเพื่อเล่นต่อ
child: AspectRatio( aspectRatio: widget.videoController.value.aspectRatio, child: GestureDetector( onTap: () { setState(() { widget.videoController.value.isPlaying ? widget.videoController.pause() : widget.videoController.play(); }); }, child: VideoPlayer(widget.videoController)), ),
เนื้อหาเกี่ยวกับการจัดการวิดีโอด้วย video_player ใน flutter ก็ขอจบเพียงเท่านี้
ตอนหน้าจะเป็นอะไร รอติดตาม