mirror of
https://github.com/immich-app/immich.git
synced 2026-05-16 04:22:17 -04:00
Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 02581e81a7 | |||
| 3ab3d5cf43 | |||
| 0ef04d9baa | |||
| df016f9228 | |||
| 17779c1e74 | |||
| 01d6a244d8 |
@@ -23,6 +23,8 @@ import java.io.IOException
|
||||
import java.nio.ByteBuffer
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
private const val MAX_PREALLOC_BYTES = 128 * 1024 * 1024
|
||||
|
||||
private class RemoteRequest(val cancellationSignal: CancellationSignal)
|
||||
|
||||
class RemoteImagesImpl(context: Context) : RemoteImageApi {
|
||||
@@ -228,7 +230,6 @@ private class CronetImageFetcher : ImageFetcher {
|
||||
private val onComplete: () -> Unit,
|
||||
) : UrlRequest.Callback() {
|
||||
private var buffer: NativeByteBuffer? = null
|
||||
private var wrapped: ByteBuffer? = null
|
||||
private var error: Exception? = null
|
||||
|
||||
override fun onRedirectReceived(request: UrlRequest, info: UrlResponseInfo, newUrl: String) {
|
||||
@@ -242,15 +243,16 @@ private class CronetImageFetcher : ImageFetcher {
|
||||
}
|
||||
|
||||
try {
|
||||
// Content-Length is a size hint only. With Content-Encoding (gzip/br/...),
|
||||
// Cronet auto-decompresses and writes decompressed bytes to our buffer, which
|
||||
// may exceed the wire/compressed Content-Length. Always use the growable
|
||||
// buffer path so we can't overflow.
|
||||
val contentLength = info.allHeaders["content-length"]?.firstOrNull()?.toIntOrNull() ?: 0
|
||||
if (contentLength > 0) {
|
||||
buffer = NativeByteBuffer(contentLength + 1)
|
||||
wrapped = NativeBuffer.wrap(buffer!!.pointer, contentLength + 1)
|
||||
request.read(wrapped)
|
||||
} else {
|
||||
buffer = NativeByteBuffer(INITIAL_BUFFER_SIZE)
|
||||
request.read(buffer!!.wrapRemaining())
|
||||
}
|
||||
// Cap the up-front alloc: Content-Length is untrusted and can be huge or near
|
||||
// Int.MAX_VALUE (overflowing `+1`). For larger responses the grow path takes over.
|
||||
val initialSize = if (contentLength in 1..MAX_PREALLOC_BYTES) contentLength + 1 else INITIAL_BUFFER_SIZE
|
||||
buffer = NativeByteBuffer(initialSize)
|
||||
request.read(buffer!!.wrapRemaining())
|
||||
} catch (e: Exception) {
|
||||
error = e
|
||||
return request.cancel()
|
||||
@@ -263,14 +265,14 @@ private class CronetImageFetcher : ImageFetcher {
|
||||
byteBuffer: ByteBuffer
|
||||
) {
|
||||
try {
|
||||
val buf = if (wrapped == null) {
|
||||
buffer!!.run {
|
||||
advance(byteBuffer.position())
|
||||
ensureHeadroom()
|
||||
wrapRemaining()
|
||||
}
|
||||
} else {
|
||||
wrapped
|
||||
// Always pass a fresh wrap so byteBuffer.position() represents only the
|
||||
// bytes Cronet wrote in this iteration. Reusing the caller-supplied
|
||||
// ByteBuffer breaks advance(): Cronet's position keeps accumulating
|
||||
// across reads, which would double-count previous iterations' bytes.
|
||||
val buf = buffer!!.run {
|
||||
advance(byteBuffer.position())
|
||||
ensureHeadroom()
|
||||
wrapRemaining()
|
||||
}
|
||||
request.read(buf)
|
||||
} catch (e: Exception) {
|
||||
@@ -280,7 +282,6 @@ private class CronetImageFetcher : ImageFetcher {
|
||||
}
|
||||
|
||||
override fun onSucceeded(request: UrlRequest, info: UrlResponseInfo) {
|
||||
wrapped?.let { buffer!!.advance(it.position()) }
|
||||
onSuccess(buffer!!)
|
||||
onComplete()
|
||||
}
|
||||
|
||||
@@ -110,7 +110,7 @@ class NativeSyncApiImpl: ImmichPlugin, NativeSyncApi, FlutterPlugin {
|
||||
|
||||
var domainAlbum = PlatformAlbum(
|
||||
id: album.localIdentifier,
|
||||
name: album.localizedTitle!,
|
||||
name: album.localizedTitle ?? album.localIdentifier,
|
||||
updatedAt: nil,
|
||||
isCloud: isCloud,
|
||||
assetCount: Int64(assets.count)
|
||||
|
||||
@@ -18,3 +18,7 @@ enum CleanupStep { selectDate, scan, delete }
|
||||
enum AssetKeepType { none, photosOnly, videosOnly }
|
||||
|
||||
enum AssetDateAggregation { start, end }
|
||||
|
||||
enum SlideshowLook { contain, cover, blurredBackground }
|
||||
|
||||
enum SlideshowDirection { forward, backward, shuffle }
|
||||
|
||||
@@ -4,6 +4,7 @@ import 'package:immich_mobile/domain/models/config/map_config.dart';
|
||||
import 'package:immich_mobile/domain/models/config/theme_config.dart';
|
||||
import 'package:immich_mobile/domain/models/config/timeline_config.dart';
|
||||
import 'package:immich_mobile/domain/models/config/viewer_config.dart';
|
||||
import 'package:immich_mobile/domain/models/config/slideshow_config.dart';
|
||||
|
||||
class AppConfig {
|
||||
final ThemeConfig theme;
|
||||
@@ -12,6 +13,7 @@ class AppConfig {
|
||||
final TimelineConfig timeline;
|
||||
final ImageConfig image;
|
||||
final ViewerConfig viewer;
|
||||
final SlideshowConfig slideshow;
|
||||
|
||||
const AppConfig({
|
||||
this.theme = const .new(),
|
||||
@@ -20,6 +22,7 @@ class AppConfig {
|
||||
this.timeline = const .new(),
|
||||
this.image = const .new(),
|
||||
this.viewer = const .new(),
|
||||
this.slideshow = const .new(),
|
||||
});
|
||||
|
||||
AppConfig copyWith({
|
||||
@@ -29,6 +32,7 @@ class AppConfig {
|
||||
TimelineConfig? timeline,
|
||||
ImageConfig? image,
|
||||
ViewerConfig? viewer,
|
||||
SlideshowConfig? slideshow,
|
||||
}) => .new(
|
||||
theme: theme ?? this.theme,
|
||||
cleanup: cleanup ?? this.cleanup,
|
||||
@@ -36,6 +40,7 @@ class AppConfig {
|
||||
timeline: timeline ?? this.timeline,
|
||||
image: image ?? this.image,
|
||||
viewer: viewer ?? this.viewer,
|
||||
slideshow: slideshow ?? this.slideshow,
|
||||
);
|
||||
|
||||
@override
|
||||
@@ -47,12 +52,13 @@ class AppConfig {
|
||||
other.map == map &&
|
||||
other.timeline == timeline &&
|
||||
other.image == image &&
|
||||
other.viewer == viewer);
|
||||
other.viewer == viewer &&
|
||||
other.slideshow == slideshow);
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(theme, cleanup, map, timeline, image, viewer);
|
||||
int get hashCode => Object.hash(theme, cleanup, map, timeline, image, viewer, slideshow);
|
||||
|
||||
@override
|
||||
String toString() =>
|
||||
'AppConfig(theme: $theme, cleanup: $cleanup, map: $map, timeline: $timeline, image: $image, viewer: $viewer)';
|
||||
'AppConfig(theme: $theme, cleanup: $cleanup, map: $map, timeline: $timeline, image: $image, viewer: $viewer, slideshow: $slideshow)';
|
||||
}
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import 'package:immich_mobile/constants/enums.dart';
|
||||
|
||||
class SlideshowConfig {
|
||||
final bool transition;
|
||||
final bool repeat;
|
||||
final int duration;
|
||||
final SlideshowLook look;
|
||||
final SlideshowDirection direction;
|
||||
|
||||
const SlideshowConfig({
|
||||
this.transition = true,
|
||||
this.repeat = true,
|
||||
this.duration = 5,
|
||||
this.look = SlideshowLook.contain,
|
||||
this.direction = SlideshowDirection.forward,
|
||||
});
|
||||
|
||||
SlideshowConfig copyWith({
|
||||
bool? transition,
|
||||
bool? repeat,
|
||||
int? duration,
|
||||
SlideshowLook? look,
|
||||
SlideshowDirection? direction,
|
||||
}) => SlideshowConfig(
|
||||
transition: transition ?? this.transition,
|
||||
repeat: repeat ?? this.repeat,
|
||||
duration: duration ?? this.duration,
|
||||
look: look ?? this.look,
|
||||
direction: direction ?? this.direction,
|
||||
);
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
(other is SlideshowConfig &&
|
||||
other.transition == transition &&
|
||||
other.repeat == repeat &&
|
||||
other.duration == duration &&
|
||||
other.look == look &&
|
||||
other.direction == direction);
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(transition, repeat, duration, look, direction);
|
||||
|
||||
@override
|
||||
String toString() =>
|
||||
'SlideshowConfig(transition: $transition, repeat: $repeat, duration: $duration, look: $look, direction: $direction)';
|
||||
}
|
||||
@@ -64,7 +64,19 @@ enum MetadataKey<T extends Object> {
|
||||
),
|
||||
cleanupKeepAlbumIds<List<String>>(.appConfig, 'cleanup.keepAlbumIds', [], _ListCodec(_PrimitiveCodec.string)),
|
||||
cleanupCutoffDaysAgo<int>(.appConfig, 'cleanup.cutoffDaysAgo', -1),
|
||||
cleanupDefaultsInitialized<bool>(.appConfig, 'cleanup.defaultsInitialized', false);
|
||||
cleanupDefaultsInitialized<bool>(.appConfig, 'cleanup.defaultsInitialized', false),
|
||||
|
||||
// Slideshow
|
||||
slideshowTransition<bool>(.appConfig, 'slideshow.transition', true),
|
||||
slideshowRepeat<bool>(.appConfig, 'slideshow.repeat', true),
|
||||
slideshowDuration<int>(.appConfig, 'slideshow.duration', 5),
|
||||
slideshowLook<SlideshowLook>(.appConfig, 'slideshow.look', SlideshowLook.contain, _EnumCodec(SlideshowLook.values)),
|
||||
slideshowDirection<SlideshowDirection>(
|
||||
.appConfig,
|
||||
'slideshow.direction',
|
||||
SlideshowDirection.forward,
|
||||
_EnumCodec(SlideshowDirection.values),
|
||||
);
|
||||
|
||||
final MetadataDomain domain;
|
||||
final String name;
|
||||
|
||||
@@ -29,6 +29,9 @@ enum StoreKey<T> {
|
||||
readonlyModeEnabled<bool>._(138),
|
||||
albumGridView<bool>._(140),
|
||||
|
||||
// Image viewer navigation settings
|
||||
tapToNavigate<bool>._(141),
|
||||
|
||||
// Experimental stuff
|
||||
enableBackup<bool>._(1003),
|
||||
useWifiForUploadVideos<bool>._(1004),
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:drift/drift.dart';
|
||||
import 'package:drift_sqlite_async/drift_sqlite_async.dart';
|
||||
import 'package:drift_flutter/drift_flutter.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/asset_edit.entity.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/asset_face.entity.dart';
|
||||
@@ -32,10 +31,6 @@ import 'package:immich_mobile/infrastructure/entities/user_metadata.entity.dart'
|
||||
import 'package:immich_mobile/infrastructure/repositories/db.repository.drift.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/db.repository.steps.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:sqlite3/sqlite3.dart';
|
||||
import 'package:sqlite_async/sqlite_async.dart';
|
||||
|
||||
@DriftDatabase(
|
||||
tables: [
|
||||
@@ -65,9 +60,8 @@ import 'package:sqlite_async/sqlite_async.dart';
|
||||
include: {'package:immich_mobile/infrastructure/entities/merged_asset.drift'},
|
||||
)
|
||||
class Drift extends $Drift {
|
||||
Drift(super.executor);
|
||||
|
||||
Drift.sqlite(SqliteConnection db) : super(SqliteAsyncDriftConnection(db));
|
||||
Drift([QueryExecutor? executor])
|
||||
: super(executor ?? driftDatabase(name: 'immich', native: const DriftNativeOptions(shareAcrossIsolates: true)));
|
||||
|
||||
Future<void> reset() async {
|
||||
// https://github.com/simolus3/drift/commit/bd80a46264b6dd833ef4fd87fffc03f5a832ab41#diff-3f879e03b4a35779344ef16170b9353608dd9c42385f5402ec6035aac4dd8a04R76-R94
|
||||
@@ -311,18 +305,3 @@ class DriftDatabaseRepository {
|
||||
|
||||
Future<T> transaction<T>(Future<T> Function() callback) => _db.transaction(callback);
|
||||
}
|
||||
|
||||
Future<SqliteConnection> openSqliteConnection({required String name}) async {
|
||||
final dbFolder = await getApplicationDocumentsDirectory();
|
||||
final file = File(p.join(dbFolder.path, '$name.sqlite'));
|
||||
return SqliteDatabase(path: file.path);
|
||||
}
|
||||
|
||||
Future<void> configureSqliteCache() async {
|
||||
// Make sqlite3 pick a more suitable location for temporary files - the
|
||||
// one from the system may be inaccessible due to sand-boxing.
|
||||
final cacheBase = (await getTemporaryDirectory()).path;
|
||||
// We can't access /tmp on Android, which sqlite3 would try by default.
|
||||
// Explicitly tell it about the correct temporary directory.
|
||||
sqlite3.tempDirectory = cacheBase;
|
||||
}
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import 'package:drift/drift.dart';
|
||||
import 'package:drift_sqlite_async/drift_sqlite_async.dart';
|
||||
import 'package:drift_flutter/drift_flutter.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/log.entity.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/logger_db.repository.drift.dart';
|
||||
import 'package:sqlite_async/sqlite_async.dart';
|
||||
|
||||
@DriftDatabase(tables: [LogMessageEntity])
|
||||
class DriftLogger extends $DriftLogger {
|
||||
DriftLogger.fromExecutor(super.executor);
|
||||
|
||||
DriftLogger.sqlite(SqliteConnection db) : super(SqliteAsyncDriftConnection(db));
|
||||
DriftLogger([QueryExecutor? executor])
|
||||
: super(
|
||||
executor ?? driftDatabase(name: 'immich_logs', native: const DriftNativeOptions(shareAcrossIsolates: true)),
|
||||
);
|
||||
|
||||
@override
|
||||
int get schemaVersion => 1;
|
||||
@@ -19,8 +19,7 @@ class DriftLogger extends $DriftLogger {
|
||||
await customStatement('PRAGMA foreign_keys = ON');
|
||||
await customStatement('PRAGMA synchronous = NORMAL');
|
||||
await customStatement('PRAGMA journal_mode = WAL');
|
||||
await customStatement('PRAGMA busy_timeout = 30000'); // 30s
|
||||
await customStatement('PRAGMA cache_size = -32000'); // 32MB
|
||||
await customStatement('PRAGMA busy_timeout = 500');
|
||||
await customStatement('PRAGMA temp_store = MEMORY');
|
||||
},
|
||||
);
|
||||
|
||||
@@ -139,6 +139,13 @@ extension<T extends Object> on MetadataDomain<T> {
|
||||
autoPlayVideo: repo._read(.viewerAutoPlayVideo),
|
||||
tapToNavigate: repo._read(.viewerTapToNavigate),
|
||||
),
|
||||
slideshow: .new(
|
||||
transition: repo._read(.slideshowTransition),
|
||||
repeat: repo._read(.slideshowRepeat),
|
||||
duration: repo._read(.slideshowDuration),
|
||||
look: repo._read(.slideshowLook),
|
||||
direction: repo._read(.slideshowDirection),
|
||||
),
|
||||
);
|
||||
case .systemConfig:
|
||||
repo._systemConfig = .new(logLevel: repo._read(.logLevel));
|
||||
|
||||
@@ -0,0 +1,350 @@
|
||||
import 'dart:async';
|
||||
import 'dart:math';
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/constants/enums.dart';
|
||||
import 'package:immich_mobile/domain/models/config/slideshow_config.dart';
|
||||
import 'package:immich_mobile/domain/services/timeline.service.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/scroll_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||
import 'package:immich_mobile/pages/common/settings.page.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/video_viewer.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/images/image_provider.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/video_player_provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/metadata.provider.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
import 'package:immich_mobile/widgets/common/immich_loading_indicator.dart';
|
||||
import 'package:immich_mobile/widgets/photo_view/photo_view.dart';
|
||||
|
||||
@RoutePage()
|
||||
class DriftSlideshowPage extends ConsumerStatefulWidget {
|
||||
final TimelineService timeline;
|
||||
|
||||
const DriftSlideshowPage({super.key, required this.timeline});
|
||||
|
||||
@override
|
||||
ConsumerState<DriftSlideshowPage> createState() => _DriftSlideshowPageState();
|
||||
}
|
||||
|
||||
class _DriftSlideshowPageState extends ConsumerState<DriftSlideshowPage> {
|
||||
late final SlideshowConfig _config;
|
||||
late final PageController _pageController;
|
||||
late final Stopwatch _stopwatch;
|
||||
late Timer _timer;
|
||||
late int _index;
|
||||
late int _nextIndex;
|
||||
bool _paused = false;
|
||||
bool _showAppBar = false;
|
||||
|
||||
@override
|
||||
initState() {
|
||||
super.initState();
|
||||
_config = ref.read(appConfigProvider.select((s) => s.slideshow));
|
||||
final asset = ref.read(assetViewerProvider).currentAsset;
|
||||
_index = asset == null ? 0 : widget.timeline.getIndex(asset.heroTag) ?? 0;
|
||||
_pageController = PageController(initialPage: _index);
|
||||
_stopwatch = Stopwatch();
|
||||
_createTimer();
|
||||
_updateNextIndex();
|
||||
|
||||
SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersive);
|
||||
}
|
||||
|
||||
@override
|
||||
dispose() {
|
||||
_timer.cancel();
|
||||
_stopwatch.stop();
|
||||
_pageController.dispose();
|
||||
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _play() {
|
||||
final asset = widget.timeline.getAssetSafe(_index)!;
|
||||
|
||||
if (asset.isImage) {
|
||||
_createTimer();
|
||||
} else if (ref.read(videoPlayerProvider(asset.heroTag)).status == VideoPlaybackStatus.paused) {
|
||||
ref.read(videoPlayerProvider(asset.heroTag).notifier).play();
|
||||
} else {
|
||||
_nextPage();
|
||||
}
|
||||
|
||||
_updateNextIndex();
|
||||
|
||||
setState(() {
|
||||
_paused = false;
|
||||
});
|
||||
}
|
||||
|
||||
void _pause() {
|
||||
_timer.cancel();
|
||||
_stopwatch.stop();
|
||||
|
||||
final asset = widget.timeline.getAssetSafe(_index)!;
|
||||
|
||||
if (!asset.isImage) {
|
||||
ref.read(videoPlayerProvider(asset.heroTag).notifier).pause();
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_paused = true;
|
||||
});
|
||||
}
|
||||
|
||||
void _updateNextIndex() {
|
||||
_nextIndex = switch (_config.direction) {
|
||||
SlideshowDirection.forward => _index + 1,
|
||||
SlideshowDirection.backward => _index - 1,
|
||||
SlideshowDirection.shuffle => widget.timeline.getIndex(widget.timeline.getRandomAsset().heroTag)!,
|
||||
};
|
||||
|
||||
if (!widget.timeline.hasRange(_nextIndex, 1)) {
|
||||
widget.timeline.preloadAssets(_nextIndex);
|
||||
}
|
||||
}
|
||||
|
||||
void _nextPage() async {
|
||||
if (_nextIndex < 0 || _nextIndex >= widget.timeline.totalAssets) {
|
||||
if (_config.repeat) {
|
||||
final wrapped = _config.direction == SlideshowDirection.forward ? 0 : widget.timeline.totalAssets - 1;
|
||||
await widget.timeline.preloadAssets(wrapped);
|
||||
_pageController.jumpToPage(wrapped);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!widget.timeline.hasRange(_nextIndex, 1)) {
|
||||
await widget.timeline.preloadAssets(_nextIndex);
|
||||
}
|
||||
|
||||
if (_config.direction == SlideshowDirection.shuffle || !_config.transition) {
|
||||
_pageController.jumpToPage(_nextIndex);
|
||||
} else {
|
||||
unawaited(_pageController.animateToPage(_nextIndex, duration: Durations.long2, curve: Curves.easeIn));
|
||||
}
|
||||
}
|
||||
|
||||
void _createTimer() {
|
||||
_timer = Timer(Duration(milliseconds: _config.duration * 1000 - _stopwatch.elapsedMilliseconds), () {
|
||||
_stopwatch.stop();
|
||||
_stopwatch.reset();
|
||||
_nextPage();
|
||||
});
|
||||
|
||||
_stopwatch.start();
|
||||
}
|
||||
|
||||
void _pageChanged(int page) {
|
||||
final asset = widget.timeline.getAssetSafe(page)!;
|
||||
|
||||
setState(() {
|
||||
_index = page;
|
||||
|
||||
if (!asset.isImage) {
|
||||
_paused = false;
|
||||
}
|
||||
});
|
||||
|
||||
_timer.cancel();
|
||||
_stopwatch.stop();
|
||||
_stopwatch.reset();
|
||||
|
||||
if (!_paused && asset.isImage) {
|
||||
_createTimer();
|
||||
}
|
||||
|
||||
_updateNextIndex();
|
||||
}
|
||||
|
||||
void _onTapUp() async {
|
||||
await SystemChrome.setEnabledSystemUIMode(_showAppBar ? SystemUiMode.immersive : SystemUiMode.edgeToEdge);
|
||||
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
setState(() {
|
||||
_showAppBar = !_showAppBar;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Widget _getProgressBar(BuildContext context) {
|
||||
final asset = widget.timeline.getAssetSafe(_index);
|
||||
|
||||
if (asset == null) {
|
||||
return Container();
|
||||
}
|
||||
|
||||
if (asset.isImage) {
|
||||
final elapsed = _stopwatch.elapsedMilliseconds;
|
||||
final duration = _config.duration * 1000;
|
||||
|
||||
return TweenAnimationBuilder(
|
||||
key: Key(_index.toString()),
|
||||
tween: Tween<double>(begin: elapsed / duration.toDouble(), end: _paused ? elapsed / duration.toDouble() : 1.0),
|
||||
duration: Duration(milliseconds: _paused ? 1 : max(duration - elapsed, 1)),
|
||||
builder: (context, value, _) => LinearProgressIndicator(
|
||||
color: context.colorScheme.primary,
|
||||
borderRadius: const BorderRadius.all(Radius.zero),
|
||||
minHeight: 5,
|
||||
value: value,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
return LinearProgressIndicator(
|
||||
color: context.colorScheme.primary,
|
||||
borderRadius: const BorderRadius.all(Radius.zero),
|
||||
minHeight: 5,
|
||||
value:
|
||||
ref.watch(videoPlayerProvider(asset.heroTag).select((s) => s.position)).inMilliseconds /
|
||||
asset.duration.inMilliseconds,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Widget _getBlur(BuildContext context, int index) {
|
||||
final asset = widget.timeline.getAssetSafe(index);
|
||||
|
||||
if (asset == null) {
|
||||
return Container();
|
||||
}
|
||||
|
||||
return ImageFiltered(
|
||||
imageFilter: ImageFilter.blur(sigmaX: 30, sigmaY: 30),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
image: DecorationImage(
|
||||
image: getFullImageProvider(asset, size: Size(context.width, context.height)),
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
),
|
||||
child: Container(color: Colors.black.withValues(alpha: 0.2)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _getPhotoView(BuildContext context, int index) {
|
||||
final asset = widget.timeline.getAssetSafe(index);
|
||||
|
||||
if (asset == null) {
|
||||
return const Center(child: ImmichLoadingIndicator());
|
||||
}
|
||||
|
||||
final scale = _config.look == SlideshowLook.cover
|
||||
? PhotoViewComputedScale.covered
|
||||
: PhotoViewComputedScale.contained;
|
||||
final isCurrent = _index == index;
|
||||
final imageProvider = getFullImageProvider(asset, size: context.sizeData);
|
||||
|
||||
if (asset.isImage) {
|
||||
final zoomOut = index % 2 == 1;
|
||||
final elapsed = _stopwatch.elapsedMilliseconds;
|
||||
final duration = _config.duration * 1000;
|
||||
final progress = zoomOut ? 1.0 - elapsed / duration.toDouble() : elapsed / duration.toDouble();
|
||||
|
||||
return TweenAnimationBuilder(
|
||||
tween: Tween<double>(
|
||||
begin: progress,
|
||||
end: _paused
|
||||
? progress
|
||||
: zoomOut
|
||||
? 0.0
|
||||
: 1.0,
|
||||
),
|
||||
duration: Duration(milliseconds: _paused ? 1 : max(duration - elapsed, 1)),
|
||||
builder: (context, value, _) => PhotoView(
|
||||
imageProvider: imageProvider,
|
||||
index: index,
|
||||
disableScaleGestures: true,
|
||||
gaplessPlayback: true,
|
||||
filterQuality: FilterQuality.high,
|
||||
initialScale: scale * (1.0 + value / 10.0),
|
||||
controller: PhotoViewController(),
|
||||
onTapUp: (_, _, _) => _onTapUp(),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
final status = ref.watch(videoPlayerProvider(asset.heroTag).select((s) => s.status));
|
||||
final position = ref.read(videoPlayerProvider(asset.heroTag)).position;
|
||||
|
||||
if (status == VideoPlaybackStatus.completed && isCurrent && position.inMicroseconds > 0) {
|
||||
_nextPage();
|
||||
} else if (status == VideoPlaybackStatus.playing) {
|
||||
ref.read(videoPlayerProvider(asset.heroTag).notifier).setLoop(false);
|
||||
}
|
||||
|
||||
return PhotoView.customChild(
|
||||
onTapUp: (_, _, _) => _onTapUp(),
|
||||
disableScaleGestures: true,
|
||||
filterQuality: FilterQuality.high,
|
||||
initialScale: scale,
|
||||
child: NativeVideoViewer(
|
||||
asset: asset,
|
||||
isCurrent: isCurrent,
|
||||
image: Image(image: imageProvider, fit: BoxFit.contain, alignment: Alignment.center),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: PreferredSize(
|
||||
preferredSize: Size(AppBar().preferredSize.width, AppBar().preferredSize.height + 5),
|
||||
child: IgnorePointer(
|
||||
ignoring: !_showAppBar,
|
||||
child: AnimatedOpacity(
|
||||
opacity: _showAppBar ? 1.0 : 0.0,
|
||||
duration: Durations.short2,
|
||||
child: Column(
|
||||
children: [
|
||||
AppBar(
|
||||
backgroundColor: context.scaffoldBackgroundColor,
|
||||
title: Text("slideshow".t(context: context)),
|
||||
actions: [
|
||||
IconButton(
|
||||
onPressed: _paused ? _play : _pause,
|
||||
icon: Icon(_paused ? Icons.play_arrow : Icons.pause),
|
||||
),
|
||||
IconButton(
|
||||
onPressed: () {
|
||||
_pause();
|
||||
context.pushRoute(SettingsSubRoute(section: SettingSection.assetViewer));
|
||||
},
|
||||
icon: const Icon(Icons.settings),
|
||||
),
|
||||
],
|
||||
),
|
||||
_getProgressBar(context),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
extendBody: true,
|
||||
extendBodyBehindAppBar: true,
|
||||
backgroundColor: Colors.black,
|
||||
body: PhotoViewGestureDetectorScope(
|
||||
axis: Axis.horizontal,
|
||||
child: PageView.builder(
|
||||
controller: _pageController,
|
||||
physics: const FastClampingScrollPhysics(),
|
||||
itemCount: widget.timeline.totalAssets,
|
||||
onPageChanged: _pageChanged,
|
||||
itemBuilder: (context, index) => Stack(
|
||||
children: [
|
||||
if (_config.look == SlideshowLook.blurredBackground) _getBlur(context, index),
|
||||
_getPhotoView(context, index),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -50,10 +50,13 @@ class BaseActionButton extends ConsumerWidget {
|
||||
final iconColor = this.iconColor;
|
||||
|
||||
return MenuItemButton(
|
||||
style: MenuItemButton.styleFrom(alignment: Alignment.centerLeft, padding: const EdgeInsets.all(16)),
|
||||
leadingIcon: Icon(iconData, color: iconColor),
|
||||
style: MenuItemButton.styleFrom(
|
||||
alignment: Alignment.centerLeft,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
|
||||
),
|
||||
leadingIcon: Icon(iconData, color: iconColor, size: 20),
|
||||
onPressed: onPressed,
|
||||
child: Text(label, style: TextStyle(fontSize: 16, color: iconColor)),
|
||||
child: Text(label, style: TextStyle(fontSize: 15, color: iconColor)),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
|
||||
class SlideshowActionButton extends ConsumerWidget {
|
||||
final bool iconOnly;
|
||||
final bool menuItem;
|
||||
|
||||
const SlideshowActionButton({super.key, this.iconOnly = false, this.menuItem = false});
|
||||
|
||||
void _onTap(BuildContext context, WidgetRef ref) {
|
||||
if (!context.mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
context.pushRoute(DriftSlideshowRoute(timeline: ref.read(timelineServiceProvider)));
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return BaseActionButton(
|
||||
iconData: Icons.slideshow,
|
||||
label: "slideshow".t(context: context),
|
||||
iconOnly: iconOnly,
|
||||
menuItem: menuItem,
|
||||
onPressed: () => _onTap(context, ref),
|
||||
maxWidth: 100,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -120,6 +120,9 @@ class _ThumbnailTileState extends ConsumerState<ThumbnailTile> {
|
||||
},
|
||||
flightShuttleBuilder: (context, animation, direction, from, to) {
|
||||
void animationStatusListener(AnimationStatus status) {
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
final heroInFlight = status == AnimationStatus.forward || status == AnimationStatus.reverse;
|
||||
if (_hideIndicators != heroInFlight) {
|
||||
setState(() => _hideIndicators = heroInFlight);
|
||||
|
||||
@@ -60,6 +60,7 @@ import 'package:immich_mobile/presentation/pages/drift_place_detail.page.dart';
|
||||
import 'package:immich_mobile/presentation/pages/drift_recently_taken.page.dart';
|
||||
import 'package:immich_mobile/presentation/pages/drift_recently_added.page.dart';
|
||||
import 'package:immich_mobile/presentation/pages/drift_remote_album.page.dart';
|
||||
import 'package:immich_mobile/presentation/pages/drift_slideshow.page.dart';
|
||||
import 'package:immich_mobile/presentation/pages/drift_trash.page.dart';
|
||||
import 'package:immich_mobile/presentation/pages/drift_user_selection.page.dart';
|
||||
import 'package:immich_mobile/presentation/pages/drift_video.page.dart';
|
||||
@@ -189,6 +190,7 @@ class AppRouter extends RootStackRouter {
|
||||
AutoRoute(page: AssetTroubleshootRoute.page, guards: [_authGuard, _duplicateGuard]),
|
||||
AutoRoute(page: DownloadInfoRoute.page, guards: [_authGuard, _duplicateGuard]),
|
||||
AutoRoute(page: CleanupPreviewRoute.page, guards: [_authGuard, _duplicateGuard]),
|
||||
AutoRoute(page: DriftSlideshowRoute.page, guards: [_authGuard, _duplicateGuard]),
|
||||
// required to handle all deeplinks in deep_link.service.dart
|
||||
// auto_route_library#1722
|
||||
RedirectRoute(path: '*', redirectTo: '/'),
|
||||
|
||||
@@ -1095,6 +1095,53 @@ class DriftSearchRoute extends PageRouteInfo<void> {
|
||||
);
|
||||
}
|
||||
|
||||
/// generated route for
|
||||
/// [DriftSlideshowPage]
|
||||
class DriftSlideshowRoute extends PageRouteInfo<DriftSlideshowRouteArgs> {
|
||||
DriftSlideshowRoute({
|
||||
Key? key,
|
||||
required TimelineService timeline,
|
||||
List<PageRouteInfo>? children,
|
||||
}) : super(
|
||||
DriftSlideshowRoute.name,
|
||||
args: DriftSlideshowRouteArgs(key: key, timeline: timeline),
|
||||
initialChildren: children,
|
||||
);
|
||||
|
||||
static const String name = 'DriftSlideshowRoute';
|
||||
|
||||
static PageInfo page = PageInfo(
|
||||
name,
|
||||
builder: (data) {
|
||||
final args = data.argsAs<DriftSlideshowRouteArgs>();
|
||||
return DriftSlideshowPage(key: args.key, timeline: args.timeline);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
class DriftSlideshowRouteArgs {
|
||||
const DriftSlideshowRouteArgs({this.key, required this.timeline});
|
||||
|
||||
final Key? key;
|
||||
|
||||
final TimelineService timeline;
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'DriftSlideshowRouteArgs{key: $key, timeline: $timeline}';
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
if (identical(this, other)) return true;
|
||||
if (other is! DriftSlideshowRouteArgs) return false;
|
||||
return key == other.key && timeline == other.timeline;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => key.hashCode ^ timeline.hashCode;
|
||||
}
|
||||
|
||||
/// generated route for
|
||||
/// [DriftTrashPage]
|
||||
class DriftTrashRoute extends PageRouteInfo<void> {
|
||||
|
||||
@@ -27,6 +27,7 @@ import 'package:immich_mobile/presentation/widgets/action_buttons/set_profile_pi
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/share_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/share_link_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/similar_photos_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/slideshow_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/trash_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/unarchive_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/unstack_action_button.widget.dart';
|
||||
@@ -73,6 +74,7 @@ enum ActionButtonType {
|
||||
similarPhotos,
|
||||
setProfilePicture,
|
||||
viewInTimeline,
|
||||
slideshow,
|
||||
download,
|
||||
upload,
|
||||
openInBrowser,
|
||||
@@ -179,6 +181,7 @@ enum ActionButtonType {
|
||||
context.timelineOrigin != TimelineOrigin.localAlbum &&
|
||||
context.isOwner,
|
||||
ActionButtonType.cast => context.isCasting || context.asset.hasRemote,
|
||||
ActionButtonType.slideshow => true,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -200,6 +203,7 @@ enum ActionButtonType {
|
||||
iconOnly: iconOnly,
|
||||
menuItem: menuItem,
|
||||
),
|
||||
ActionButtonType.slideshow => SlideshowActionButton(iconOnly: iconOnly, menuItem: menuItem),
|
||||
ActionButtonType.archive => ArchiveActionButton(source: context.source, iconOnly: iconOnly, menuItem: menuItem),
|
||||
ActionButtonType.unarchive => UnArchiveActionButton(
|
||||
source: context.source,
|
||||
|
||||
@@ -43,9 +43,8 @@ void configureFileDownloaderNotifications() {
|
||||
|
||||
abstract final class Bootstrap {
|
||||
static Future<(Drift, DriftLogger)> initDomain({bool listenStoreUpdates = true, bool shouldBufferLogs = true}) async {
|
||||
await configureSqliteCache();
|
||||
final drift = Drift.sqlite(await openSqliteConnection(name: 'immich'));
|
||||
final logDb = DriftLogger.sqlite(await openSqliteConnection(name: 'immich_logs'));
|
||||
final drift = Drift();
|
||||
final logDb = DriftLogger();
|
||||
final DriftStoreRepository storeRepo = DriftStoreRepository(drift);
|
||||
|
||||
await StoreService.init(storeRepository: storeRepo, listenUpdates: listenStoreUpdates);
|
||||
|
||||
@@ -18,6 +18,7 @@ import 'package:immich_mobile/providers/infrastructure/current_album.provider.da
|
||||
import 'package:immich_mobile/providers/infrastructure/remote_album.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
|
||||
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
import 'package:immich_mobile/widgets/album/remote_album_shared_user_icons.dart';
|
||||
|
||||
class RemoteAlbumSliverAppBar extends ConsumerStatefulWidget {
|
||||
@@ -89,6 +90,10 @@ class _MesmerizingSliverAppBarState extends ConsumerState<RemoteAlbumSliverAppBa
|
||||
onPressed: () => context.maybePop(),
|
||||
),
|
||||
actions: [
|
||||
IconButton(
|
||||
onPressed: () => context.pushRoute(DriftSlideshowRoute(timeline: ref.read(timelineServiceProvider))),
|
||||
icon: Icon(Icons.slideshow_outlined, color: actionIconColor, shadows: actionIconShadows),
|
||||
),
|
||||
if (currentAlbum.isActivityEnabled && currentAlbum.isShared)
|
||||
IconButton(
|
||||
icon: Icon(Icons.chat_outlined, color: actionIconColor, shadows: actionIconShadows),
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:immich_mobile/widgets/settings/asset_viewer_settings/image_viewer_quality_setting.dart';
|
||||
import 'package:immich_mobile/widgets/settings/asset_viewer_settings/image_viewer_tap_to_navigate_setting.dart';
|
||||
import 'package:immich_mobile/widgets/settings/asset_viewer_settings/video_viewer_settings.dart';
|
||||
import 'package:immich_mobile/widgets/settings/asset_viewer_settings/slideshow_settings.dart';
|
||||
import 'package:immich_mobile/widgets/settings/settings_sub_page_scaffold.dart';
|
||||
|
||||
class AssetViewerSettings extends StatelessWidget {
|
||||
@@ -13,6 +14,7 @@ class AssetViewerSettings extends StatelessWidget {
|
||||
const ImageViewerQualitySetting(),
|
||||
const ImageViewerTapToNavigateSetting(),
|
||||
const VideoViewerSettings(),
|
||||
const SlideshowSettings(),
|
||||
];
|
||||
|
||||
return SettingsSubPageScaffold(settings: assetViewerSetting, showDivider: true);
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/constants/enums.dart';
|
||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/metadata.provider.dart';
|
||||
import 'package:immich_mobile/widgets/settings/setting_group_title.dart';
|
||||
import 'package:immich_mobile/widgets/settings/settings_radio_list_tile.dart';
|
||||
import 'package:immich_mobile/widgets/settings/settings_slider_list_tile.dart';
|
||||
import 'package:immich_mobile/widgets/settings/settings_sub_title.dart';
|
||||
import 'package:immich_mobile/widgets/settings/settings_switch_list_tile.dart';
|
||||
|
||||
class SlideshowSettings extends HookConsumerWidget {
|
||||
const SlideshowSettings({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final slideshow = ref.read(appConfigProvider).slideshow;
|
||||
final useTransition = useState(slideshow.transition);
|
||||
final useRepeat = useState(slideshow.repeat);
|
||||
final useDuration = useState(slideshow.duration);
|
||||
final useLook = useState(slideshow.look);
|
||||
final useDirection = useState(slideshow.direction);
|
||||
|
||||
useValueChanged<bool, void>(useTransition.value, (_, __) {
|
||||
ref.read(metadataProvider).write(.slideshowTransition, useTransition.value);
|
||||
});
|
||||
useValueChanged<bool, void>(useRepeat.value, (_, __) {
|
||||
ref.read(metadataProvider).write(.slideshowRepeat, useRepeat.value);
|
||||
});
|
||||
useValueChanged<int, void>(useDuration.value, (_, __) {
|
||||
ref.read(metadataProvider).write(.slideshowDuration, useDuration.value);
|
||||
});
|
||||
useValueChanged<SlideshowLook, void>(useLook.value, (_, __) {
|
||||
ref.read(metadataProvider).write(.slideshowLook, useLook.value);
|
||||
});
|
||||
useValueChanged<SlideshowDirection, void>(useDirection.value, (_, __) {
|
||||
ref.read(metadataProvider).write(.slideshowDirection, useDirection.value);
|
||||
});
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SettingGroupTitle(
|
||||
title: 'slideshow'.t(context: context),
|
||||
icon: Icons.slideshow_outlined,
|
||||
),
|
||||
SettingsSwitchListTile(
|
||||
valueNotifier: useTransition,
|
||||
title: "show_slideshow_transition".t(context: context),
|
||||
enabled: useDirection.value != SlideshowDirection.shuffle,
|
||||
),
|
||||
SettingsSwitchListTile(
|
||||
valueNotifier: useRepeat,
|
||||
title: "slideshow_repeat".t(context: context),
|
||||
subtitle: "slideshow_repeat_description".t(context: context),
|
||||
),
|
||||
SettingsSliderListTile(
|
||||
valueNotifier: useDuration,
|
||||
text: "duration".t(context: context),
|
||||
minValue: 5,
|
||||
noDivisons: 5,
|
||||
maxValue: 30,
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 20),
|
||||
child: SettingsSubTitle(title: 'look'.t(context: context)),
|
||||
),
|
||||
SettingsRadioListTile(
|
||||
groups: [
|
||||
SettingsRadioGroup(
|
||||
title: 'contain'.t(context: context),
|
||||
value: SlideshowLook.contain,
|
||||
),
|
||||
SettingsRadioGroup(
|
||||
title: 'cover'.t(context: context),
|
||||
value: SlideshowLook.cover,
|
||||
),
|
||||
SettingsRadioGroup(
|
||||
title: 'blurred_background'.t(context: context),
|
||||
value: SlideshowLook.blurredBackground,
|
||||
),
|
||||
],
|
||||
groupBy: useLook.value,
|
||||
onRadioChanged: (value) {
|
||||
if (value != null) {
|
||||
useLook.value = value;
|
||||
}
|
||||
},
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 20),
|
||||
child: SettingsSubTitle(title: 'direction'.t(context: context)),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 32),
|
||||
child: SettingsRadioListTile(
|
||||
groups: [
|
||||
SettingsRadioGroup(
|
||||
title: 'forward'.t(context: context),
|
||||
value: SlideshowDirection.forward,
|
||||
),
|
||||
SettingsRadioGroup(
|
||||
title: 'backward'.t(context: context),
|
||||
value: SlideshowDirection.backward,
|
||||
),
|
||||
SettingsRadioGroup(
|
||||
title: 'shuffle'.t(context: context),
|
||||
value: SlideshowDirection.shuffle,
|
||||
),
|
||||
],
|
||||
groupBy: useDirection.value,
|
||||
onRadioChanged: (value) {
|
||||
if (value != null) {
|
||||
useDirection.value = value;
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
+16
-24
@@ -370,11 +370,11 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.32.1"
|
||||
drift_sqlite_async:
|
||||
drift_flutter:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: drift_sqlite_async
|
||||
sha256: "1b6e99562fc5d35fe5e3696741720a8aca47f4c3eee35d4b9b94be819f53a6f6"
|
||||
name: drift_flutter
|
||||
sha256: "887fdec622174dc7eaefd0048403e34ee07cc18626ac8a7544cc3b8a4a172166"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.3.0"
|
||||
@@ -1619,38 +1619,30 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.10.2"
|
||||
sqlcipher_flutter_libs:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: sqlcipher_flutter_libs
|
||||
sha256: "38d62d659d2fb8739bf25a42c9a350d1fdd6c29a5a61f13a946778ec75d27929"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.7.0+eol"
|
||||
sqlite3:
|
||||
dependency: "direct main"
|
||||
dependency: transitive
|
||||
description:
|
||||
name: sqlite3
|
||||
sha256: "56da3e13ed7d28a66f930aa2b2b29db6736a233f08283326e96321dd812030f5"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.3.1"
|
||||
sqlite3_connection_pool:
|
||||
sqlite3_flutter_libs:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: sqlite3_connection_pool
|
||||
sha256: "90b25972c7699d84da97df1c5919804275560b4ab8a158bbec890434b9718f65"
|
||||
name: sqlite3_flutter_libs
|
||||
sha256: "3ed7553eee7bb368f8950f58ba29f634e06e813c029aff6a0d60862b96de8454"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.2.4"
|
||||
sqlite3_web:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: sqlite3_web
|
||||
sha256: d876398a9f2cbf115d93fc34901f8fa129b58b13b5fa9377156ed3a9a05695e3
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.7.1"
|
||||
sqlite_async:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: sqlite_async
|
||||
sha256: "4c243c5386eba3a7102f98999388a7e0a7f2632e4e06dafb3b4f5a44170a26f6"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.14.1"
|
||||
version: "0.6.0+eol"
|
||||
sqlparser:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
||||
+1
-3
@@ -19,7 +19,7 @@ dependencies:
|
||||
crypto: ^3.0.7
|
||||
device_info_plus: ^12.4.0
|
||||
drift: ^2.32.1
|
||||
drift_sqlite_async: 0.3.0
|
||||
drift_flutter: ^0.3.0
|
||||
dynamic_color: ^1.8.1
|
||||
easy_localization: ^3.0.8
|
||||
ffi: ^2.2.0
|
||||
@@ -66,8 +66,6 @@ dependencies:
|
||||
share_plus: ^10.1.4
|
||||
sliver_tools: ^0.2.12
|
||||
stream_transform: ^2.1.1
|
||||
sqlite3: ^3.3.1
|
||||
sqlite_async: 0.14.1
|
||||
thumbhash: 0.1.0+1
|
||||
timezone: ^0.9.4
|
||||
url_launcher: ^6.3.2
|
||||
|
||||
@@ -131,7 +131,7 @@ void main() {
|
||||
durationMs: 0,
|
||||
orientation: 0,
|
||||
isFavorite: false,
|
||||
playbackStyle: PlatformAssetPlaybackStyle.image,
|
||||
playbackStyle: PlatformAssetPlaybackStyle.image
|
||||
);
|
||||
|
||||
final assetsToRestore = [LocalAssetStub.image1];
|
||||
@@ -215,7 +215,7 @@ void main() {
|
||||
isFavorite: false,
|
||||
createdAt: 1700000000,
|
||||
updatedAt: 1732000000,
|
||||
playbackStyle: PlatformAssetPlaybackStyle.image,
|
||||
playbackStyle: PlatformAssetPlaybackStyle.image
|
||||
);
|
||||
|
||||
final localAsset = platformAsset.toLocalAsset();
|
||||
|
||||
@@ -1,3 +1,54 @@
|
||||
<script module lang="ts">
|
||||
import { TUNABLES } from '$lib/utils/tunables';
|
||||
|
||||
// Chrome renders HDR images with normally invisible seam lines in a regular
|
||||
// grid pattern. When the user pinch/scroll zooms, these seams become visible
|
||||
// and grow more prominent at higher zoom levels.
|
||||
//
|
||||
// Adding `will-change: transform` prevents the seams by converting the
|
||||
// element into a GPU texture that Chrome rasterizes once and reuses. But
|
||||
// this texture is frozen at a fixed resolution and never re-renders from
|
||||
// the source image, so zooming in magnifies the frozen texture rather than
|
||||
// the source, which can appear blurry.
|
||||
//
|
||||
// To keep the texture sharp, we size this div closer to the image's native
|
||||
// dimensions and apply a CSS counter-scale. Chrome renders these textures
|
||||
// as a grid of small tiles backed by a shared GPU memory budget — if the
|
||||
// texture is too large, tiles go missing and show up as transparent gaps.
|
||||
// We cap the texture size based on the device's GPU capability.
|
||||
//
|
||||
// This workaround is only needed in Chromium-based browsers. Firefox and
|
||||
// Safari use different rasterization pipelines and don't exhibit this bug.
|
||||
// See https://issues.chromium.org/issues/40084005
|
||||
const isChromium = 'chrome' in globalThis;
|
||||
|
||||
function getMaxRasterPixels() {
|
||||
const override = TUNABLES.IMAGE_RASTER.MAX_PIXELS;
|
||||
if (override > 0) {
|
||||
return override;
|
||||
}
|
||||
if (override < 0 || !isChromium) {
|
||||
return 0;
|
||||
}
|
||||
try {
|
||||
const canvas = document.createElement('canvas');
|
||||
const gl = canvas.getContext('webgl');
|
||||
const maxTextureSize = gl?.getParameter(gl.MAX_TEXTURE_SIZE) ?? 0;
|
||||
if (maxTextureSize >= 16_384) {
|
||||
return 16_000_000;
|
||||
}
|
||||
if (maxTextureSize >= 8192) {
|
||||
return 10_000_000;
|
||||
}
|
||||
return 4_000_000;
|
||||
} catch {
|
||||
return 4_000_000;
|
||||
}
|
||||
}
|
||||
|
||||
const maxRasterPixels = getMaxRasterPixels();
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import AlphaBackground from '$lib/components/AlphaBackground.svelte';
|
||||
import BrokenAsset from '$lib/components/assets/BrokenAsset.svelte';
|
||||
@@ -98,14 +149,27 @@
|
||||
return { width: 1, height: 1 };
|
||||
});
|
||||
|
||||
const { width, height, insetInlineStart, top } = $derived.by(() => {
|
||||
const { insetInlineStart, top, rasterWidth, rasterHeight, rasterScale } = $derived.by(() => {
|
||||
const scaleFn = objectFit === 'cover' ? scaleToCover : scaleToFit;
|
||||
const { width, height } = scaleFn(imageDimensions, container);
|
||||
if (maxRasterPixels === 0) {
|
||||
return {
|
||||
insetInlineStart: (container.width - width) / 2 + 'px',
|
||||
top: (container.height - height) / 2 + 'px',
|
||||
rasterWidth: width + 'px',
|
||||
rasterHeight: height + 'px',
|
||||
rasterScale: 1,
|
||||
};
|
||||
}
|
||||
const nativeRatio = imageDimensions.width / width;
|
||||
const budgetRatio = Math.sqrt(maxRasterPixels / Math.max(width * height, 1));
|
||||
const rasterRatio = Math.max(1, Math.min(nativeRatio, budgetRatio));
|
||||
return {
|
||||
width: width + 'px',
|
||||
height: height + 'px',
|
||||
insetInlineStart: (container.width - width) / 2 + 'px',
|
||||
top: (container.height - height) / 2 + 'px',
|
||||
rasterWidth: width * rasterRatio + 'px',
|
||||
rasterHeight: height * rasterRatio + 'px',
|
||||
rasterScale: 1 / rasterRatio,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -152,11 +216,14 @@
|
||||
{@render backdrop?.()}
|
||||
|
||||
<div
|
||||
class="pointer-events-none absolute inset-0"
|
||||
class="pointer-events-none absolute"
|
||||
style:inset-inline-start={insetInlineStart}
|
||||
style:top
|
||||
style:width
|
||||
style:height
|
||||
style:width={rasterWidth}
|
||||
style:height={rasterHeight}
|
||||
style:transform="scale({rasterScale})"
|
||||
style:transform-origin="0 0"
|
||||
style:will-change={maxRasterPixels > 0 ? 'transform' : undefined}
|
||||
>
|
||||
{#if show.alphaBackground}
|
||||
<AlphaBackground />
|
||||
@@ -174,8 +241,8 @@
|
||||
{#if show.thumbnail}
|
||||
<ImageLayer
|
||||
{adaptiveImageLoader}
|
||||
{width}
|
||||
{height}
|
||||
width={rasterWidth}
|
||||
height={rasterHeight}
|
||||
quality="thumbnail"
|
||||
src={status.urls.thumbnail}
|
||||
alt=""
|
||||
@@ -192,8 +259,8 @@
|
||||
<ImageLayer
|
||||
{adaptiveImageLoader}
|
||||
{alt}
|
||||
{width}
|
||||
{height}
|
||||
width={rasterWidth}
|
||||
height={rasterHeight}
|
||||
{overlays}
|
||||
quality="preview"
|
||||
src={status.urls.preview}
|
||||
@@ -205,8 +272,8 @@
|
||||
<ImageLayer
|
||||
{adaptiveImageLoader}
|
||||
{alt}
|
||||
{width}
|
||||
{height}
|
||||
width={rasterWidth}
|
||||
height={rasterHeight}
|
||||
{overlays}
|
||||
quality="original"
|
||||
src={status.urls.original}
|
||||
|
||||
@@ -31,4 +31,7 @@ export const TUNABLES = {
|
||||
IMAGE_THUMBNAIL: {
|
||||
THUMBHASH_FADE_DURATION: getNumber(storage.getItem('THUMBHASH_FADE_DURATION'), 100),
|
||||
},
|
||||
IMAGE_RASTER: {
|
||||
MAX_PIXELS: getNumber(storage.getItem('IMAGE_RASTER.MAX_PIXELS'), 0),
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user