#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": "Set Cover",
|
||||||
"@setCoverDialogTitle": {},
|
"@setCoverDialogTitle": {},
|
||||||
"setCoverDialogLatest": "Latest item",
|
"setCoverDialogLatest": "Latest item",
|
||||||
|
|
|
@ -15,7 +15,7 @@ class Covers with ChangeNotifier {
|
||||||
Covers._private();
|
Covers._private();
|
||||||
|
|
||||||
Future<void> init() async {
|
Future<void> init() async {
|
||||||
_rows = await metadataDb.loadCovers();
|
_rows = await metadataDb.loadAllCovers();
|
||||||
}
|
}
|
||||||
|
|
||||||
int get count => _rows.length;
|
int get count => _rows.length;
|
||||||
|
|
|
@ -12,7 +12,7 @@ class Favourites with ChangeNotifier {
|
||||||
Favourites._private();
|
Favourites._private();
|
||||||
|
|
||||||
Future<void> init() async {
|
Future<void> init() async {
|
||||||
_rows = await metadataDb.loadFavourites();
|
_rows = await metadataDb.loadAllFavourites();
|
||||||
}
|
}
|
||||||
|
|
||||||
int get count => _rows.length;
|
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/address.dart';
|
||||||
import 'package:aves/model/metadata/catalog.dart';
|
import 'package:aves/model/metadata/catalog.dart';
|
||||||
import 'package:aves/model/metadata_db_upgrade.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:aves/services/common/services.dart';
|
||||||
import 'package:collection/collection.dart';
|
import 'package:collection/collection.dart';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
|
@ -25,7 +26,7 @@ abstract class MetadataDb {
|
||||||
|
|
||||||
Future<void> clearEntries();
|
Future<void> clearEntries();
|
||||||
|
|
||||||
Future<Set<AvesEntry>> loadEntries();
|
Future<Set<AvesEntry>> loadAllEntries();
|
||||||
|
|
||||||
Future<void> saveEntries(Iterable<AvesEntry> entries);
|
Future<void> saveEntries(Iterable<AvesEntry> entries);
|
||||||
|
|
||||||
|
@ -43,7 +44,7 @@ abstract class MetadataDb {
|
||||||
|
|
||||||
Future<void> clearMetadataEntries();
|
Future<void> clearMetadataEntries();
|
||||||
|
|
||||||
Future<List<CatalogMetadata>> loadMetadataEntries();
|
Future<List<CatalogMetadata>> loadAllMetadataEntries();
|
||||||
|
|
||||||
Future<void> saveMetadata(Set<CatalogMetadata> metadataEntries);
|
Future<void> saveMetadata(Set<CatalogMetadata> metadataEntries);
|
||||||
|
|
||||||
|
@ -53,7 +54,7 @@ abstract class MetadataDb {
|
||||||
|
|
||||||
Future<void> clearAddresses();
|
Future<void> clearAddresses();
|
||||||
|
|
||||||
Future<List<AddressDetails>> loadAddresses();
|
Future<List<AddressDetails>> loadAllAddresses();
|
||||||
|
|
||||||
Future<void> saveAddresses(Set<AddressDetails> addresses);
|
Future<void> saveAddresses(Set<AddressDetails> addresses);
|
||||||
|
|
||||||
|
@ -63,7 +64,7 @@ abstract class MetadataDb {
|
||||||
|
|
||||||
Future<void> clearFavourites();
|
Future<void> clearFavourites();
|
||||||
|
|
||||||
Future<Set<FavouriteRow>> loadFavourites();
|
Future<Set<FavouriteRow>> loadAllFavourites();
|
||||||
|
|
||||||
Future<void> addFavourites(Iterable<FavouriteRow> rows);
|
Future<void> addFavourites(Iterable<FavouriteRow> rows);
|
||||||
|
|
||||||
|
@ -75,13 +76,27 @@ abstract class MetadataDb {
|
||||||
|
|
||||||
Future<void> clearCovers();
|
Future<void> clearCovers();
|
||||||
|
|
||||||
Future<Set<CoverRow>> loadCovers();
|
Future<Set<CoverRow>> loadAllCovers();
|
||||||
|
|
||||||
Future<void> addCovers(Iterable<CoverRow> rows);
|
Future<void> addCovers(Iterable<CoverRow> rows);
|
||||||
|
|
||||||
Future<void> updateCoverEntryId(int oldId, CoverRow row);
|
Future<void> updateCoverEntryId(int oldId, CoverRow row);
|
||||||
|
|
||||||
Future<void> removeCovers(Set<CollectionFilter> filters);
|
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 {
|
class SqfliteMetadataDb implements MetadataDb {
|
||||||
|
@ -95,6 +110,7 @@ class SqfliteMetadataDb implements MetadataDb {
|
||||||
static const addressTable = 'address';
|
static const addressTable = 'address';
|
||||||
static const favouriteTable = 'favourites';
|
static const favouriteTable = 'favourites';
|
||||||
static const coverTable = 'covers';
|
static const coverTable = 'covers';
|
||||||
|
static const videoPlaybackTable = 'videoPlayback';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> init() async {
|
Future<void> init() async {
|
||||||
|
@ -146,9 +162,13 @@ class SqfliteMetadataDb implements MetadataDb {
|
||||||
'filter TEXT PRIMARY KEY'
|
'filter TEXT PRIMARY KEY'
|
||||||
', contentId INTEGER'
|
', contentId INTEGER'
|
||||||
')');
|
')');
|
||||||
|
await db.execute('CREATE TABLE $videoPlaybackTable('
|
||||||
|
'contentId INTEGER PRIMARY KEY'
|
||||||
|
', resumeTimeMillis INTEGER'
|
||||||
|
')');
|
||||||
},
|
},
|
||||||
onUpgrade: MetadataDbUpgrader.upgradeDb,
|
onUpgrade: MetadataDbUpgrader.upgradeDb,
|
||||||
version: 4,
|
version: 5,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -183,6 +203,7 @@ class SqfliteMetadataDb implements MetadataDb {
|
||||||
if (!metadataOnly) {
|
if (!metadataOnly) {
|
||||||
batch.delete(favouriteTable, where: where, whereArgs: whereArgs);
|
batch.delete(favouriteTable, where: where, whereArgs: whereArgs);
|
||||||
batch.delete(coverTable, where: where, whereArgs: whereArgs);
|
batch.delete(coverTable, where: where, whereArgs: whereArgs);
|
||||||
|
batch.delete(videoPlaybackTable, where: where, whereArgs: whereArgs);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
await batch.commit(noResult: true);
|
await batch.commit(noResult: true);
|
||||||
|
@ -194,11 +215,11 @@ class SqfliteMetadataDb implements MetadataDb {
|
||||||
Future<void> clearEntries() async {
|
Future<void> clearEntries() async {
|
||||||
final db = await _database;
|
final db = await _database;
|
||||||
final count = await db.delete(entryTable, where: '1');
|
final count = await db.delete(entryTable, where: '1');
|
||||||
debugPrint('$runtimeType clearEntries deleted $count entries');
|
debugPrint('$runtimeType clearEntries deleted $count rows');
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<Set<AvesEntry>> loadEntries() async {
|
Future<Set<AvesEntry>> loadAllEntries() async {
|
||||||
final db = await _database;
|
final db = await _database;
|
||||||
final maps = await db.query(entryTable);
|
final maps = await db.query(entryTable);
|
||||||
final entries = maps.map((map) => AvesEntry.fromMap(map)).toSet();
|
final entries = maps.map((map) => AvesEntry.fromMap(map)).toSet();
|
||||||
|
@ -252,7 +273,7 @@ class SqfliteMetadataDb implements MetadataDb {
|
||||||
Future<void> clearDates() async {
|
Future<void> clearDates() async {
|
||||||
final db = await _database;
|
final db = await _database;
|
||||||
final count = await db.delete(dateTakenTable, where: '1');
|
final count = await db.delete(dateTakenTable, where: '1');
|
||||||
debugPrint('$runtimeType clearDates deleted $count entries');
|
debugPrint('$runtimeType clearDates deleted $count rows');
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
@ -269,11 +290,11 @@ class SqfliteMetadataDb implements MetadataDb {
|
||||||
Future<void> clearMetadataEntries() async {
|
Future<void> clearMetadataEntries() async {
|
||||||
final db = await _database;
|
final db = await _database;
|
||||||
final count = await db.delete(metadataTable, where: '1');
|
final count = await db.delete(metadataTable, where: '1');
|
||||||
debugPrint('$runtimeType clearMetadataEntries deleted $count entries');
|
debugPrint('$runtimeType clearMetadataEntries deleted $count rows');
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<List<CatalogMetadata>> loadMetadataEntries() async {
|
Future<List<CatalogMetadata>> loadAllMetadataEntries() async {
|
||||||
final db = await _database;
|
final db = await _database;
|
||||||
final maps = await db.query(metadataTable);
|
final maps = await db.query(metadataTable);
|
||||||
final metadataEntries = maps.map((map) => CatalogMetadata.fromMap(map)).toList();
|
final metadataEntries = maps.map((map) => CatalogMetadata.fromMap(map)).toList();
|
||||||
|
@ -330,11 +351,11 @@ class SqfliteMetadataDb implements MetadataDb {
|
||||||
Future<void> clearAddresses() async {
|
Future<void> clearAddresses() async {
|
||||||
final db = await _database;
|
final db = await _database;
|
||||||
final count = await db.delete(addressTable, where: '1');
|
final count = await db.delete(addressTable, where: '1');
|
||||||
debugPrint('$runtimeType clearAddresses deleted $count entries');
|
debugPrint('$runtimeType clearAddresses deleted $count rows');
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<List<AddressDetails>> loadAddresses() async {
|
Future<List<AddressDetails>> loadAllAddresses() async {
|
||||||
final db = await _database;
|
final db = await _database;
|
||||||
final maps = await db.query(addressTable);
|
final maps = await db.query(addressTable);
|
||||||
final addresses = maps.map((map) => AddressDetails.fromMap(map)).toList();
|
final addresses = maps.map((map) => AddressDetails.fromMap(map)).toList();
|
||||||
|
@ -376,11 +397,11 @@ class SqfliteMetadataDb implements MetadataDb {
|
||||||
Future<void> clearFavourites() async {
|
Future<void> clearFavourites() async {
|
||||||
final db = await _database;
|
final db = await _database;
|
||||||
final count = await db.delete(favouriteTable, where: '1');
|
final count = await db.delete(favouriteTable, where: '1');
|
||||||
debugPrint('$runtimeType clearFavourites deleted $count entries');
|
debugPrint('$runtimeType clearFavourites deleted $count rows');
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<Set<FavouriteRow>> loadFavourites() async {
|
Future<Set<FavouriteRow>> loadAllFavourites() async {
|
||||||
final db = await _database;
|
final db = await _database;
|
||||||
final maps = await db.query(favouriteTable);
|
final maps = await db.query(favouriteTable);
|
||||||
final rows = maps.map((map) => FavouriteRow.fromMap(map)).toSet();
|
final rows = maps.map((map) => FavouriteRow.fromMap(map)).toSet();
|
||||||
|
@ -432,11 +453,11 @@ class SqfliteMetadataDb implements MetadataDb {
|
||||||
Future<void> clearCovers() async {
|
Future<void> clearCovers() async {
|
||||||
final db = await _database;
|
final db = await _database;
|
||||||
final count = await db.delete(coverTable, where: '1');
|
final count = await db.delete(coverTable, where: '1');
|
||||||
debugPrint('$runtimeType clearCovers deleted $count entries');
|
debugPrint('$runtimeType clearCovers deleted $count rows');
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<Set<CoverRow>> loadCovers() async {
|
Future<Set<CoverRow>> loadAllCovers() async {
|
||||||
final db = await _database;
|
final db = await _database;
|
||||||
final maps = await db.query(coverTable);
|
final maps = await db.query(coverTable);
|
||||||
final rows = maps.map(CoverRow.fromMap).whereNotNull().toSet();
|
final rows = maps.map(CoverRow.fromMap).whereNotNull().toSet();
|
||||||
|
@ -446,6 +467,7 @@ class SqfliteMetadataDb implements MetadataDb {
|
||||||
@override
|
@override
|
||||||
Future<void> addCovers(Iterable<CoverRow> rows) async {
|
Future<void> addCovers(Iterable<CoverRow> rows) async {
|
||||||
if (rows.isEmpty) return;
|
if (rows.isEmpty) return;
|
||||||
|
|
||||||
final db = await _database;
|
final db = await _database;
|
||||||
final batch = db.batch();
|
final batch = db.batch();
|
||||||
rows.forEach((row) => _batchInsertCover(batch, row));
|
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()]));
|
filters.forEach((filter) => batch.delete(coverTable, where: 'filter = ?', whereArgs: [filter.toJson()]));
|
||||||
await batch.commit(noResult: true);
|
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 entryTable = SqfliteMetadataDb.entryTable;
|
||||||
static const metadataTable = SqfliteMetadataDb.metadataTable;
|
static const metadataTable = SqfliteMetadataDb.metadataTable;
|
||||||
static const coverTable = SqfliteMetadataDb.coverTable;
|
static const coverTable = SqfliteMetadataDb.coverTable;
|
||||||
|
static const videoPlaybackTable = SqfliteMetadataDb.videoPlaybackTable;
|
||||||
|
|
||||||
// warning: "ALTER TABLE ... RENAME COLUMN ..." is not supported
|
// warning: "ALTER TABLE ... RENAME COLUMN ..." is not supported
|
||||||
// on SQLite <3.25.0, bundled on older Android devices
|
// on SQLite <3.25.0, bundled on older Android devices
|
||||||
|
@ -21,6 +22,9 @@ class MetadataDbUpgrader {
|
||||||
case 3:
|
case 3:
|
||||||
await _upgradeFrom3(db);
|
await _upgradeFrom3(db);
|
||||||
break;
|
break;
|
||||||
|
case 4:
|
||||||
|
await _upgradeFrom4(db);
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
oldVersion++;
|
oldVersion++;
|
||||||
}
|
}
|
||||||
|
@ -109,4 +113,12 @@ class MetadataDbUpgrader {
|
||||||
', contentId INTEGER'
|
', 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();
|
final entries = _rawEntries.where((entry) => uris.contains(entry.uri)).toSet();
|
||||||
await favourites.remove(entries);
|
await favourites.remove(entries);
|
||||||
await covers.removeEntries(entries);
|
await covers.removeEntries(entries);
|
||||||
|
await metadataDb.removeVideoPlayback(entries.map((entry) => entry.contentId).whereNotNull().toSet());
|
||||||
|
|
||||||
entries.forEach((v) => _entryById.remove(v.contentId));
|
entries.forEach((v) => _entryById.remove(v.contentId));
|
||||||
_rawEntries.removeAll(entries);
|
_rawEntries.removeAll(entries);
|
||||||
|
@ -157,6 +158,7 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM
|
||||||
await metadataDb.updateAddressId(oldContentId, entry.addressDetails);
|
await metadataDb.updateAddressId(oldContentId, entry.addressDetails);
|
||||||
await favourites.moveEntry(oldContentId, entry);
|
await favourites.moveEntry(oldContentId, entry);
|
||||||
await covers.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([]);
|
List<String> sortedPlaces = List.unmodifiable([]);
|
||||||
|
|
||||||
Future<void> loadAddresses() async {
|
Future<void> loadAddresses() async {
|
||||||
final saved = await metadataDb.loadAddresses();
|
final saved = await metadataDb.loadAllAddresses();
|
||||||
final idMap = entryById;
|
final idMap = entryById;
|
||||||
saved.forEach((metadata) => idMap[metadata.contentId]?.addressDetails = metadata);
|
saved.forEach((metadata) => idMap[metadata.contentId]?.addressDetails = metadata);
|
||||||
onAddressMetadataChanged();
|
onAddressMetadataChanged();
|
||||||
|
|
|
@ -51,7 +51,7 @@ class MediaStoreSource extends CollectionSource {
|
||||||
clearEntries();
|
clearEntries();
|
||||||
|
|
||||||
debugPrint('$runtimeType refresh ${stopwatch.elapsed} fetch known entries');
|
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');
|
debugPrint('$runtimeType refresh ${stopwatch.elapsed} check obsolete entries');
|
||||||
final knownDateById = Map.fromEntries(oldEntries.map((entry) => MapEntry(entry.contentId!, entry.dateModifiedSecs!)));
|
final knownDateById = Map.fromEntries(oldEntries.map((entry) => MapEntry(entry.contentId!, entry.dateModifiedSecs!)));
|
||||||
final obsoleteContentIds = (await mediaStoreService.checkObsoleteContentIds(knownDateById.keys.toList())).toSet();
|
final obsoleteContentIds = (await mediaStoreService.checkObsoleteContentIds(knownDateById.keys.toList())).toSet();
|
||||||
|
|
|
@ -15,7 +15,7 @@ mixin TagMixin on SourceBase {
|
||||||
List<String> sortedTags = List.unmodifiable([]);
|
List<String> sortedTags = List.unmodifiable([]);
|
||||||
|
|
||||||
Future<void> loadCatalogMetadata() async {
|
Future<void> loadCatalogMetadata() async {
|
||||||
final saved = await metadataDb.loadMetadataEntries();
|
final saved = await metadataDb.loadAllMetadataEntries();
|
||||||
final idMap = entryById;
|
final idMap = entryById;
|
||||||
saved.forEach((metadata) => idMap[metadata.contentId]?.catalogMetadata = metadata);
|
saved.forEach((metadata) => idMap[metadata.contentId]?.catalogMetadata = metadata);
|
||||||
onCatalogMetadataChanged();
|
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/favourites.dart';
|
||||||
import 'package:aves/model/metadata/address.dart';
|
import 'package:aves/model/metadata/address.dart';
|
||||||
import 'package:aves/model/metadata/catalog.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/services/common/services.dart';
|
||||||
import 'package:aves/utils/file_utils.dart';
|
import 'package:aves/utils/file_utils.dart';
|
||||||
import 'package:aves/widgets/common/identity/aves_expansion_tile.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<List<AddressDetails>> _dbAddressLoader;
|
||||||
late Future<Set<FavouriteRow>> _dbFavouritesLoader;
|
late Future<Set<FavouriteRow>> _dbFavouritesLoader;
|
||||||
late Future<Set<CoverRow>> _dbCoversLoader;
|
late Future<Set<CoverRow>> _dbCoversLoader;
|
||||||
|
late Future<Set<VideoPlaybackRow>> _dbVideoPlaybackLoader;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
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() {
|
void _startDbReport() {
|
||||||
_dbFileSizeLoader = metadataDb.dbFileSize();
|
_dbFileSizeLoader = metadataDb.dbFileSize();
|
||||||
_dbEntryLoader = metadataDb.loadEntries();
|
_dbEntryLoader = metadataDb.loadAllEntries();
|
||||||
_dbDateLoader = metadataDb.loadDates();
|
_dbDateLoader = metadataDb.loadDates();
|
||||||
_dbMetadataLoader = metadataDb.loadMetadataEntries();
|
_dbMetadataLoader = metadataDb.loadAllMetadataEntries();
|
||||||
_dbAddressLoader = metadataDb.loadAddresses();
|
_dbAddressLoader = metadataDb.loadAllAddresses();
|
||||||
_dbFavouritesLoader = metadataDb.loadFavourites();
|
_dbFavouritesLoader = metadataDb.loadAllFavourites();
|
||||||
_dbCoversLoader = metadataDb.loadCovers();
|
_dbCoversLoader = metadataDb.loadAllCovers();
|
||||||
|
_dbVideoPlaybackLoader = metadataDb.loadAllVideoPlayback();
|
||||||
setState(() {});
|
setState(() {});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
import 'package:aves/model/entry.dart';
|
import 'package:aves/model/entry.dart';
|
||||||
import 'package:aves/model/metadata/address.dart';
|
import 'package:aves/model/metadata/address.dart';
|
||||||
import 'package:aves/model/metadata/catalog.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/services/common/services.dart';
|
||||||
import 'package:aves/widgets/viewer/info/common.dart';
|
import 'package:aves/widgets/viewer/info/common.dart';
|
||||||
import 'package:collection/collection.dart';
|
import 'package:collection/collection.dart';
|
||||||
|
@ -23,6 +24,7 @@ class _DbTabState extends State<DbTab> {
|
||||||
late Future<AvesEntry?> _dbEntryLoader;
|
late Future<AvesEntry?> _dbEntryLoader;
|
||||||
late Future<CatalogMetadata?> _dbMetadataLoader;
|
late Future<CatalogMetadata?> _dbMetadataLoader;
|
||||||
late Future<AddressDetails?> _dbAddressLoader;
|
late Future<AddressDetails?> _dbAddressLoader;
|
||||||
|
late Future<VideoPlaybackRow?> _dbVideoPlaybackLoader;
|
||||||
|
|
||||||
AvesEntry get entry => widget.entry;
|
AvesEntry get entry => widget.entry;
|
||||||
|
|
||||||
|
@ -35,9 +37,10 @@ class _DbTabState extends State<DbTab> {
|
||||||
void _loadDatabase() {
|
void _loadDatabase() {
|
||||||
final contentId = entry.contentId;
|
final contentId = entry.contentId;
|
||||||
_dbDateLoader = metadataDb.loadDates().then((values) => values[contentId]);
|
_dbDateLoader = metadataDb.loadDates().then((values) => values[contentId]);
|
||||||
_dbEntryLoader = metadataDb.loadEntries().then((values) => values.firstWhereOrNull((row) => row.contentId == contentId));
|
_dbEntryLoader = metadataDb.loadAllEntries().then((values) => values.firstWhereOrNull((row) => row.contentId == contentId));
|
||||||
_dbMetadataLoader = metadataDb.loadMetadataEntries().then((values) => values.firstWhereOrNull((row) => row.contentId == contentId));
|
_dbMetadataLoader = metadataDb.loadAllMetadataEntries().then((values) => values.firstWhereOrNull((row) => row.contentId == contentId));
|
||||||
_dbAddressLoader = metadataDb.loadAddresses().then((values) => values.firstWhereOrNull((row) => row.contentId == contentId));
|
_dbAddressLoader = metadataDb.loadAllAddresses().then((values) => values.firstWhereOrNull((row) => row.contentId == contentId));
|
||||||
|
_dbVideoPlaybackLoader = metadataDb.loadVideoPlayback(contentId);
|
||||||
setState(() {});
|
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(() {});
|
setState(() {});
|
||||||
|
|
||||||
if (settings.enableVideoAutoPlay) {
|
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
|
// video decoding may fail or have initial artifacts when the player initializes
|
||||||
// during this widget initialization (because of the page transition and hero animation?)
|
// during this widget initialization (because of the page transition and hero animation?)
|
||||||
// so we play after a delay for increased stability
|
// so we play after a delay for increased stability
|
||||||
await Future.delayed(const Duration(milliseconds: 300) * timeDilation);
|
await Future.delayed(const Duration(milliseconds: 300) * timeDilation);
|
||||||
|
|
||||||
await videoController.play();
|
if (resumeTimeMillis != null) {
|
||||||
|
await videoController.seekTo(resumeTimeMillis);
|
||||||
|
} else {
|
||||||
|
await videoController.play();
|
||||||
|
}
|
||||||
|
|
||||||
// playing controllers are paused when the entry changes,
|
// playing controllers are paused when the entry changes,
|
||||||
// but the controller may still be preparing (not yet playing) when this happens
|
// but the controller may still be preparing (not yet playing) when this happens
|
||||||
|
|
|
@ -1,6 +1,11 @@
|
||||||
import 'dart:typed_data';
|
import 'dart:typed_data';
|
||||||
|
|
||||||
import 'package:aves/model/entry.dart';
|
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/foundation.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
@ -11,7 +16,65 @@ abstract class AvesVideoController {
|
||||||
|
|
||||||
AvesVideoController(AvesEntry entry) : _entry = entry;
|
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();
|
Future<void> play();
|
||||||
|
|
||||||
|
|
|
@ -69,6 +69,7 @@ class IjkPlayerAvesVideoController extends AvesVideoController {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> dispose() async {
|
Future<void> dispose() async {
|
||||||
|
await super.dispose();
|
||||||
_initialPlayTimer?.cancel();
|
_initialPlayTimer?.cancel();
|
||||||
_stopListening();
|
_stopListening();
|
||||||
await _valueStreamController.close();
|
await _valueStreamController.close();
|
||||||
|
|
|
@ -185,7 +185,12 @@ class VideoActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix
|
||||||
if (controller.isPlaying) {
|
if (controller.isPlaying) {
|
||||||
await controller.pause();
|
await controller.pause();
|
||||||
} else {
|
} else {
|
||||||
await controller.play();
|
final resumeTimeMillis = await controller.getResumeTime(context);
|
||||||
|
if (resumeTimeMillis != null) {
|
||||||
|
await controller.seekTo(resumeTimeMillis);
|
||||||
|
} else {
|
||||||
|
await controller.play();
|
||||||
|
}
|
||||||
// hide overlay
|
// hide overlay
|
||||||
_overlayHidingTimer = Timer(context.read<DurationsData>().iconAnimation + Durations.videoOverlayHideDelay, () {
|
_overlayHidingTimer = Timer(context.read<DurationsData>().iconAnimation + Durations.videoOverlayHideDelay, () {
|
||||||
const ToggleOverlayNotification(visible: false).dispatch(context);
|
const ToggleOverlayNotification(visible: false).dispatch(context);
|
||||||
|
|
|
@ -15,8 +15,10 @@ class FakeMetadataDb extends Fake implements MetadataDb {
|
||||||
@override
|
@override
|
||||||
Future<void> removeIds(Set<int> contentIds, {required bool metadataOnly}) => SynchronousFuture(null);
|
Future<void> removeIds(Set<int> contentIds, {required bool metadataOnly}) => SynchronousFuture(null);
|
||||||
|
|
||||||
|
// entries
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<Set<AvesEntry>> loadEntries() => SynchronousFuture({});
|
Future<Set<AvesEntry>> loadAllEntries() => SynchronousFuture({});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> saveEntries(Iterable<AvesEntry> entries) => SynchronousFuture(null);
|
Future<void> saveEntries(Iterable<AvesEntry> entries) => SynchronousFuture(null);
|
||||||
|
@ -24,11 +26,15 @@ class FakeMetadataDb extends Fake implements MetadataDb {
|
||||||
@override
|
@override
|
||||||
Future<void> updateEntryId(int oldId, AvesEntry entry) => SynchronousFuture(null);
|
Future<void> updateEntryId(int oldId, AvesEntry entry) => SynchronousFuture(null);
|
||||||
|
|
||||||
|
// date taken
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<Map<int?, int?>> loadDates() => SynchronousFuture({});
|
Future<Map<int?, int?>> loadDates() => SynchronousFuture({});
|
||||||
|
|
||||||
|
// catalog metadata
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<List<CatalogMetadata>> loadMetadataEntries() => SynchronousFuture([]);
|
Future<List<CatalogMetadata>> loadAllMetadataEntries() => SynchronousFuture([]);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> saveMetadata(Set<CatalogMetadata> metadataEntries) => SynchronousFuture(null);
|
Future<void> saveMetadata(Set<CatalogMetadata> metadataEntries) => SynchronousFuture(null);
|
||||||
|
@ -36,8 +42,10 @@ class FakeMetadataDb extends Fake implements MetadataDb {
|
||||||
@override
|
@override
|
||||||
Future<void> updateMetadataId(int oldId, CatalogMetadata? metadata) => SynchronousFuture(null);
|
Future<void> updateMetadataId(int oldId, CatalogMetadata? metadata) => SynchronousFuture(null);
|
||||||
|
|
||||||
|
// address
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<List<AddressDetails>> loadAddresses() => SynchronousFuture([]);
|
Future<List<AddressDetails>> loadAllAddresses() => SynchronousFuture([]);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> saveAddresses(Set<AddressDetails> addresses) => SynchronousFuture(null);
|
Future<void> saveAddresses(Set<AddressDetails> addresses) => SynchronousFuture(null);
|
||||||
|
@ -45,8 +53,10 @@ class FakeMetadataDb extends Fake implements MetadataDb {
|
||||||
@override
|
@override
|
||||||
Future<void> updateAddressId(int oldId, AddressDetails? address) => SynchronousFuture(null);
|
Future<void> updateAddressId(int oldId, AddressDetails? address) => SynchronousFuture(null);
|
||||||
|
|
||||||
|
// favourites
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<Set<FavouriteRow>> loadFavourites() => SynchronousFuture({});
|
Future<Set<FavouriteRow>> loadAllFavourites() => SynchronousFuture({});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> addFavourites(Iterable<FavouriteRow> rows) => SynchronousFuture(null);
|
Future<void> addFavourites(Iterable<FavouriteRow> rows) => SynchronousFuture(null);
|
||||||
|
@ -57,8 +67,10 @@ class FakeMetadataDb extends Fake implements MetadataDb {
|
||||||
@override
|
@override
|
||||||
Future<void> removeFavourites(Iterable<FavouriteRow> rows) => SynchronousFuture(null);
|
Future<void> removeFavourites(Iterable<FavouriteRow> rows) => SynchronousFuture(null);
|
||||||
|
|
||||||
|
// covers
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<Set<CoverRow>> loadCovers() => SynchronousFuture({});
|
Future<Set<CoverRow>> loadAllCovers() => SynchronousFuture({});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> addCovers(Iterable<CoverRow> rows) => SynchronousFuture(null);
|
Future<void> addCovers(Iterable<CoverRow> rows) => SynchronousFuture(null);
|
||||||
|
|
|
@ -2,6 +2,9 @@
|
||||||
"ko": [
|
"ko": [
|
||||||
"unsupportedTypeDialogTitle",
|
"unsupportedTypeDialogTitle",
|
||||||
"unsupportedTypeDialogMessage",
|
"unsupportedTypeDialogMessage",
|
||||||
|
"videoResumeDialogMessage",
|
||||||
|
"videoStartOverButtonLabel",
|
||||||
|
"videoResumeButtonLabel",
|
||||||
"editEntryDateDialogExtractFromTitle",
|
"editEntryDateDialogExtractFromTitle",
|
||||||
"collectionActionShowTitleSearch",
|
"collectionActionShowTitleSearch",
|
||||||
"collectionActionHideTitleSearch",
|
"collectionActionHideTitleSearch",
|
||||||
|
@ -16,6 +19,9 @@
|
||||||
"ru": [
|
"ru": [
|
||||||
"unsupportedTypeDialogTitle",
|
"unsupportedTypeDialogTitle",
|
||||||
"unsupportedTypeDialogMessage",
|
"unsupportedTypeDialogMessage",
|
||||||
|
"videoResumeDialogMessage",
|
||||||
|
"videoStartOverButtonLabel",
|
||||||
|
"videoResumeButtonLabel",
|
||||||
"editEntryDateDialogExtractFromTitle",
|
"editEntryDateDialogExtractFromTitle",
|
||||||
"aboutLinkPolicy",
|
"aboutLinkPolicy",
|
||||||
"aboutCreditsTranslators",
|
"aboutCreditsTranslators",
|
||||||
|
|
Loading…
Reference in a new issue