diff --git a/lib/api/client.dart b/lib/api/client.dart index 7218357..fa6ec39 100644 --- a/lib/api/client.dart +++ b/lib/api/client.dart @@ -210,6 +210,36 @@ class EHApi extends __EHApi { return _getTags(ids.join(","), cancel: cancel); } + String getFileUrl(int id) { + final uri = Uri.parse(_combineBaseUrls(_dio.options.baseUrl, baseUrl)); + final newUri = uri.resolve("file/$id"); + return newUri.toString(); + } + + String getThumbnailUrl(int id, + {int? max, + int? width, + int? height, + int? quality, + bool? force, + ThumbnailMethod? method, + ThumbnailAlign? align}) { + final uri = Uri.parse(_combineBaseUrls(_dio.options.baseUrl, baseUrl)); + final queryParameters = { + r'max': max, + r'width': width, + r'height': height, + r'quality': quality, + r'force': force, + r'method': method?.name, + r'align': align?.name, + }; + queryParameters.removeWhere((k, v) => v == null); + final newUri = + uri.resolve("thumbnail/$id").replace(queryParameters: queryParameters); + return newUri.toString(); + } + String exportGalleryZipUrl(int gid, {bool? jpnTitle, int? maxLength, bool? exportAd}) { final uri = Uri.parse(_combineBaseUrls(_dio.options.baseUrl, baseUrl)); diff --git a/lib/components/gallery_basic_info.dart b/lib/components/gallery_basic_info.dart index 872e66c..fa343f1 100644 --- a/lib/components/gallery_basic_info.dart +++ b/lib/components/gallery_basic_info.dart @@ -38,7 +38,9 @@ class GalleryBasicInfo extends StatelessWidget { mainAxisAlignment: MainAxisAlignment.spaceAround, children: [ ElevatedButton( - onPressed: null, + onPressed: () { + context.push('/gallery/${gMeta.gid}/page/1'); + }, child: Text(AppLocalizations.of(context)!.read)), ElevatedButton( onPressed: () { diff --git a/lib/components/gallery_info.dart b/lib/components/gallery_info.dart index bf10a57..2b5f128 100644 --- a/lib/components/gallery_info.dart +++ b/lib/components/gallery_info.dart @@ -68,7 +68,7 @@ class _GalleryInfo extends State with ThemeModeWidget { ]), ), ThumbnailGridView( - widget.gData.pages, + widget.gData, SliverGridDelegateWithFixedCrossAxisCount( crossAxisCount: useMobile ? 2 : 5), files: widget.files, diff --git a/lib/components/gallery_info_desktop.dart b/lib/components/gallery_info_desktop.dart index 11af656..9219177 100644 --- a/lib/components/gallery_info_desktop.dart +++ b/lib/components/gallery_info_desktop.dart @@ -131,6 +131,13 @@ class GalleryInfoDesktop extends StatelessWidget { SizedBox( width: 150, child: Column(children: [ + ElevatedButton( + onPressed: () { + context.push( + '/gallery/${gData.meta.gid}/page/1'); + }, + child: + Text(AppLocalizations.of(context)!.read)), ElevatedButton( onPressed: () { context.push( diff --git a/lib/components/thumbnail.dart b/lib/components/thumbnail.dart index 55e8a74..ec9f595 100644 --- a/lib/components/thumbnail.dart +++ b/lib/components/thumbnail.dart @@ -3,21 +3,32 @@ import 'dart:ui'; import 'package:dio/dio.dart'; import 'package:flutter/material.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:go_router/go_router.dart'; import 'package:logging/logging.dart'; import 'package:path/path.dart'; import 'package:palette_generator/palette_generator.dart'; import '../api/client.dart'; +import '../api/file.dart'; import '../api/gallery.dart'; import '../globals.dart'; import '../utils.dart'; import '../utils/clipboard.dart'; +import '../viewer/single.dart'; import 'image.dart'; final _log = Logger("Thumbnail"); class Thumbnail extends StatefulWidget { const Thumbnail(ExtendedPMeta pMeta, - {Key? key, int? max, int? width, int? height, int? fileId, this.gid}) + {Key? key, + int? max, + int? width, + int? height, + int? fileId, + this.gid, + this.index, + this.files, + this.gdata}) : _pMeta = pMeta, _max = max ?? 1200, _width = width, @@ -30,6 +41,9 @@ class Thumbnail extends StatefulWidget { final int? _height; final int? _fileId; final int? gid; + final int? index; + final EhFiles? files; + final GalleryData? gdata; int get height => _height != null ? _height! @@ -223,6 +237,19 @@ class _Thumbnail extends State { ]; return list; })); + final timg = _data != null + ? ImageWithContextMenu(_data!, + uri: _uri, fileName: _fileName, dir: _dir) + : null; + final img = widget.gid != null && widget.index != null && _data != null + ? GestureDetector( + onTap: () { + context.push("/gallery/${widget.gid}/page/${widget.index}", + extra: SinglePageViewerExtra( + data: widget.gdata, files: widget.files)); + }, + child: timg) + : timg; return SizedBox( width: widget.width.toDouble(), height: widget.height.toDouble(), @@ -240,10 +267,7 @@ class _Thumbnail extends State { sigmaX: 10, sigmaY: 10, tileMode: TileMode.decal), - child: ImageWithContextMenu(_data!, - uri: _uri, - fileName: _fileName, - dir: _dir))), + child: img)), SizedBox( width: widget.width.toDouble(), height: widget.height.toDouble(), @@ -265,8 +289,7 @@ class _Thumbnail extends State { SizedBox( width: widget.width.toDouble(), height: widget.height.toDouble(), - child: ImageWithContextMenu(_data!, - uri: _uri, fileName: _fileName, dir: _dir)), + child: img), moreVertMenu ]) : Center( diff --git a/lib/components/thumbnail_gridview.dart b/lib/components/thumbnail_gridview.dart index 9cbc15c..6a2d53d 100644 --- a/lib/components/thumbnail_gridview.dart +++ b/lib/components/thumbnail_gridview.dart @@ -5,10 +5,10 @@ import '../globals.dart'; import 'thumbnail.dart'; class ThumbnailGridView extends StatelessWidget { - const ThumbnailGridView(this.pages, this.gridDelegate, + const ThumbnailGridView(this.gdata, this.gridDelegate, {Key? key, this.files, this.gid}) : super(key: key); - final List pages; + final GalleryData gdata; final int? gid; final EhFiles? files; final SliverGridDelegate gridDelegate; @@ -16,7 +16,8 @@ class ThumbnailGridView extends StatelessWidget { @override Widget build(BuildContext context) { final displayAd = prefs.getBool("displayAd") ?? false; - final npages = displayAd ? pages : pages.where((e) => !e.isAd).toList(); + final npages = + displayAd ? gdata.pages : gdata.pages.where((e) => !e.isAd).toList(); return SliverGrid.builder( gridDelegate: gridDelegate, itemCount: npages.length, @@ -26,7 +27,12 @@ class ThumbnailGridView extends StatelessWidget { files != null ? files!.files[page.token]!.first.id : null; return Container( padding: const EdgeInsets.all(4), - child: Thumbnail(page, fileId: fileId, gid: gid)); + child: Thumbnail(page, + fileId: fileId, + gid: gid, + index: index + 1, + files: files, + gdata: gdata)); }); } } diff --git a/lib/main.dart b/lib/main.dart index c5c0c3b..df52018 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -19,6 +19,7 @@ import 'logs/file.dart'; import 'set_server.dart'; import 'settings.dart'; import 'utils.dart'; +import 'viewer/single.dart'; final _routerLog = Logger("Router"); @@ -83,7 +84,7 @@ final _router = GoRouter( return null; } catch (e) { _routerLog.warning("Failed to parse gid:", e); - return "/"; + return "/gallery"; } }), GoRoute( @@ -106,6 +107,33 @@ final _router = GoRouter( return "/"; } }), + GoRoute( + path: SinglePageViewer.routeName, + builder: (context, state) { + final extra = state.extra as SinglePageViewerExtra?; + return SinglePageViewer( + gid: int.parse(state.pathParameters["gid"]!), + index: int.parse(state.pathParameters["index"]!), + key: state.pageKey, + data: extra?.data, + files: extra?.files, + ); + }, + redirect: (context, state) { + try { + int.parse(state.pathParameters["gid"]!); + } catch (e) { + _routerLog.warning("Failed to parse gid:", e); + return "/gallery"; + } + try { + int.parse(state.pathParameters["index"]!); + return null; + } catch (e) { + _routerLog.warning("Failed to parse index:", e); + return "/gallery/${state.pathParameters["gid"]}"; + } + }), ], ); diff --git a/lib/viewer/single.dart b/lib/viewer/single.dart new file mode 100644 index 0000000..587cac6 --- /dev/null +++ b/lib/viewer/single.dart @@ -0,0 +1,203 @@ +import 'package:dio/dio.dart'; +import 'package:dio_image_provider/dio_image_provider.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/scheduler.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:go_router/go_router.dart'; +import 'package:keymap/keymap.dart'; +import 'package:logging/logging.dart'; +import 'package:photo_view/photo_view.dart'; +import 'package:photo_view/photo_view_gallery.dart'; +import '../api/file.dart'; +import '../api/gallery.dart'; +import '../globals.dart'; + +final _log = Logger("SinglePageViewer"); + +class SinglePageViewerExtra { + const SinglePageViewerExtra({this.data, this.files}); + final GalleryData? data; + final EhFiles? files; +} + +class SinglePageViewer extends StatefulWidget { + const SinglePageViewer( + {Key? key, required this.gid, required this.index, this.data, this.files}) + : super(key: key); + final GalleryData? data; + final EhFiles? files; + final int gid; + final int index; + static const String routeName = '/gallery/:gid/page/:index'; + + @override + State createState() => _SinglePageViewer(); +} + +class _SinglePageViewer extends State with ThemeModeWidget { + late PageController _pageController; + late int _index; + late GalleryData? _data; + late EhFiles? _files; + CancelToken? _cancel; + bool _isLoading = false; + bool _page_changed = false; + Object? _error; + @override + void initState() { + _index = widget.index - 1; + _pageController = PageController(initialPage: _index); + _data = widget.data; + _files = widget.files; + super.initState(); + } + + @override + void dispose() { + _cancel?.cancel(); + _pageController.dispose(); + super.dispose(); + } + + Future _fetchData() async { + try { + _cancel = CancelToken(); + _isLoading = true; + if (_data == null) { + final data = (await api.getGallery(widget.gid)).unwrap(); + _data = data; + } + final fileData = (await api.getFiles( + _data!.pages.map((e) => e.token).toList(), + cancel: _cancel)) + .unwrap(); + if (!_cancel!.isCancelled) { + if (_index < 0) { + _index = 0; + _pageController.jumpToPage(_index); + _page_changed = true; + } else if (_index >= _data!.pages.length) { + _index = _data!.pages.length - 1; + _pageController.jumpToPage(_index); + _page_changed = true; + } + setState(() { + _files = fileData; + _isLoading = false; + }); + } + } catch (e) { + if (!_cancel!.isCancelled) { + _log.severe("Failed to load gallery ${widget.gid}:", e); + setState(() { + _error = e; + _isLoading = false; + }); + } + } + } + + void _onPageChanged(BuildContext context) { + context.replace("/gallery/${widget.gid}/page/${_index + 1}", + extra: SinglePageViewerExtra(data: _data, files: _files)); + _page_changed = false; + } + + @override + Widget build(BuildContext context) { + final isLoading = _error == null && (_data == null || _files == null); + if (isLoading && !_isLoading) _fetchData(); + if (_data == null || _files == null) { + return Scaffold( + appBar: AppBar( + leading: IconButton( + icon: const Icon(Icons.arrow_back), + onPressed: () { + context.canPop() ? context.pop() : context.go("/"); + }, + ), + title: Text(AppLocalizations.of(context)!.loading), + actions: [ + buildThemeModeIcon(context), + buildMoreVertSettingsButon(context), + ], + ), + body: isLoading + ? const Center(child: CircularProgressIndicator()) + : Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + SelectableText("Error $_error"), + ElevatedButton.icon( + onPressed: () { + _fetchData(); + setState(() { + _error = null; + }); + }, + icon: const Icon(Icons.refresh), + label: Text(AppLocalizations.of(context)!.retry)) + ]))); + } + if (_page_changed) { + _onPageChanged(context); + } + return Scaffold( + backgroundColor: Colors.black, + extendBody: true, + extendBodyBehindAppBar: true, + body: KeyboardWidget( + bindings: [ + KeyAction(LogicalKeyboardKey.arrowLeft, "previous page", () { + if (_index > 0) { + _index -= 1; + _pageController.previousPage( + duration: const Duration(milliseconds: 200), + curve: Curves.easeInOut); + } + }), + KeyAction(LogicalKeyboardKey.arrowRight, "next page", () { + if (_index < _data!.pages.length - 1) { + _index += 1; + _pageController.nextPage( + duration: const Duration(milliseconds: 200), + curve: Curves.easeInOut); + } + }), + KeyAction(LogicalKeyboardKey.backspace, "back", () { + context.canPop() ? context.pop() : context.go("/"); + }), + ], + child: PhotoViewGallery.builder( + scrollPhysics: const BouncingScrollPhysics(), + pageController: _pageController, + itemCount: _data!.pages.length, + builder: (BuildContext context, int index) { + final data = _data!.pages[index]; + final f = _files!.files[data.token]!.first; + return PhotoViewGalleryPageOptions( + imageProvider: DioImage.string( + api.getFileUrl(f.id), + dio: dio, + ), + initialScale: PhotoViewComputedScale.contained, + heroAttributes: PhotoViewHeroAttributes( + tag: data.token, + transitionOnUserGestures: true, + ), + filterQuality: FilterQuality.high, + ); + }, + onPageChanged: (index) { + _index = index; + SchedulerBinding.instance.addPostFrameCallback((_) { + _onPageChanged(context); + }); + }, + ), + ), + ); + } +} diff --git a/linux/CMakeLists.txt b/linux/CMakeLists.txt index 905f274..955bf49 100644 --- a/linux/CMakeLists.txt +++ b/linux/CMakeLists.txt @@ -101,9 +101,9 @@ if(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT) endif() # Start with a clean build bundle directory every time. -install(CODE " - file(REMOVE_RECURSE \"${BUILD_BUNDLE_DIR}/\") - " COMPONENT Runtime) +# install(CODE " +# file(REMOVE_RECURSE \"${BUILD_BUNDLE_DIR}/\") +# " COMPONENT Runtime) set(INSTALL_BUNDLE_DATA_DIR "${CMAKE_INSTALL_PREFIX}/data") set(INSTALL_BUNDLE_LIB_DIR "${CMAKE_INSTALL_PREFIX}/lib") diff --git a/pubspec.lock b/pubspec.lock index 55abd2e..3c7129a 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -242,6 +242,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.1.0+1" + dio_image_provider: + dependency: "direct main" + description: + name: dio_image_provider + sha256: d7937f2b33c52db70cbf6209cf6ef6a929e006afcadaebfb6b0dc2da80dce52b + url: "https://pub.dev" + source: hosted + version: "0.0.2" enum_flag: dependency: "direct main" description: @@ -316,6 +324,14 @@ packages: description: flutter source: sdk version: "0.0.0" + flutter_markdown: + dependency: transitive + description: + name: flutter_markdown + sha256: "35108526a233cc0755664d445f8a6b4b61e6f8fe993b3658b80b4a26827fc196" + url: "https://pub.dev" + source: hosted + version: "0.6.18+2" flutter_staggered_grid_view: dependency: transitive description: @@ -462,6 +478,14 @@ packages: url: "https://pub.dev" source: hosted version: "6.7.1" + keymap: + dependency: "direct main" + description: + name: keymap + sha256: "837f37bce794bb41c3a49cd122718544921afec348f34c78f6153c72b43f3c10" + url: "https://pub.dev" + source: hosted + version: "0.0.92" lints: dependency: transitive description: @@ -478,6 +502,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.2.0" + markdown: + dependency: transitive + description: + name: markdown + sha256: acf35edccc0463a9d7384e437c015a3535772e09714cf60e07eeef3a15870dcd + url: "https://pub.dev" + source: hosted + version: "7.1.1" matcher: dependency: transitive description: @@ -590,6 +622,14 @@ packages: url: "https://pub.dev" source: hosted version: "5.4.0" + photo_view: + dependency: "direct main" + description: + name: photo_view + sha256: "8036802a00bae2a78fc197af8a158e3e2f7b500561ed23b4c458107685e645bb" + url: "https://pub.dev" + source: hosted + version: "0.14.0" pixel_snap: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index d569f7b..b3a15c8 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -12,6 +12,7 @@ dependencies: cryptography_flutter: ^2.3.0 dio: ^5.3.2 dio_cookie_manager: ^3.1.0+1 + dio_image_provider: any enum_flag: ^1.0.2 event_listener: ^0.2.0 file: ^6.1.4 @@ -27,10 +28,12 @@ dependencies: infinite_scroll_pagination: ^4.0.0 intl: any json_annotation: ^4.8.1 + keymap: ^0.0.92 logging: ^1.2.0 palette_generator: ^0.3.3+3 path: ^1.8.3 path_provider: ^2.1.0 + photo_view: ^0.14.0 retrofit: ^4.0.1 shared_preferences: ^2.2.0 super_clipboard: ^0.6.4