video: added loop mode setting, fixed pause-seek position update, cleanup

This commit is contained in:
Thibault Deckers 2021-04-09 11:08:16 +09:00
parent 37dde5cb38
commit 52e4f96f7b
14 changed files with 139 additions and 273 deletions

View file

@ -115,6 +115,13 @@
"coordinateFormatDecimal": "Decimal degrees",
"@coordinateFormatDecimal": {},
"videoLoopModeNever": "Never",
"@videoLoopModeNever": {},
"videoLoopModeShortOnly": "Short videos only",
"@videoLoopModeShortOnly": {},
"videoLoopModeAlways": "Always",
"@videoLoopModeAlways": {},
"mapStyleGoogleNormal": "Google Maps",
"@mapStyleGoogleNormal": {},
"mapStyleGoogleHybrid": "Google Maps (Hybrid)",
@ -544,6 +551,10 @@
"@settingsVideoShowVideos": {},
"settingsVideoEnableHardwareAcceleration": "Enable hardware acceleration",
"@settingsVideoEnableHardwareAcceleration": {},
"settingsVideoLoopModeTile": "Loop mode",
"@settingsVideoLoopModeTile": {},
"settingsVideoLoopModeTitle": "Loop Mode",
"@settingsVideoLoopModeTitle": {},
"settingsSectionPrivacy": "Privacy",
"@settingsSectionPrivacy": {},

View file

@ -59,6 +59,10 @@
"coordinateFormatDms": "도분초",
"coordinateFormatDecimal": "소수점",
"videoLoopModeNever": "반복 안 함",
"videoLoopModeShortOnly": "짧은 동영상만 반복",
"videoLoopModeAlways": "항상 반복",
"mapStyleGoogleNormal": "구글 지도",
"mapStyleGoogleHybrid": "구글 지도 (위성)",
"mapStyleGoogleTerrain": "구글 지도 (지형)",
@ -253,6 +257,8 @@
"settingsSectionVideo": "동영상",
"settingsVideoShowVideos": "미디어에 동영상 표시",
"settingsVideoEnableHardwareAcceleration": "하드웨어 가속 사용",
"settingsVideoLoopModeTile": "반복 모드",
"settingsVideoLoopModeTitle": "반복 모드",
"settingsSectionPrivacy": "개인정보 보호",
"settingsEnableAnalytics": "진단 데이터 보내기",

View file

@ -8,3 +8,5 @@ enum HomePageSetting { collection, albums }
enum EntryMapStyle { googleNormal, googleHybrid, googleTerrain, osmHot, stamenToner, stamenWatercolor }
enum KeepScreenOn { never, viewerOnly, always }
enum VideoLoopMode { never, shortOnly, always }

View file

@ -51,6 +51,7 @@ class Settings extends ChangeNotifier {
// video
static const isVideoHardwareAccelerationEnabledKey = 'video_hwaccel_mediacodec';
static const videoLoopModeKey = 'video_loop';
// info
static const infoMapStyleKey = 'info_map_style';
@ -232,6 +233,10 @@ class Settings extends ChangeNotifier {
bool get isVideoHardwareAccelerationEnabled => getBoolOrDefault(isVideoHardwareAccelerationEnabledKey, true);
VideoLoopMode get videoLoopMode => getEnumOrDefault(videoLoopModeKey, VideoLoopMode.shortOnly, VideoLoopMode.values);
set videoLoopMode(VideoLoopMode newValue) => setAndNotify(videoLoopModeKey, newValue.toString());
// info
EntryMapStyle get infoMapStyle => getEnumOrDefault(infoMapStyleKey, EntryMapStyle.stamenWatercolor, EntryMapStyle.values);

View file

@ -0,0 +1,35 @@
import 'package:aves/model/entry.dart';
import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:flutter/widgets.dart';
import 'enums.dart';
extension ExtraVideoLoopMode on VideoLoopMode {
String getName(BuildContext context) {
switch (this) {
case VideoLoopMode.never:
return context.l10n.videoLoopModeNever;
case VideoLoopMode.shortOnly:
return context.l10n.videoLoopModeShortOnly;
case VideoLoopMode.always:
return context.l10n.videoLoopModeAlways;
default:
return toString();
}
}
static const shortVideoThreshold = Duration(seconds: 30);
bool shouldLoop(AvesEntry entry) {
switch (this) {
case VideoLoopMode.never:
return false;
case VideoLoopMode.shortOnly:
if (entry.durationMillis == null) return false;
return entry.durationMillis < shortVideoThreshold.inMilliseconds;
case VideoLoopMode.always:
return true;
}
return false;
}
}

View file

@ -6,9 +6,7 @@ abstract class AvesVideoController {
void dispose();
Future<void> setDataSource(String uri);
Future<void> refreshVideoInfo();
Future<void> setDataSource(String uri, {int startMillis = 0});
Future<void> play();
@ -16,7 +14,11 @@ abstract class AvesVideoController {
Future<void> seekTo(int targetMillis);
Future<void> seekToProgress(double progress);
Future<void> seekToProgress(double progress) async {
if (duration != null) {
await seekTo((duration * progress).toInt());
}
}
Listenable get playCompletedListenable;
@ -28,40 +30,22 @@ abstract class AvesVideoController {
bool get isPlaying => status == VideoStatus.playing;
bool get isVideoReady;
Stream<bool> get isVideoReadyStream;
int get duration;
int get currentPosition;
double get progress => (currentPosition ?? 0).toDouble() / (duration ?? 1);
double get progress => duration == null ? 0 : (currentPosition ?? 0).toDouble() / duration;
Stream<int> get positionStream;
Widget buildPlayerWidget(BuildContext context, AvesEntry entry);
}
class AvesVideoInfo {
// in millis
int duration, currentPosition;
AvesVideoInfo({
this.duration,
this.currentPosition,
});
}
enum VideoStatus {
idle,
initialized,
preparing,
prepared,
playing,
paused,
playing,
completed,
stopped,
disposed,
error,
}

View file

@ -3,6 +3,7 @@ import 'dart:ui';
import 'package:aves/model/entry.dart';
import 'package:aves/model/settings/settings.dart';
import 'package:aves/model/settings/video_loop_mode.dart';
import 'package:aves/utils/change_notifier.dart';
import 'package:aves/widgets/common/video/controller.dart';
import 'package:fijkplayer/fijkplayer.dart';
@ -12,7 +13,7 @@ class IjkPlayerAvesVideoController extends AvesVideoController {
FijkPlayer _instance;
final List<StreamSubscription> _subscriptions = [];
final StreamController<FijkValue> _valueStreamController = StreamController.broadcast();
final AChangeNotifier _playFinishNotifier = AChangeNotifier();
final AChangeNotifier _completedNotifier = AChangeNotifier();
Offset _macroBlockCrop = Offset.zero;
Stream<FijkValue> get _valueStream => _valueStreamController.stream;
@ -25,41 +26,45 @@ class IjkPlayerAvesVideoController extends AvesVideoController {
// cf https://www.jianshu.com/p/843c86a9e9ad
final option = FijkOption();
// `fastseek`: enable fast, but inaccurate seeks for some formats
option.setFormatOption('fflags', 'fastseek');
// `enable-accurate-seek`: enable accurate seek, default: 0, in [0, 1]
option.setPlayerOption('enable-accurate-seek', 1);
// `framedrop`: drop frames when cpu is too slow, default: 0, in [-1, 120]
option.setPlayerOption('framedrop', 5);
final _hwAccelerationEnabled = settings.isVideoHardwareAccelerationEnabled;
if (_hwAccelerationEnabled) {
// crop HW acceleration macroblock misalignment for videos with dimensions that do not fit 16x
// when accurate seek is enabled and seeking fails, it takes time (cf `accurate-seek-timeout`) to acknowledge the error and proceed
// failure seems to happen when pause-seeking videos with an audio stream, whatever container or video stream
// player cannot be dynamically set to use accurate seek only when playing
const accurateSeekEnabled = false;
// when HW acceleration is enabled, videos with dimensions that do not fit 16x macroblocks need cropping
// TODO TLAD HW codecs sometimes fail when seek-starting some videos, e.g. MP2TS/h264(HDPR)
final hwAccelerationEnabled = settings.isVideoHardwareAccelerationEnabled;
if (hwAccelerationEnabled) {
final s = entry.displaySize % 16 * -1 % 16;
_macroBlockCrop = Offset(s.width, s.height);
}
final loopEnabled = settings.videoLoopMode.shouldLoop(entry);
// `fastseek`: enable fast, but inaccurate seeks for some formats
// in practice the flag seems ineffective, but harmless too
option.setFormatOption('fflags', 'fastseek');
// `enable-accurate-seek`: enable accurate seek, default: 0, in [0, 1]
option.setPlayerOption('enable-accurate-seek', accurateSeekEnabled ? 1 : 0);
// `accurate-seek-timeout`: accurate seek timeout, default: 5000 ms, in [0, 5000]
option.setPlayerOption('accurate-seek-timeout', 1000);
// `framedrop`: drop frames when cpu is too slow, default: 0, in [-1, 120]
option.setPlayerOption('framedrop', 5);
// `loop`: set number of times the playback shall be looped, default: 1, in [INT_MIN, INT_MAX]
option.setPlayerOption('loop', loopEnabled ? -1 : 1);
// `mediacodec-all-videos`: MediaCodec: enable all videos, default: 0, in [0, 1]
// TODO TLAD enabling `mediacodec-all-videos` randomly fails to render some videos, e.g. MP2TS/h264(HDPR)
option.setPlayerOption('mediacodec-all-videos', _hwAccelerationEnabled ? 1 : 0);
// option.setPlayerOption('analyzemaxduration', 200 * 1024);
// option.setPlayerOption('analyzeduration', 200 * 1024);
// option.setPlayerOption('probesize', 1024 * 1024);
// CJL options
// option.setPlayerOption('reconnect', 5);
// option.setPlayerOption('mediacodec', 1);
// option.setPlayerOption('packet-buffering', 1);
// option.setPlayerOption('soundtouch', 1);
// option.setPlayerOption('start-on-prepared', 1);
// TODO TLAD check looping
// option.setPlayerOption('loop', 42);
option.setPlayerOption('mediacodec-all-videos', hwAccelerationEnabled ? 1 : 0);
_instance.applyOptions(option);
_instance.addListener(_onValueChanged);
_subscriptions.add(_valueStream.where((value) => value.completed).listen((_) => _playFinishNotifier.notifyListeners()));
_subscriptions.add(_valueStream.where((value) => value.state == FijkState.completed).listen((_) => _completedNotifier.notifyListeners()));
}
@override
@ -77,10 +82,13 @@ class IjkPlayerAvesVideoController extends AvesVideoController {
// enable autoplay, even when seeking on uninitialized player, otherwise the texture is not updated
// as a workaround, pausing after a brief duration is possible, but fiddly
@override
Future<void> setDataSource(String uri) => _instance.setDataSource(uri, autoPlay: true);
@override
Future<void> refreshVideoInfo() => null;
Future<void> setDataSource(String uri, {int startMillis = 0}) async {
if (startMillis > 0) {
// `seek-at-start`: set offset of player should be seeked, default: 0, in [0, INT_MAX]
await _instance.setOption(FijkOption.playerCategory, 'seek-at-start', startMillis);
}
await _instance.setDataSource(uri, autoPlay: true);
}
@override
Future<void> play() => _instance.start();
@ -92,10 +100,7 @@ class IjkPlayerAvesVideoController extends AvesVideoController {
Future<void> seekTo(int targetMillis) => _instance.seekTo(targetMillis);
@override
Future<void> seekToProgress(double progress) => _instance.seekTo((duration * progress).toInt());
@override
Listenable get playCompletedListenable => _playFinishNotifier;
Listenable get playCompletedListenable => _completedNotifier;
@override
VideoStatus get status => _instance.state.toAves;
@ -103,12 +108,6 @@ class IjkPlayerAvesVideoController extends AvesVideoController {
@override
Stream<VideoStatus> get statusStream => _valueStream.map((value) => value.state.toAves);
@override
bool get isVideoReady => _instance.value.videoRenderStart;
@override
Stream<bool> get isVideoReadyStream => _valueStream.map((value) => value.videoRenderStart);
@override
bool get isPlayable => _instance.isPlayable();
@ -141,23 +140,19 @@ extension ExtraIjkStatus on FijkState {
VideoStatus get toAves {
switch (this) {
case FijkState.idle:
case FijkState.end:
case FijkState.stopped:
return VideoStatus.idle;
case FijkState.initialized:
return VideoStatus.initialized;
case FijkState.asyncPreparing:
return VideoStatus.preparing;
return VideoStatus.initialized;
case FijkState.prepared:
return VideoStatus.prepared;
case FijkState.started:
return VideoStatus.playing;
case FijkState.paused:
return VideoStatus.paused;
case FijkState.started:
return VideoStatus.playing;
case FijkState.completed:
return VideoStatus.completed;
case FijkState.stopped:
return VideoStatus.stopped;
case FijkState.end:
return VideoStatus.disposed;
case FijkState.error:
return VideoStatus.error;
}

View file

@ -1,144 +0,0 @@
// import 'dart:async';
//
// import 'package:aves/model/entry.dart';
// import 'package:aves/utils/change_notifier.dart';
// import 'package:aves/widgets/common/video/controller.dart';
// import 'package:flutter/material.dart';
// import 'package:flutter_ijkplayer/flutter_ijkplayer.dart';
//
// class IjkPlayerAvesVideoController extends AvesVideoController {
// IjkMediaController _instance;
// final List<StreamSubscription> _subscriptions = [];
// final AChangeNotifier _playFinishNotifier = AChangeNotifier();
//
// IjkPlayerAvesVideoController() {
// _instance = IjkMediaController();
// _subscriptions.add(_instance.playFinishStream.listen((_) => _playFinishNotifier.notifyListeners()));
// }
//
// @override
// void dispose() {
// _subscriptions
// ..forEach((sub) => sub.cancel())
// ..clear();
// _instance?.dispose();
// }
//
// // enable autoplay, even when seeking on uninitialized player, otherwise the texture is not updated
// // as a workaround, pausing after a brief duration is possible, but fiddly
// @override
// Future<void> setDataSource(String uri) => _instance.setDataSource(DataSource.photoManagerUrl(uri), autoPlay: true);
//
// @override
// Future<void> refreshVideoInfo() => _instance.refreshVideoInfo();
//
// @override
// Future<void> play() => _instance.play();
//
// @override
// Future<void> pause() => _instance.pause();
//
// @override
// Future<void> seekTo(int targetMillis) => _instance.seekTo(targetMillis / 1000.0);
//
// @override
// Future<void> seekToProgress(double progress) => _instance.seekToProgress(progress);
//
// @override
// Listenable get playCompletedListenable => _playFinishNotifier;
//
// @override
// VideoStatus get status => _instance.ijkStatus.toAves;
//
// @override
// Stream<VideoStatus> get statusStream => _instance.ijkStatusStream.map((status) => status.toAves);
//
// // we check whether video info is ready instead of checking for `noDatasource` status,
// // as the controller could also be uninitialized with the `pause` status
// // (e.g. when switching between video entries without playing them the first time)
// @override
// bool get isPlayable => _videoInfo.hasData && [VideoStatus.prepared, VideoStatus.playing, VideoStatus.paused, VideoStatus.completed].contains(status);
//
// @override
// bool get isVideoReady => _instance.textureId != null;
//
// @override
// Stream<bool> get isVideoReadyStream => _instance.textureIdStream.map((id) => id != null);
//
// // `videoInfo` is never null (even if `toString` prints `null`)
// // check presence with `hasData` instead
// VideoInfo get _videoInfo => _instance.videoInfo;
//
// @override
// int get duration => _videoInfo.durationMillis;
//
// @override
// int get currentPosition => _videoInfo.currentPositionMillis;
//
// @override
// Stream<int> get positionStream => _instance.videoInfoStream.map((info) => info.currentPositionMillis);
//
// @override
// Widget buildPlayerWidget(BuildContext context, AvesEntry entry) => IjkPlayer(
// mediaController: _instance,
// controllerWidgetBuilder: (controller) => SizedBox.shrink(),
// statusWidgetBuilder: (context, controller, status) => SizedBox.shrink(),
// textureBuilder: (context, controller, info) {
// var id = controller.textureId;
// var child = id != null
// ? Texture(
// textureId: id,
// )
// : Container(
// color: Colors.black,
// );
//
// final degree = entry.rotationDegrees ?? 0;
// if (degree != 0) {
// child = RotatedBox(
// quarterTurns: degree ~/ 90,
// child: child,
// );
// }
//
// return Center(
// child: AspectRatio(
// aspectRatio: entry.displayAspectRatio,
// child: child,
// ),
// );
// },
// backgroundColor: Colors.transparent,
// );
// }
//
// extension ExtraVideoInfo on VideoInfo {
// int get durationMillis => duration == null ? null : (duration * 1000).toInt();
//
// int get currentPositionMillis => currentPosition == null ? null : (currentPosition * 1000).toInt();
// }
//
// extension ExtraIjkStatus on IjkStatus {
// VideoStatus get toAves {
// switch (this) {
// case IjkStatus.noDatasource:
// return VideoStatus.idle;
// case IjkStatus.preparing:
// return VideoStatus.preparing;
// case IjkStatus.prepared:
// return VideoStatus.prepared;
// case IjkStatus.playing:
// return VideoStatus.playing;
// case IjkStatus.pause:
// return VideoStatus.paused;
// case IjkStatus.complete:
// return VideoStatus.completed;
// case IjkStatus.disposed:
// return VideoStatus.disposed;
// case IjkStatus.setDatasourceFail:
// case IjkStatus.error:
// return VideoStatus.error;
// }
// return VideoStatus.idle;
// }
// }

View file

@ -21,7 +21,7 @@
// VlcAvesVideoController();
//
// @override
// Future<void> setDataSource(String uri) async {
// Future<void> setDataSource(String uri, {int startMillis = 0}) async {
// _instance = VlcPlayerController.file(
// File(uri),
// );
@ -49,9 +49,6 @@
// void _onValueChanged() => _valueStreamController.add(_instance.value);
//
// @override
// Future<void> refreshVideoInfo() => null;
//
// @override
// Future<void> play() => _instance.play();
//
// @override
@ -61,9 +58,6 @@
// Future<void> seekTo(int targetMillis) => _instance.seekTo(Duration(milliseconds: targetMillis));
//
// @override
// Future<void> seekToProgress(double progress) => _instance.seekTo(Duration(milliseconds: (duration * progress).toInt()));
//
// @override
// Listenable get playCompletedListenable => _playFinishNotifier;
//
// @override
@ -73,12 +67,6 @@
// Stream<VideoStatus> get statusStream => _valueStream.map((value) => value.toAves);
//
// @override
// bool get isVideoReady => _instance != null && _instance.value.isInitialized && !_instance.value.hasError;
//
// @override
// Stream<bool> get isVideoReadyStream => _valueStream.map((value) => value.isInitialized && !value.hasError);
//
// @override
// bool get isPlayable => _instance != null;
//
// @override

View file

@ -18,7 +18,7 @@
// VideoPlayerAvesVideoController();
//
// @override
// Future<void> setDataSource(String uri) async {
// Future<void> setDataSource(String uri, {int startMillis = 0}) async {
// _instance = VideoPlayerController.network(uri);
// _instance.addListener(_onValueChanged);
// _subscriptions.add(_valueStream.where((value) => value.position > value.duration).listen((_) => _playFinishNotifier.notifyListeners()));
@ -40,9 +40,6 @@
// void _onValueChanged() => _valueStreamController.add(_instance.value);
//
// @override
// Future<void> refreshVideoInfo() => null;
//
// @override
// Future<void> play() => _instance.play();
//
// @override
@ -52,9 +49,6 @@
// Future<void> seekTo(int targetMillis) => _instance.seekTo(Duration(milliseconds: targetMillis));
//
// @override
// Future<void> seekToProgress(double progress) => _instance.seekTo(Duration(milliseconds: (duration * progress).toInt()));
//
// @override
// Listenable get playCompletedListenable => _playFinishNotifier;
//
// @override
@ -64,12 +58,6 @@
// Stream<VideoStatus> get statusStream => _valueStream.map((value) => value.toAves);
//
// @override
// bool get isVideoReady => _instance != null && _instance.value.isInitialized && !_instance.value.hasError;
//
// @override
// Stream<bool> get isVideoReadyStream => _valueStream.map((value) => value.isInitialized && !value.hasError);
//
// @override
// bool get isPlayable => _instance != null && _instance.value.isInitialized && !_instance.value.hasError;
//
// @override

View file

@ -4,6 +4,7 @@ import 'package:aves/model/settings/enums.dart';
import 'package:aves/model/settings/home_page.dart';
import 'package:aves/model/settings/screen_on.dart';
import 'package:aves/model/settings/settings.dart';
import 'package:aves/model/settings/video_loop_mode.dart';
import 'package:aves/model/source/collection_source.dart';
import 'package:aves/theme/durations.dart';
import 'package:aves/theme/icons.dart';
@ -244,6 +245,23 @@ class _SettingsPageState extends State<SettingsPage> {
onChanged: (v) => settings.isVideoHardwareAccelerationEnabled = v,
title: Text(context.l10n.settingsVideoEnableHardwareAcceleration),
),
ListTile(
title: Text(context.l10n.settingsVideoLoopModeTile),
subtitle: Text(settings.videoLoopMode.getName(context)),
onTap: () async {
final value = await showDialog<VideoLoopMode>(
context: context,
builder: (context) => AvesSelectionDialog<VideoLoopMode>(
initialValue: settings.videoLoopMode,
options: Map.fromEntries(VideoLoopMode.values.map((v) => MapEntry(v, v.getName(context)))),
title: context.l10n.settingsVideoLoopModeTitle,
),
);
if (value != null) {
settings.videoLoopMode = value;
}
},
),
],
);
}

View file

@ -35,9 +35,6 @@ class _VideoControlOverlayState extends State<VideoControlOverlay> with SingleTi
final List<StreamSubscription> _subscriptions = [];
double _seekTargetPercent;
// video info is not refreshed by default, so we use a timer to do so
Timer _progressTimer;
AvesEntry get entry => widget.entry;
Animation<double> get scale => widget.scale;
@ -74,16 +71,13 @@ class _VideoControlOverlayState extends State<VideoControlOverlay> with SingleTi
void _registerWidget(VideoControlOverlay widget) {
_subscriptions.add(widget.controller.statusStream.listen(_onStatusChange));
_subscriptions.add(widget.controller.isVideoReadyStream.listen(_onVideoReadinessChanged));
_onStatusChange(widget.controller.status);
_onVideoReadinessChanged(widget.controller.isVideoReady);
}
void _unregisterWidget(VideoControlOverlay widget) {
_subscriptions
..forEach((sub) => sub.cancel())
..clear();
_stopTimer();
}
@override
@ -194,24 +188,6 @@ class _VideoControlOverlayState extends State<VideoControlOverlay> with SingleTi
);
}
void _startTimer() {
if (!controller.isVideoReady) return;
_progressTimer?.cancel();
_progressTimer = Timer.periodic(Durations.videoProgressTimerInterval, (_) => controller.refreshVideoInfo());
}
void _stopTimer() {
_progressTimer?.cancel();
}
void _onVideoReadinessChanged(bool isVideoReady) {
if (isVideoReady) {
_startTimer();
} else {
_stopTimer();
}
}
void _onStatusChange(VideoStatus status) {
if (status == VideoStatus.playing && _seekTargetPercent != null) {
_seekFromTarget();
@ -247,16 +223,17 @@ class _VideoControlOverlayState extends State<VideoControlOverlay> with SingleTi
if (isPlayable) {
await _seekFromTarget();
} else {
await controller.setDataSource(entry.uri);
// controller duration is not set yet, so we use the expected duration instead
final seekTargetMillis = (entry.durationMillis * _seekTargetPercent).toInt();
await controller.setDataSource(entry.uri, startMillis: seekTargetMillis);
_seekTargetPercent = null;
}
}
Future _seekFromTarget() async {
// `seekToProgress` is not safe as it can be called when the `duration` is not set yet
// so we make sure the video info is up to date first
if (controller.duration == null) {
await controller.refreshVideoInfo();
} else {
if (controller.duration != null) {
await controller.seekToProgress(_seekTargetPercent);
_seekTargetPercent = null;
}

View file

@ -68,5 +68,6 @@ class _VideoViewState extends State<VideoView> {
});
}
// not called when looping
void _onPlayCompleted() => controller.seekTo(0);
}

View file

@ -211,7 +211,7 @@ packages:
description:
path: "."
ref: aves
resolved-ref: "7171c3ede20f407b523c18692572cbcd12acc169"
resolved-ref: a4640923c7c6141ff543f676a8cb7d2fe8b0ffba
url: "git://github.com/deckerst/fijkplayer.git"
source: git
version: "0.8.7"