migrate media artwork storage and mobile drawer navigation

This commit is contained in:
ngfchl
2026-07-19 11:30:36 +08:00
parent 8481276954
commit d762a1e4cc
2 changed files with 173 additions and 124 deletions
+100 -9
View File
@@ -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
View File
@@ -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 {