migrate media artwork storage and mobile drawer navigation
This commit is contained in:
@@ -21,15 +21,19 @@ class MediaLibraryStore {
|
||||
);
|
||||
_database = await openDatabase(
|
||||
databasePath,
|
||||
version: 4,
|
||||
version: 5,
|
||||
onCreate: (db, _) async {
|
||||
await _createSchema(db);
|
||||
},
|
||||
onUpgrade: (db, _, _) async {
|
||||
onUpgrade: (db, oldVersion, _) async {
|
||||
await _createSchema(db);
|
||||
if (oldVersion < 5) {
|
||||
await _migrateArtworkBlobSchema(db);
|
||||
}
|
||||
},
|
||||
);
|
||||
await _createSchema(_database!);
|
||||
await _vacuumIfFragmented(_database!);
|
||||
return _database!;
|
||||
}
|
||||
|
||||
@@ -211,7 +215,7 @@ class MediaLibraryStore {
|
||||
INSERT OR REPLACE INTO media_items (
|
||||
library_id, file_id, resource_path, cloud_name, file_size, gcid,
|
||||
file_type, tmdb_id, media_kind, title, original_title,
|
||||
release_date, overview, poster_path, backdrop_path, poster, backdrop,
|
||||
release_date, overview, poster_path, backdrop_path,
|
||||
has_chinese_audio, has_chinese_subtitle, collection_id,
|
||||
collection_name, updated_at
|
||||
)
|
||||
@@ -219,7 +223,7 @@ class MediaLibraryStore {
|
||||
library_id, file_id, resource_path, cloud_name, file_size, gcid,
|
||||
file_type, tmdb_id, media_kind, title, original_title,
|
||||
release_date, overview, $importedPosterPath, $importedBackdropPath,
|
||||
NULL, NULL, has_chinese_audio, has_chinese_subtitle, collection_id,
|
||||
has_chinese_audio, has_chinese_subtitle, collection_id,
|
||||
collection_name, updated_at
|
||||
FROM imported_backup.media_items
|
||||
''');
|
||||
@@ -235,9 +239,14 @@ class MediaLibraryStore {
|
||||
Future<MediaLibraryStorageStats> optimizeStorage() async {
|
||||
final db = await _db;
|
||||
final before = await _databaseBytes(db.path);
|
||||
final removedArtwork = await db.rawUpdate('''UPDATE media_items
|
||||
SET poster = NULL, backdrop = NULL
|
||||
WHERE poster IS NOT NULL OR backdrop IS NOT NULL''');
|
||||
final columns = await _tableColumns(db, 'main', 'media_items');
|
||||
final hasLegacyArtwork =
|
||||
columns.contains('poster') || columns.contains('backdrop');
|
||||
final removedArtwork = hasLegacyArtwork
|
||||
? await db.rawUpdate('''UPDATE media_items
|
||||
SET poster = NULL, backdrop = NULL
|
||||
WHERE poster IS NOT NULL OR backdrop IS NOT NULL''')
|
||||
: 0;
|
||||
try {
|
||||
await db.execute('PRAGMA wal_checkpoint(TRUNCATE)');
|
||||
} on DatabaseException catch (error) {
|
||||
@@ -548,8 +557,6 @@ class MediaLibraryStore {
|
||||
overview TEXT NOT NULL,
|
||||
poster_path TEXT,
|
||||
backdrop_path TEXT,
|
||||
poster BLOB,
|
||||
backdrop BLOB,
|
||||
has_chinese_audio INTEGER NOT NULL DEFAULT 0,
|
||||
has_chinese_subtitle INTEGER NOT NULL DEFAULT 0,
|
||||
collection_id INTEGER,
|
||||
@@ -592,6 +599,90 @@ class MediaLibraryStore {
|
||||
await _ensureColumn(db, 'media_items', 'backdrop_path', 'TEXT');
|
||||
}
|
||||
|
||||
Future<void> _migrateArtworkBlobSchema(Database db) async {
|
||||
final columns = await _tableColumns(db, 'main', 'media_items');
|
||||
if (!columns.contains('poster') && !columns.contains('backdrop')) return;
|
||||
await db.transaction((txn) async {
|
||||
await txn.execute('''
|
||||
CREATE TABLE media_items_compact (
|
||||
library_id TEXT NOT NULL,
|
||||
file_id TEXT NOT NULL,
|
||||
resource_path TEXT NOT NULL,
|
||||
cloud_name TEXT NOT NULL,
|
||||
file_size INTEGER,
|
||||
gcid TEXT,
|
||||
file_type INTEGER NOT NULL,
|
||||
tmdb_id INTEGER,
|
||||
media_kind TEXT,
|
||||
title TEXT NOT NULL,
|
||||
original_title TEXT NOT NULL,
|
||||
release_date TEXT NOT NULL,
|
||||
overview TEXT NOT NULL,
|
||||
poster_path TEXT,
|
||||
backdrop_path TEXT,
|
||||
has_chinese_audio INTEGER NOT NULL DEFAULT 0,
|
||||
has_chinese_subtitle INTEGER NOT NULL DEFAULT 0,
|
||||
collection_id INTEGER,
|
||||
collection_name TEXT,
|
||||
updated_at REAL NOT NULL,
|
||||
PRIMARY KEY (library_id, file_id),
|
||||
FOREIGN KEY (library_id) REFERENCES media_libraries(id) ON DELETE CASCADE
|
||||
)
|
||||
''');
|
||||
await txn.execute('''
|
||||
INSERT INTO media_items_compact (
|
||||
library_id, file_id, resource_path, cloud_name, file_size, gcid,
|
||||
file_type, tmdb_id, media_kind, title, original_title, release_date,
|
||||
overview, poster_path, backdrop_path, has_chinese_audio,
|
||||
has_chinese_subtitle, collection_id, collection_name, updated_at
|
||||
)
|
||||
SELECT
|
||||
library_id, file_id, resource_path, cloud_name, file_size, gcid,
|
||||
file_type, tmdb_id, media_kind, title, original_title, release_date,
|
||||
overview, poster_path, backdrop_path, has_chinese_audio,
|
||||
has_chinese_subtitle, collection_id, collection_name, updated_at
|
||||
FROM media_items
|
||||
''');
|
||||
await txn.execute('DROP TABLE media_items');
|
||||
await txn.execute(
|
||||
'ALTER TABLE media_items_compact RENAME TO media_items',
|
||||
);
|
||||
await _createMediaItemIndexes(txn);
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _vacuumIfFragmented(Database db) async {
|
||||
final pageCount = await db.rawQuery('PRAGMA page_count');
|
||||
final freeList = await db.rawQuery('PRAGMA freelist_count');
|
||||
final pages = _asInt(pageCount.firstOrNull?['page_count']) ?? 0;
|
||||
final free = _asInt(freeList.firstOrNull?['freelist_count']) ?? 0;
|
||||
if (pages == 0 || free < 1024 || free / pages < 0.2) return;
|
||||
try {
|
||||
await db.execute('PRAGMA wal_checkpoint(TRUNCATE)');
|
||||
await db.execute('VACUUM');
|
||||
} on DatabaseException {
|
||||
// A failed optional compaction never prevents users from reading media.
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _createMediaItemIndexes(DatabaseExecutor db) async {
|
||||
await db.execute(
|
||||
'CREATE INDEX IF NOT EXISTS idx_media_items_library_title '
|
||||
'ON media_items(library_id, title COLLATE NOCASE)',
|
||||
);
|
||||
await db.execute(
|
||||
'CREATE INDEX IF NOT EXISTS idx_media_items_tmdb_id '
|
||||
'ON media_items(tmdb_id)',
|
||||
);
|
||||
await db.execute(
|
||||
'CREATE INDEX IF NOT EXISTS idx_media_items_gcid ON media_items(gcid)',
|
||||
);
|
||||
await db.execute(
|
||||
'CREATE INDEX IF NOT EXISTS idx_media_items_collection '
|
||||
'ON media_items(library_id, collection_id)',
|
||||
);
|
||||
}
|
||||
|
||||
MediaLibraryItem _itemFromRow(Map<String, Object?> row) {
|
||||
return MediaLibraryItem.fromJson({
|
||||
'libraryID': row['library_id'],
|
||||
|
||||
+73
-115
@@ -257,44 +257,42 @@ class _WorkspacePageState extends ConsumerState<WorkspacePage> {
|
||||
onSearch: _submitSearch,
|
||||
onToggleSearch: _toggleSearch,
|
||||
onSettings: () => _showSettings(context),
|
||||
onOpenMenu: () => _showMobileMenu(context, auth),
|
||||
);
|
||||
final content = IndexedStack(
|
||||
index: _mode == WorkspaceMode.cloud ? 0 : 1,
|
||||
children: [_buildCloudContent(fp), _buildMediaContent()],
|
||||
);
|
||||
if (compact) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(8, 8, 8, 6),
|
||||
child: Column(
|
||||
children: [
|
||||
topBar,
|
||||
const SizedBox(height: 8),
|
||||
Expanded(
|
||||
child: Stack(
|
||||
children: [
|
||||
Positioned.fill(child: content),
|
||||
Positioned(
|
||||
right: 4,
|
||||
bottom: 8,
|
||||
child: _MobileFloatingSearch(
|
||||
mode: _mode,
|
||||
searchController: _searchController,
|
||||
searchFocusNode: _searchFocusNode,
|
||||
searchOpen: _searchOpen,
|
||||
onSearch: _submitSearch,
|
||||
onToggleSearch: _toggleSearch,
|
||||
return _MobileDrawerSwipeArea(
|
||||
onOpen: () => _showMobileMenu(context, auth),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(8, 8, 8, 8),
|
||||
child: Column(
|
||||
children: [
|
||||
topBar,
|
||||
const SizedBox(height: 8),
|
||||
Expanded(
|
||||
child: Stack(
|
||||
children: [
|
||||
Positioned.fill(child: content),
|
||||
Positioned(
|
||||
right: 4,
|
||||
bottom: 8,
|
||||
child: _MobileFloatingSearch(
|
||||
mode: _mode,
|
||||
searchController: _searchController,
|
||||
searchFocusNode: _searchFocusNode,
|
||||
searchOpen: _searchOpen,
|
||||
onSearch: _submitSearch,
|
||||
onToggleSearch: _toggleSearch,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_MobileWorkspaceNavigation(
|
||||
mode: _mode,
|
||||
onModeChanged: _changeMode,
|
||||
onMore: () => _showMobileMenu(context, auth),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -517,6 +515,7 @@ class _TopBar extends StatelessWidget {
|
||||
final ValueChanged<String> onSearch;
|
||||
final VoidCallback onToggleSearch;
|
||||
final VoidCallback onSettings;
|
||||
final VoidCallback onOpenMenu;
|
||||
|
||||
const _TopBar({
|
||||
required this.mode,
|
||||
@@ -528,6 +527,7 @@ class _TopBar extends StatelessWidget {
|
||||
required this.onSearch,
|
||||
required this.onToggleSearch,
|
||||
required this.onSettings,
|
||||
required this.onOpenMenu,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -561,9 +561,9 @@ class _TopBar extends StatelessWidget {
|
||||
onTap: () => onModeChanged(WorkspaceMode.media),
|
||||
),
|
||||
_TopBarIconButton(
|
||||
tooltip: '设置',
|
||||
icon: Icons.settings_rounded,
|
||||
onTap: onSettings,
|
||||
tooltip: '打开菜单',
|
||||
icon: Icons.menu_rounded,
|
||||
onTap: onOpenMenu,
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -834,98 +834,56 @@ class _MobileFloatingSearch extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class _MobileWorkspaceNavigation extends StatelessWidget {
|
||||
final WorkspaceMode mode;
|
||||
final ValueChanged<WorkspaceMode> onModeChanged;
|
||||
final VoidCallback onMore;
|
||||
class _MobileDrawerSwipeArea extends StatefulWidget {
|
||||
final Widget child;
|
||||
final VoidCallback onOpen;
|
||||
|
||||
const _MobileWorkspaceNavigation({
|
||||
required this.mode,
|
||||
required this.onModeChanged,
|
||||
required this.onMore,
|
||||
});
|
||||
const _MobileDrawerSwipeArea({required this.child, required this.onOpen});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return OS26Glass(
|
||||
radius: 14,
|
||||
opacity: 0.68,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _MobileNavItem(
|
||||
icon: Icons.folder_rounded,
|
||||
label: '文件',
|
||||
selected: mode == WorkspaceMode.cloud,
|
||||
onTap: () => onModeChanged(WorkspaceMode.cloud),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: _MobileNavItem(
|
||||
icon: Icons.movie_rounded,
|
||||
label: '影视',
|
||||
selected: mode == WorkspaceMode.media,
|
||||
onTap: () => onModeChanged(WorkspaceMode.media),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: _MobileNavItem(
|
||||
icon: Icons.more_horiz_rounded,
|
||||
label: '更多',
|
||||
selected: false,
|
||||
onTap: onMore,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
State<_MobileDrawerSwipeArea> createState() => _MobileDrawerSwipeAreaState();
|
||||
}
|
||||
|
||||
class _MobileNavItem extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final String label;
|
||||
final bool selected;
|
||||
final VoidCallback onTap;
|
||||
class _MobileDrawerSwipeAreaState extends State<_MobileDrawerSwipeArea> {
|
||||
var _distance = 0.0;
|
||||
var _opening = false;
|
||||
|
||||
const _MobileNavItem({
|
||||
required this.icon,
|
||||
required this.label,
|
||||
required this.selected,
|
||||
required this.onTap,
|
||||
});
|
||||
void _onDragUpdate(DragUpdateDetails details) {
|
||||
if (_opening) return;
|
||||
final delta = details.primaryDelta ?? 0;
|
||||
if (delta <= 0) {
|
||||
_distance = 0;
|
||||
return;
|
||||
}
|
||||
_distance += delta;
|
||||
if (_distance >= 28) {
|
||||
_opening = true;
|
||||
_distance = 0;
|
||||
widget.onOpen();
|
||||
Future<void>.delayed(const Duration(milliseconds: 300), () {
|
||||
if (mounted) _opening = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final cs = ShadTheme.of(context).colorScheme;
|
||||
return InkWell(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
onTap: onTap,
|
||||
child: SizedBox(
|
||||
height: 48,
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
icon,
|
||||
size: 19,
|
||||
color: selected ? cs.primary : cs.mutedForeground,
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: selected ? FontWeight.w700 : FontWeight.w500,
|
||||
color: selected ? cs.primary : cs.mutedForeground,
|
||||
),
|
||||
),
|
||||
],
|
||||
Widget build(BuildContext context) => Stack(
|
||||
children: [
|
||||
widget.child,
|
||||
Positioned(
|
||||
left: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
width: 28,
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.translucent,
|
||||
onHorizontalDragUpdate: _onDragUpdate,
|
||||
onHorizontalDragEnd: (_) => _distance = 0,
|
||||
onHorizontalDragCancel: () => _distance = 0,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
class _MobileWorkspaceMenu extends StatelessWidget {
|
||||
|
||||
Reference in New Issue
Block a user