#110 video: resume playback
This commit is contained in:
parent
c6c87bdc96
commit
87b007c60f
18 changed files with 321 additions and 40 deletions
|
@ -341,6 +341,17 @@
|
|||
}
|
||||
},
|
||||
|
||||
"videoResumeDialogMessage": "Do you want to resume playing at {time}?",
|
||||
"@videoResumeDialogMessage": {
|
||||
"placeholders": {
|
||||
"time": {}
|
||||
}
|
||||
},
|
||||
"videoStartOverButtonLabel": "START OVER",
|
||||
"@videoStartOverButtonLabel": {},
|
||||
"videoResumeButtonLabel": "RESUME",
|
||||
"@videoResumeButtonLabel": {},
|
||||
|
||||
"setCoverDialogTitle": "Set Cover",
|
||||
"@setCoverDialogTitle": {},
|
||||
"setCoverDialogLatest": "Latest item",
|
||||
|
|
|
@ -15,7 +15,7 @@ class Covers with ChangeNotifier {
|
|||
Covers._private();
|
||||
|
||||
Future<void> init() async {
|
||||
_rows = await metadataDb.loadCovers();
|
||||
_rows = await metadataDb.loadAllCovers();
|
||||
}
|
||||
|
||||
int get count => _rows.length;
|
||||
|
|
|
@ -12,7 +12,7 @@ class Favourites with ChangeNotifier {
|
|||
Favourites._private();
|
||||
|
||||
Future<void> init() async {
|
||||
_rows = await metadataDb.loadFavourites();
|
||||
_rows = await metadataDb.loadAllFavourites();
|
||||
}
|
||||
|
||||
int get count => _rows.length;
|
||||
|
|
|
@ -7,6 +7,7 @@ import 'package:aves/model/filters/filters.dart';
|
|||
import 'package:aves/model/metadata/address.dart';
|
||||
import 'package:aves/model/metadata/catalog.dart';
|
||||
import 'package:aves/model/metadata_db_upgrade.dart';
|
||||
import 'package:aves/model/video_playback.dart';
|
||||
import 'package:aves/services/common/services.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
@ -25,7 +26,7 @@ abstract class MetadataDb {
|
|||
|
||||
Future<void> clearEntries();
|
||||
|
||||
Future<Set<AvesEntry>> loadEntries();
|
||||
Future<Set<AvesEntry>> loadAllEntries();
|
||||
|
||||
Future<void> saveEntries(Iterable<AvesEntry> entries);
|
||||
|
||||
|
@ -43,7 +44,7 @@ abstract class MetadataDb {
|
|||
|
||||
Future<void> clearMetadataEntries();
|
||||
|
||||
Future<List<CatalogMetadata>> loadMetadataEntries();
|
||||
Future<List<CatalogMetadata>> loadAllMetadataEntries();
|
||||
|
||||
Future<void> saveMetadata(Set<CatalogMetadata> metadataEntries);
|
||||
|
||||
|
@ -53,7 +54,7 @@ abstract class MetadataDb {
|
|||
|
||||
Future<void> clearAddresses();
|
||||
|
||||
Future<List<AddressDetails>> loadAddresses();
|
||||
Future<List<AddressDetails>> loadAllAddresses();
|
||||
|
||||
Future<void> saveAddresses(Set<AddressDetails> addresses);
|
||||
|
||||
|
@ -63,7 +64,7 @@ abstract class MetadataDb {
|
|||
|
||||
Future<void> clearFavourites();
|
||||
|
||||
Future<Set<FavouriteRow>> loadFavourites();
|
||||
Future<Set<FavouriteRow>> loadAllFavourites();
|
||||
|
||||
Future<void> addFavourites(Iterable<FavouriteRow> rows);
|
||||
|
||||
|
@ -75,13 +76,27 @@ abstract class MetadataDb {
|
|||
|
||||
Future<void> clearCovers();
|
||||
|
||||
Future<Set<CoverRow>> loadCovers();
|
||||
Future<Set<CoverRow>> loadAllCovers();
|
||||
|
||||
Future<void> addCovers(Iterable<CoverRow> rows);
|
||||
|
||||
Future<void> updateCoverEntryId(int oldId, CoverRow row);
|
||||
|
||||
Future<void> removeCovers(Set<CollectionFilter> filters);
|
||||
|
||||
// video playback
|
||||
|
||||
Future<void> clearVideoPlayback();
|
||||
|
||||
Future<Set<VideoPlaybackRow>> loadAllVideoPlayback();
|
||||
|
||||
Future<VideoPlaybackRow?> loadVideoPlayback(int? contentId);
|
||||
|
||||
Future<void> addVideoPlayback(Set<VideoPlaybackRow> rows);
|
||||
|
||||
Future<void> updateVideoPlaybackId(int oldId, int? newId);
|
||||
|
||||
Future<void> removeVideoPlayback(Set<int> contentIds);
|
||||
}
|
||||
|
||||
class SqfliteMetadataDb implements MetadataDb {
|
||||
|
@ -95,6 +110,7 @@ class SqfliteMetadataDb implements MetadataDb {
|
|||
static const addressTable = 'address';
|
||||
static const favouriteTable = 'favourites';
|
||||
static const coverTable = 'covers';
|
||||
static const videoPlaybackTable = 'videoPlayback';
|
||||
|
||||
@override
|
||||
Future<void> init() async {
|
||||
|
@ -146,9 +162,13 @@ class SqfliteMetadataDb implements MetadataDb {
|
|||
'filter TEXT PRIMARY KEY'
|
||||
', contentId INTEGER'
|
||||
')');
|
||||
await db.execute('CREATE TABLE $videoPlaybackTable('
|
||||
'contentId INTEGER PRIMARY KEY'
|
||||
', resumeTimeMillis INTEGER'
|
||||
')');
|
||||
},
|
||||
onUpgrade: MetadataDbUpgrader.upgradeDb,
|
||||
version: 4,
|
||||
version: 5,
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -183,6 +203,7 @@ class SqfliteMetadataDb implements MetadataDb {
|
|||
if (!metadataOnly) {
|
||||
batch.delete(favouriteTable, where: where, whereArgs: whereArgs);
|
||||
batch.delete(coverTable, where: where, whereArgs: whereArgs);
|
||||
batch.delete(videoPlaybackTable, where: where, whereArgs: whereArgs);
|
||||
}
|
||||
});
|
||||
await batch.commit(noResult: true);
|
||||
|
@ -194,11 +215,11 @@ class SqfliteMetadataDb implements MetadataDb {
|
|||
Future<void> clearEntries() async {
|
||||
final db = await _database;
|
||||
final count = await db.delete(entryTable, where: '1');
|
||||
debugPrint('$runtimeType clearEntries deleted $count entries');
|
||||
debugPrint('$runtimeType clearEntries deleted $count rows');
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Set<AvesEntry>> loadEntries() async {
|
||||
Future<Set<AvesEntry>> loadAllEntries() async {
|
||||
final db = await _database;
|
||||
final maps = await db.query(entryTable);
|
||||
final entries = maps.map((map) => AvesEntry.fromMap(map)).toSet();
|
||||
|
@ -252,7 +273,7 @@ class SqfliteMetadataDb implements MetadataDb {
|
|||
Future<void> clearDates() async {
|
||||
final db = await _database;
|
||||
final count = await db.delete(dateTakenTable, where: '1');
|
||||
debugPrint('$runtimeType clearDates deleted $count entries');
|
||||
debugPrint('$runtimeType clearDates deleted $count rows');
|
||||
}
|
||||
|
||||
@override
|
||||
|
@ -269,11 +290,11 @@ class SqfliteMetadataDb implements MetadataDb {
|
|||
Future<void> clearMetadataEntries() async {
|
||||
final db = await _database;
|
||||
final count = await db.delete(metadataTable, where: '1');
|
||||
debugPrint('$runtimeType clearMetadataEntries deleted $count entries');
|
||||
debugPrint('$runtimeType clearMetadataEntries deleted $count rows');
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<CatalogMetadata>> loadMetadataEntries() async {
|
||||
Future<List<CatalogMetadata>> loadAllMetadataEntries() async {
|
||||
final db = await _database;
|
||||
final maps = await db.query(metadataTable);
|
||||
final metadataEntries = maps.map((map) => CatalogMetadata.fromMap(map)).toList();
|
||||
|
@ -330,11 +351,11 @@ class SqfliteMetadataDb implements MetadataDb {
|
|||
Future<void> clearAddresses() async {
|
||||
final db = await _database;
|
||||
final count = await db.delete(addressTable, where: '1');
|
||||
debugPrint('$runtimeType clearAddresses deleted $count entries');
|
||||
debugPrint('$runtimeType clearAddresses deleted $count rows');
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<AddressDetails>> loadAddresses() async {
|
||||
Future<List<AddressDetails>> loadAllAddresses() async {
|
||||
final db = await _database;
|
||||
final maps = await db.query(addressTable);
|
||||
final addresses = maps.map((map) => AddressDetails.fromMap(map)).toList();
|
||||
|
@ -376,11 +397,11 @@ class SqfliteMetadataDb implements MetadataDb {
|
|||
Future<void> clearFavourites() async {
|
||||
final db = await _database;
|
||||
final count = await db.delete(favouriteTable, where: '1');
|
||||
debugPrint('$runtimeType clearFavourites deleted $count entries');
|
||||
debugPrint('$runtimeType clearFavourites deleted $count rows');
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Set<FavouriteRow>> loadFavourites() async {
|
||||
Future<Set<FavouriteRow>> loadAllFavourites() async {
|
||||
final db = await _database;
|
||||
final maps = await db.query(favouriteTable);
|
||||
final rows = maps.map((map) => FavouriteRow.fromMap(map)).toSet();
|
||||
|
@ -432,11 +453,11 @@ class SqfliteMetadataDb implements MetadataDb {
|
|||
Future<void> clearCovers() async {
|
||||
final db = await _database;
|
||||
final count = await db.delete(coverTable, where: '1');
|
||||
debugPrint('$runtimeType clearCovers deleted $count entries');
|
||||
debugPrint('$runtimeType clearCovers deleted $count rows');
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Set<CoverRow>> loadCovers() async {
|
||||
Future<Set<CoverRow>> loadAllCovers() async {
|
||||
final db = await _database;
|
||||
final maps = await db.query(coverTable);
|
||||
final rows = maps.map(CoverRow.fromMap).whereNotNull().toSet();
|
||||
|
@ -446,6 +467,7 @@ class SqfliteMetadataDb implements MetadataDb {
|
|||
@override
|
||||
Future<void> addCovers(Iterable<CoverRow> rows) async {
|
||||
if (rows.isEmpty) return;
|
||||
|
||||
final db = await _database;
|
||||
final batch = db.batch();
|
||||
rows.forEach((row) => _batchInsertCover(batch, row));
|
||||
|
@ -479,4 +501,71 @@ class SqfliteMetadataDb implements MetadataDb {
|
|||
filters.forEach((filter) => batch.delete(coverTable, where: 'filter = ?', whereArgs: [filter.toJson()]));
|
||||
await batch.commit(noResult: true);
|
||||
}
|
||||
|
||||
// video playback
|
||||
|
||||
@override
|
||||
Future<void> clearVideoPlayback() async {
|
||||
final db = await _database;
|
||||
final count = await db.delete(videoPlaybackTable, where: '1');
|
||||
debugPrint('$runtimeType clearVideoPlayback deleted $count rows');
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Set<VideoPlaybackRow>> loadAllVideoPlayback() async {
|
||||
final db = await _database;
|
||||
final maps = await db.query(videoPlaybackTable);
|
||||
final rows = maps.map(VideoPlaybackRow.fromMap).whereNotNull().toSet();
|
||||
return rows;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<VideoPlaybackRow?> loadVideoPlayback(int? contentId) async {
|
||||
if (contentId == null) return null;
|
||||
|
||||
final db = await _database;
|
||||
final maps = await db.query(videoPlaybackTable, where: 'contentId = ?', whereArgs: [contentId]);
|
||||
if (maps.isEmpty) return null;
|
||||
|
||||
return VideoPlaybackRow.fromMap(maps.first);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> addVideoPlayback(Set<VideoPlaybackRow> rows) async {
|
||||
if (rows.isEmpty) return;
|
||||
|
||||
final db = await _database;
|
||||
final batch = db.batch();
|
||||
rows.forEach((row) => _batchInsertVideoPlayback(batch, row));
|
||||
await batch.commit(noResult: true);
|
||||
}
|
||||
|
||||
void _batchInsertVideoPlayback(Batch batch, VideoPlaybackRow row) {
|
||||
batch.insert(
|
||||
videoPlaybackTable,
|
||||
row.toMap(),
|
||||
conflictAlgorithm: ConflictAlgorithm.replace,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> updateVideoPlaybackId(int oldId, int? newId) async {
|
||||
if (newId != null) {
|
||||
final db = await _database;
|
||||
await db.update(videoPlaybackTable, {'contentId': newId}, where: 'contentId = ?', whereArgs: [oldId]);
|
||||
} else {
|
||||
await removeVideoPlayback({oldId});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> removeVideoPlayback(Set<int> contentIds) async {
|
||||
if (contentIds.isEmpty) return;
|
||||
|
||||
final db = await _database;
|
||||
// using array in `whereArgs` and using it with `where filter IN ?` is a pain, so we prefer `batch` instead
|
||||
final batch = db.batch();
|
||||
contentIds.forEach((id) => batch.delete(videoPlaybackTable, where: 'contentId = ?', whereArgs: [id]));
|
||||
await batch.commit(noResult: true);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -6,6 +6,7 @@ class MetadataDbUpgrader {
|
|||
static const entryTable = SqfliteMetadataDb.entryTable;
|
||||
static const metadataTable = SqfliteMetadataDb.metadataTable;
|
||||
static const coverTable = SqfliteMetadataDb.coverTable;
|
||||
static const videoPlaybackTable = SqfliteMetadataDb.videoPlaybackTable;
|
||||
|
||||
// warning: "ALTER TABLE ... RENAME COLUMN ..." is not supported
|
||||
// on SQLite <3.25.0, bundled on older Android devices
|
||||
|
@ -21,6 +22,9 @@ class MetadataDbUpgrader {
|
|||
case 3:
|
||||
await _upgradeFrom3(db);
|
||||
break;
|
||||
case 4:
|
||||
await _upgradeFrom4(db);
|
||||
break;
|
||||
}
|
||||
oldVersion++;
|
||||
}
|
||||
|
@ -109,4 +113,12 @@ class MetadataDbUpgrader {
|
|||
', contentId INTEGER'
|
||||
')');
|
||||
}
|
||||
|
||||
static Future<void> _upgradeFrom4(Database db) async {
|
||||
debugPrint('upgrading DB from v4');
|
||||
await db.execute('CREATE TABLE $videoPlaybackTable('
|
||||
'contentId INTEGER PRIMARY KEY'
|
||||
', resumeTimeMillis INTEGER'
|
||||
')');
|
||||
}
|
||||
}
|
||||
|
|
|
@ -119,6 +119,7 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM
|
|||
final entries = _rawEntries.where((entry) => uris.contains(entry.uri)).toSet();
|
||||
await favourites.remove(entries);
|
||||
await covers.removeEntries(entries);
|
||||
await metadataDb.removeVideoPlayback(entries.map((entry) => entry.contentId).whereNotNull().toSet());
|
||||
|
||||
entries.forEach((v) => _entryById.remove(v.contentId));
|
||||
_rawEntries.removeAll(entries);
|
||||
|
@ -157,6 +158,7 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM
|
|||
await metadataDb.updateAddressId(oldContentId, entry.addressDetails);
|
||||
await favourites.moveEntry(oldContentId, entry);
|
||||
await covers.moveEntry(oldContentId, entry);
|
||||
await metadataDb.updateVideoPlaybackId(oldContentId, entry.contentId);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -21,7 +21,7 @@ mixin LocationMixin on SourceBase {
|
|||
List<String> sortedPlaces = List.unmodifiable([]);
|
||||
|
||||
Future<void> loadAddresses() async {
|
||||
final saved = await metadataDb.loadAddresses();
|
||||
final saved = await metadataDb.loadAllAddresses();
|
||||
final idMap = entryById;
|
||||
saved.forEach((metadata) => idMap[metadata.contentId]?.addressDetails = metadata);
|
||||
onAddressMetadataChanged();
|
||||
|
|
|
@ -51,7 +51,7 @@ class MediaStoreSource extends CollectionSource {
|
|||
clearEntries();
|
||||
|
||||
debugPrint('$runtimeType refresh ${stopwatch.elapsed} fetch known entries');
|
||||
final oldEntries = await metadataDb.loadEntries();
|
||||
final oldEntries = await metadataDb.loadAllEntries();
|
||||
debugPrint('$runtimeType refresh ${stopwatch.elapsed} check obsolete entries');
|
||||
final knownDateById = Map.fromEntries(oldEntries.map((entry) => MapEntry(entry.contentId!, entry.dateModifiedSecs!)));
|
||||
final obsoleteContentIds = (await mediaStoreService.checkObsoleteContentIds(knownDateById.keys.toList())).toSet();
|
||||
|
|
|
@ -15,7 +15,7 @@ mixin TagMixin on SourceBase {
|
|||
List<String> sortedTags = List.unmodifiable([]);
|
||||
|
||||
Future<void> loadCatalogMetadata() async {
|
||||
final saved = await metadataDb.loadMetadataEntries();
|
||||
final saved = await metadataDb.loadAllMetadataEntries();
|
||||
final idMap = entryById;
|
||||
saved.forEach((metadata) => idMap[metadata.contentId]?.catalogMetadata = metadata);
|
||||
onCatalogMetadataChanged();
|
||||
|
|
27
lib/model/video_playback.dart
Normal file
27
lib/model/video_playback.dart
Normal file
|
@ -0,0 +1,27 @@
|
|||
import 'package:equatable/equatable.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
@immutable
|
||||
class VideoPlaybackRow extends Equatable {
|
||||
final int contentId, resumeTimeMillis;
|
||||
|
||||
@override
|
||||
List<Object?> get props => [contentId, resumeTimeMillis];
|
||||
|
||||
const VideoPlaybackRow({
|
||||
required this.contentId,
|
||||
required this.resumeTimeMillis,
|
||||
});
|
||||
|
||||
static VideoPlaybackRow? fromMap(Map map) {
|
||||
return VideoPlaybackRow(
|
||||
contentId: map['contentId'],
|
||||
resumeTimeMillis: map['resumeTimeMillis'],
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toMap() => {
|
||||
'contentId': contentId,
|
||||
'resumeTimeMillis': resumeTimeMillis,
|
||||
};
|
||||
}
|
|
@ -3,6 +3,7 @@ import 'package:aves/model/entry.dart';
|
|||
import 'package:aves/model/favourites.dart';
|
||||
import 'package:aves/model/metadata/address.dart';
|
||||
import 'package:aves/model/metadata/catalog.dart';
|
||||
import 'package:aves/model/video_playback.dart';
|
||||
import 'package:aves/services/common/services.dart';
|
||||
import 'package:aves/utils/file_utils.dart';
|
||||
import 'package:aves/widgets/common/identity/aves_expansion_tile.dart';
|
||||
|
@ -23,6 +24,7 @@ class _DebugAppDatabaseSectionState extends State<DebugAppDatabaseSection> with
|
|||
late Future<List<AddressDetails>> _dbAddressLoader;
|
||||
late Future<Set<FavouriteRow>> _dbFavouritesLoader;
|
||||
late Future<Set<CoverRow>> _dbCoversLoader;
|
||||
late Future<Set<VideoPlaybackRow>> _dbVideoPlaybackLoader;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
|
@ -188,6 +190,27 @@ class _DebugAppDatabaseSectionState extends State<DebugAppDatabaseSection> with
|
|||
);
|
||||
},
|
||||
),
|
||||
FutureBuilder<Set>(
|
||||
future: _dbVideoPlaybackLoader,
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.hasError) return Text(snapshot.error.toString());
|
||||
|
||||
if (snapshot.connectionState != ConnectionState.done) return const SizedBox.shrink();
|
||||
|
||||
return Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text('video playback rows: ${snapshot.data!.length}'),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
ElevatedButton(
|
||||
onPressed: () => metadataDb.clearVideoPlayback().then((_) => _startDbReport()),
|
||||
child: const Text('Clear'),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
@ -197,12 +220,13 @@ class _DebugAppDatabaseSectionState extends State<DebugAppDatabaseSection> with
|
|||
|
||||
void _startDbReport() {
|
||||
_dbFileSizeLoader = metadataDb.dbFileSize();
|
||||
_dbEntryLoader = metadataDb.loadEntries();
|
||||
_dbEntryLoader = metadataDb.loadAllEntries();
|
||||
_dbDateLoader = metadataDb.loadDates();
|
||||
_dbMetadataLoader = metadataDb.loadMetadataEntries();
|
||||
_dbAddressLoader = metadataDb.loadAddresses();
|
||||
_dbFavouritesLoader = metadataDb.loadFavourites();
|
||||
_dbCoversLoader = metadataDb.loadCovers();
|
||||
_dbMetadataLoader = metadataDb.loadAllMetadataEntries();
|
||||
_dbAddressLoader = metadataDb.loadAllAddresses();
|
||||
_dbFavouritesLoader = metadataDb.loadAllFavourites();
|
||||
_dbCoversLoader = metadataDb.loadAllCovers();
|
||||
_dbVideoPlaybackLoader = metadataDb.loadAllVideoPlayback();
|
||||
setState(() {});
|
||||
}
|
||||
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
import 'package:aves/model/entry.dart';
|
||||
import 'package:aves/model/metadata/address.dart';
|
||||
import 'package:aves/model/metadata/catalog.dart';
|
||||
import 'package:aves/model/video_playback.dart';
|
||||
import 'package:aves/services/common/services.dart';
|
||||
import 'package:aves/widgets/viewer/info/common.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
|
@ -23,6 +24,7 @@ class _DbTabState extends State<DbTab> {
|
|||
late Future<AvesEntry?> _dbEntryLoader;
|
||||
late Future<CatalogMetadata?> _dbMetadataLoader;
|
||||
late Future<AddressDetails?> _dbAddressLoader;
|
||||
late Future<VideoPlaybackRow?> _dbVideoPlaybackLoader;
|
||||
|
||||
AvesEntry get entry => widget.entry;
|
||||
|
||||
|
@ -35,9 +37,10 @@ class _DbTabState extends State<DbTab> {
|
|||
void _loadDatabase() {
|
||||
final contentId = entry.contentId;
|
||||
_dbDateLoader = metadataDb.loadDates().then((values) => values[contentId]);
|
||||
_dbEntryLoader = metadataDb.loadEntries().then((values) => values.firstWhereOrNull((row) => row.contentId == contentId));
|
||||
_dbMetadataLoader = metadataDb.loadMetadataEntries().then((values) => values.firstWhereOrNull((row) => row.contentId == contentId));
|
||||
_dbAddressLoader = metadataDb.loadAddresses().then((values) => values.firstWhereOrNull((row) => row.contentId == contentId));
|
||||
_dbEntryLoader = metadataDb.loadAllEntries().then((values) => values.firstWhereOrNull((row) => row.contentId == contentId));
|
||||
_dbMetadataLoader = metadataDb.loadAllMetadataEntries().then((values) => values.firstWhereOrNull((row) => row.contentId == contentId));
|
||||
_dbAddressLoader = metadataDb.loadAllAddresses().then((values) => values.firstWhereOrNull((row) => row.contentId == contentId));
|
||||
_dbVideoPlaybackLoader = metadataDb.loadVideoPlayback(contentId);
|
||||
setState(() {});
|
||||
}
|
||||
|
||||
|
@ -150,6 +153,27 @@ class _DbTabState extends State<DbTab> {
|
|||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
FutureBuilder<VideoPlaybackRow?>(
|
||||
future: _dbVideoPlaybackLoader,
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.hasError) return Text(snapshot.error.toString());
|
||||
if (snapshot.connectionState != ConnectionState.done) return const SizedBox.shrink();
|
||||
final data = snapshot.data;
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('DB video playback:${data == null ? ' no row' : ''}'),
|
||||
if (data != null)
|
||||
InfoRowGroup(
|
||||
info: {
|
||||
'resumeTimeMillis': '${data.resumeTimeMillis}',
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
|
|
@ -597,7 +597,8 @@ class _EntryViewerStackState extends State<EntryViewerStack> with FeedbackMixin,
|
|||
setState(() {});
|
||||
|
||||
if (settings.enableVideoAutoPlay) {
|
||||
await _playVideo(controller, () => entry == _entryNotifier.value);
|
||||
final resumeTimeMillis = await controller.getResumeTime(context);
|
||||
await _playVideo(controller, () => entry == _entryNotifier.value, resumeTimeMillis: resumeTimeMillis);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -649,13 +650,17 @@ class _EntryViewerStackState extends State<EntryViewerStack> with FeedbackMixin,
|
|||
}
|
||||
}
|
||||
|
||||
Future<void> _playVideo(AvesVideoController videoController, bool Function() isCurrent) async {
|
||||
Future<void> _playVideo(AvesVideoController videoController, bool Function() isCurrent, {int? resumeTimeMillis}) async {
|
||||
// video decoding may fail or have initial artifacts when the player initializes
|
||||
// during this widget initialization (because of the page transition and hero animation?)
|
||||
// so we play after a delay for increased stability
|
||||
await Future.delayed(const Duration(milliseconds: 300) * timeDilation);
|
||||
|
||||
if (resumeTimeMillis != null) {
|
||||
await videoController.seekTo(resumeTimeMillis);
|
||||
} else {
|
||||
await videoController.play();
|
||||
}
|
||||
|
||||
// playing controllers are paused when the entry changes,
|
||||
// but the controller may still be preparing (not yet playing) when this happens
|
||||
|
|
|
@ -1,6 +1,11 @@
|
|||
import 'dart:typed_data';
|
||||
|
||||
import 'package:aves/model/entry.dart';
|
||||
import 'package:aves/model/video_playback.dart';
|
||||
import 'package:aves/services/common/services.dart';
|
||||
import 'package:aves/theme/format.dart';
|
||||
import 'package:aves/widgets/common/extensions/build_context.dart';
|
||||
import 'package:aves/widgets/dialogs/aves_dialog.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
|
@ -11,7 +16,65 @@ abstract class AvesVideoController {
|
|||
|
||||
AvesVideoController(AvesEntry entry) : _entry = entry;
|
||||
|
||||
Future<void> dispose();
|
||||
static const resumeTimeSaveMinProgress = .05;
|
||||
static const resumeTimeSaveMaxProgress = .95;
|
||||
static const resumeTimeSaveMinDuration = Duration(minutes: 2);
|
||||
|
||||
@mustCallSuper
|
||||
Future<void> dispose() async {
|
||||
await _savePlaybackState();
|
||||
}
|
||||
|
||||
Future<void> _savePlaybackState() async {
|
||||
final contentId = entry.contentId;
|
||||
if (contentId == null || !isReady || duration < resumeTimeSaveMinDuration.inMilliseconds) return;
|
||||
|
||||
final _progress = progress;
|
||||
if (resumeTimeSaveMinProgress < _progress && _progress < resumeTimeSaveMaxProgress) {
|
||||
await metadataDb.addVideoPlayback({
|
||||
VideoPlaybackRow(
|
||||
contentId: contentId,
|
||||
resumeTimeMillis: currentPosition,
|
||||
)
|
||||
});
|
||||
} else {
|
||||
await metadataDb.removeVideoPlayback({contentId});
|
||||
}
|
||||
}
|
||||
|
||||
Future<int?> getResumeTime(BuildContext context) async {
|
||||
final contentId = entry.contentId;
|
||||
if (contentId == null) return null;
|
||||
|
||||
final playback = await metadataDb.loadVideoPlayback(contentId);
|
||||
final resumeTime = playback?.resumeTimeMillis ?? 0;
|
||||
if (resumeTime == 0) return null;
|
||||
|
||||
// clear on retrieval
|
||||
await metadataDb.removeVideoPlayback({contentId});
|
||||
|
||||
final resume = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) {
|
||||
return AvesDialog(
|
||||
context: context,
|
||||
content: Text(context.l10n.videoResumeDialogMessage(formatFriendlyDuration(Duration(milliseconds: resumeTime)))),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: Text(context.l10n.videoStartOverButtonLabel),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, true),
|
||||
child: Text(context.l10n.videoResumeButtonLabel),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
if (resume == null || !resume) return 0;
|
||||
return resumeTime;
|
||||
}
|
||||
|
||||
Future<void> play();
|
||||
|
||||
|
|
|
@ -69,6 +69,7 @@ class IjkPlayerAvesVideoController extends AvesVideoController {
|
|||
|
||||
@override
|
||||
Future<void> dispose() async {
|
||||
await super.dispose();
|
||||
_initialPlayTimer?.cancel();
|
||||
_stopListening();
|
||||
await _valueStreamController.close();
|
||||
|
|
|
@ -184,8 +184,13 @@ class VideoActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix
|
|||
Future<void> _togglePlayPause(BuildContext context, AvesVideoController controller) async {
|
||||
if (controller.isPlaying) {
|
||||
await controller.pause();
|
||||
} else {
|
||||
final resumeTimeMillis = await controller.getResumeTime(context);
|
||||
if (resumeTimeMillis != null) {
|
||||
await controller.seekTo(resumeTimeMillis);
|
||||
} else {
|
||||
await controller.play();
|
||||
}
|
||||
// hide overlay
|
||||
_overlayHidingTimer = Timer(context.read<DurationsData>().iconAnimation + Durations.videoOverlayHideDelay, () {
|
||||
const ToggleOverlayNotification(visible: false).dispatch(context);
|
||||
|
|
|
@ -15,8 +15,10 @@ class FakeMetadataDb extends Fake implements MetadataDb {
|
|||
@override
|
||||
Future<void> removeIds(Set<int> contentIds, {required bool metadataOnly}) => SynchronousFuture(null);
|
||||
|
||||
// entries
|
||||
|
||||
@override
|
||||
Future<Set<AvesEntry>> loadEntries() => SynchronousFuture({});
|
||||
Future<Set<AvesEntry>> loadAllEntries() => SynchronousFuture({});
|
||||
|
||||
@override
|
||||
Future<void> saveEntries(Iterable<AvesEntry> entries) => SynchronousFuture(null);
|
||||
|
@ -24,11 +26,15 @@ class FakeMetadataDb extends Fake implements MetadataDb {
|
|||
@override
|
||||
Future<void> updateEntryId(int oldId, AvesEntry entry) => SynchronousFuture(null);
|
||||
|
||||
// date taken
|
||||
|
||||
@override
|
||||
Future<Map<int?, int?>> loadDates() => SynchronousFuture({});
|
||||
|
||||
// catalog metadata
|
||||
|
||||
@override
|
||||
Future<List<CatalogMetadata>> loadMetadataEntries() => SynchronousFuture([]);
|
||||
Future<List<CatalogMetadata>> loadAllMetadataEntries() => SynchronousFuture([]);
|
||||
|
||||
@override
|
||||
Future<void> saveMetadata(Set<CatalogMetadata> metadataEntries) => SynchronousFuture(null);
|
||||
|
@ -36,8 +42,10 @@ class FakeMetadataDb extends Fake implements MetadataDb {
|
|||
@override
|
||||
Future<void> updateMetadataId(int oldId, CatalogMetadata? metadata) => SynchronousFuture(null);
|
||||
|
||||
// address
|
||||
|
||||
@override
|
||||
Future<List<AddressDetails>> loadAddresses() => SynchronousFuture([]);
|
||||
Future<List<AddressDetails>> loadAllAddresses() => SynchronousFuture([]);
|
||||
|
||||
@override
|
||||
Future<void> saveAddresses(Set<AddressDetails> addresses) => SynchronousFuture(null);
|
||||
|
@ -45,8 +53,10 @@ class FakeMetadataDb extends Fake implements MetadataDb {
|
|||
@override
|
||||
Future<void> updateAddressId(int oldId, AddressDetails? address) => SynchronousFuture(null);
|
||||
|
||||
// favourites
|
||||
|
||||
@override
|
||||
Future<Set<FavouriteRow>> loadFavourites() => SynchronousFuture({});
|
||||
Future<Set<FavouriteRow>> loadAllFavourites() => SynchronousFuture({});
|
||||
|
||||
@override
|
||||
Future<void> addFavourites(Iterable<FavouriteRow> rows) => SynchronousFuture(null);
|
||||
|
@ -57,8 +67,10 @@ class FakeMetadataDb extends Fake implements MetadataDb {
|
|||
@override
|
||||
Future<void> removeFavourites(Iterable<FavouriteRow> rows) => SynchronousFuture(null);
|
||||
|
||||
// covers
|
||||
|
||||
@override
|
||||
Future<Set<CoverRow>> loadCovers() => SynchronousFuture({});
|
||||
Future<Set<CoverRow>> loadAllCovers() => SynchronousFuture({});
|
||||
|
||||
@override
|
||||
Future<void> addCovers(Iterable<CoverRow> rows) => SynchronousFuture(null);
|
||||
|
|
|
@ -2,6 +2,9 @@
|
|||
"ko": [
|
||||
"unsupportedTypeDialogTitle",
|
||||
"unsupportedTypeDialogMessage",
|
||||
"videoResumeDialogMessage",
|
||||
"videoStartOverButtonLabel",
|
||||
"videoResumeButtonLabel",
|
||||
"editEntryDateDialogExtractFromTitle",
|
||||
"collectionActionShowTitleSearch",
|
||||
"collectionActionHideTitleSearch",
|
||||
|
@ -16,6 +19,9 @@
|
|||
"ru": [
|
||||
"unsupportedTypeDialogTitle",
|
||||
"unsupportedTypeDialogMessage",
|
||||
"videoResumeDialogMessage",
|
||||
"videoStartOverButtonLabel",
|
||||
"videoResumeButtonLabel",
|
||||
"editEntryDateDialogExtractFromTitle",
|
||||
"aboutLinkPolicy",
|
||||
"aboutCreditsTranslators",
|
||||
|
|
Loading…
Reference in a new issue