diff --git a/lib/core/storage/media_library_store.dart b/lib/core/storage/media_library_store.dart index e75faa2..6a13864 100644 --- a/lib/core/storage/media_library_store.dart +++ b/lib/core/storage/media_library_store.dart @@ -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 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 _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 _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 _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 row) { return MediaLibraryItem.fromJson({ 'libraryID': row['library_id'], diff --git a/lib/pages/workspace_page.dart b/lib/pages/workspace_page.dart index ab3a0e5..e0a2e58 100644 --- a/lib/pages/workspace_page.dart +++ b/lib/pages/workspace_page.dart @@ -257,44 +257,42 @@ class _WorkspacePageState extends ConsumerState { 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 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 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.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 {