mirror of
https://github.com/immich-app/immich.git
synced 2026-05-16 20:42:12 -04:00
Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 061a9ab120 | |||
| 02581e81a7 | |||
| 3ab3d5cf43 | |||
| 0ef04d9baa | |||
| df016f9228 | |||
| 17779c1e74 | |||
| 01d6a244d8 | |||
| 21d6755f39 | |||
| e91c017dd0 | |||
| 43687cd8b4 | |||
| 06729ee5a5 |
@@ -2,7 +2,7 @@ import { LoginResponseDto, ManualJobName } from '@immich/sdk';
|
||||
import { errorDto } from 'src/responses';
|
||||
import { app, utils } from 'src/utils';
|
||||
import request from 'supertest';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
describe('/admin/database-backups', () => {
|
||||
let cookie: string | undefined;
|
||||
@@ -13,6 +13,9 @@ describe('/admin/database-backups', () => {
|
||||
admin = await utils.adminSetup({
|
||||
onboarding: false,
|
||||
});
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await utils.resetBackups(admin.accessToken);
|
||||
});
|
||||
|
||||
|
||||
@@ -568,6 +568,8 @@ export const utils = {
|
||||
name: ManualJobName.BackupDatabase,
|
||||
});
|
||||
|
||||
await utils.waitForQueueFinish(accessToken, 'backupDatabase');
|
||||
|
||||
return utils.poll(
|
||||
() => request(app).get('/admin/database-backups').set('Authorization', `Bearer ${accessToken}`),
|
||||
({ status, body }) => status === 200 && body.backups.length === 1,
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -35,10 +35,11 @@ class BaseActionButton extends ConsumerWidget {
|
||||
final miniWidth = minWidth ?? (context.isMobile ? context.width / 4.5 : 75.0);
|
||||
final iconTheme = IconTheme.of(context);
|
||||
final iconSize = iconTheme.size ?? 24.0;
|
||||
final iconColor = this.iconColor ?? iconTheme.color ?? context.themeData.iconTheme.color;
|
||||
final textColor = context.themeData.textTheme.labelLarge?.color;
|
||||
|
||||
if (iconOnly) {
|
||||
final iconColor = this.iconColor ?? iconTheme.color ?? context.themeData.iconTheme.color;
|
||||
|
||||
return IconButton(
|
||||
onPressed: onPressed,
|
||||
icon: Icon(iconData, size: iconSize, color: iconColor),
|
||||
@@ -46,17 +47,21 @@ class BaseActionButton extends ConsumerWidget {
|
||||
}
|
||||
|
||||
if (menuItem) {
|
||||
final theme = context.themeData;
|
||||
final effectiveIconColor = iconColor ?? theme.iconTheme.color ?? theme.colorScheme.onSurfaceVariant;
|
||||
final iconColor = this.iconColor;
|
||||
|
||||
return MenuItemButton(
|
||||
style: MenuItemButton.styleFrom(alignment: Alignment.centerLeft, padding: const EdgeInsets.all(16)),
|
||||
leadingIcon: Icon(iconData, color: effectiveIconColor),
|
||||
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: theme.textTheme.labelLarge?.copyWith(fontSize: 16, color: iconColor)),
|
||||
child: Text(label, style: TextStyle(fontSize: 15, color: iconColor)),
|
||||
);
|
||||
}
|
||||
|
||||
final iconColor = this.iconColor ?? iconTheme.color ?? context.themeData.iconTheme.color;
|
||||
|
||||
return ConstrainedBox(
|
||||
constraints: BoxConstraints(maxWidth: maxWidth),
|
||||
child: MaterialButton(
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -50,7 +50,7 @@ class ViewerKebabMenu extends ConsumerWidget {
|
||||
timelineOrigin: timelineOrigin,
|
||||
);
|
||||
|
||||
final menuChildren = ActionButtonBuilder.buildViewerKebabMenu(actionContext, context);
|
||||
final menuChildren = ActionButtonBuilder.buildViewerKebabMenu(actionContext, context, ref);
|
||||
|
||||
return MenuAnchor(
|
||||
consumeOutsideTap: true,
|
||||
|
||||
@@ -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,
|
||||
@@ -330,7 +334,7 @@ class ActionButtonBuilder {
|
||||
return _actionTypes.where((type) => type.shouldShow(context)).map((type) => type.buildButton(context)).toList();
|
||||
}
|
||||
|
||||
static List<Widget> buildViewerKebabMenu(ActionButtonContext context, BuildContext buildContext) {
|
||||
static List<Widget> buildViewerKebabMenu(ActionButtonContext context, BuildContext buildContext, WidgetRef ref) {
|
||||
final visibleButtons = defaultViewerKebabMenuOrder
|
||||
.where((type) => !defaultViewerBottomBarButtons.contains(type) && type.shouldShow(context))
|
||||
.toList();
|
||||
@@ -346,7 +350,7 @@ class ActionButtonBuilder {
|
||||
if (lastGroup != null && type.kebabMenuGroup != lastGroup) {
|
||||
result.add(const Divider(height: 1));
|
||||
}
|
||||
result.add(type.buildButton(context, buildContext, false, true));
|
||||
result.add(type.buildButton(context, buildContext, false, true).build(buildContext, ref));
|
||||
lastGroup = type.kebabMenuGroup;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
+11
-16
@@ -37,29 +37,24 @@
|
||||
<a href="README_th_TH.md">ภาษาไทย</a>
|
||||
</p>
|
||||
|
||||
## تنصل
|
||||
> [!WARNING]
|
||||
> ⚠️ اتبع دائمًا خطة النسخ الاحتياطي [١-٢-٣](https://www.backblaze.com/blog/the-3-2-1-backup-strategy/) لصورك ومقاطع الفيديو الثمينة الخاصة بك
|
||||
>
|
||||
|
||||
- ⚠️ هذا التطبيق قيد التطوير النشط للغاية
|
||||
- ⚠️ توقع الأخطاء والتغييرات العاجلة
|
||||
- ⚠️ **لا تستخدم التطبيق باعتباره الطريقة الوحيدة لتخزين الصور ومقاطع الفيديو الخاصة بك**
|
||||
- ⚠️ اتبع دائمًا خطة النسخ الاحتياطي [١-٢-٣](https://www.backblaze.com/blog/the-3-2-1-backup-strategy/) لصورك ومقاطع الفيديو الثمينة الخاصة بك
|
||||
> [!NOTE]
|
||||
> يمكنك العثور على الوثائق الرئيسية، بما في ذلك أدلة التثبيت، على https://immich.app/
|
||||
|
||||
## روابط
|
||||
|
||||
## محتوى
|
||||
|
||||
- [الوثائق الرسمية](https://docs.immich.app)
|
||||
- [خريطة الطريق](https://github.com/orgs/immich-app/projects/1)
|
||||
- [تجريبي](#demo)
|
||||
- [سمات](#features)
|
||||
- [الوثائق الرسمية](https://docs.immich.app/)
|
||||
- [مقدمة](https://docs.immich.app/overview/introduction)
|
||||
- [تعليمات التحميل](https://docs.immich.app/install/requirements)
|
||||
- [خريطة الطريق](https://immich.app/roadmap)
|
||||
- [تجريبي](#تجريبي)
|
||||
- [سمات](#سمات)
|
||||
- [الترجمات](https://docs.immich.app/developer/translations)
|
||||
- [قواعد المساهمة](https://docs.immich.app/overview/support-the-project)
|
||||
|
||||
## توثيق
|
||||
|
||||
يمكنك العثور على الوثائق الرئيسية، بما في ذلك أدلة التثبيت، هنا
|
||||
https://immich.app
|
||||
|
||||
## تجريبي
|
||||
|
||||
يمكنك الوصول إلى العرض التوضيحي على الويب على
|
||||
|
||||
+10
-12
@@ -37,26 +37,24 @@
|
||||
<a href="README_th_TH.md">ภาษาไทย</a>
|
||||
</p>
|
||||
|
||||
## Avís legal
|
||||
> [!WARNING]
|
||||
> ⚠️ Always follow [3-2-1](https://www.backblaze.com/blog/the-3-2-1-backup-strategy/) backup plan for your precious photos and videos!
|
||||
>
|
||||
|
||||
- ⚠️ El projecte està en desenvolupament **molt actiu**.
|
||||
- ⚠️ Espereu errors i canvis que poden trencar coses.
|
||||
- ⚠️ **No utilitzeu l'aplicació com a única manera de guardar les vostres fotos i vídeos!**
|
||||
> [!NOTE]
|
||||
> Podeu trobar la documentació principal, incloent les guies d'instal·lació, a https://immich.app/.
|
||||
|
||||
## Contingut
|
||||
|
||||
- [Documentació oficial](https://docs.immich.app)
|
||||
- [Mapa de ruta](https://github.com/orgs/immich-app/projects/1)
|
||||
- [Demo](#demo)
|
||||
- [Funcionalitats](#funcionalitats)
|
||||
- [Documentació](https://docs.immich.app/)
|
||||
- [Introducció](https://docs.immich.app/overview/introduction)
|
||||
- [Instal·lació](https://docs.immich.app/install/requirements)
|
||||
- [Mapa de ruta](https://immich.app/roadmap)
|
||||
- [Demo](#demo)
|
||||
- [Funcionalitats](#funcionalitats)
|
||||
- [Traduccions](https://docs.immich.app/developer/translations)
|
||||
- [Directrius de contribució](https://docs.immich.app/overview/support-the-project)
|
||||
|
||||
## Documentació
|
||||
|
||||
Podeu trobar la documentació principal, incloent les guies d'instal·lació, a https://immich.app/.
|
||||
|
||||
## Demo
|
||||
|
||||
Podeu accedir a la demostració web a https://demo.immich.app. Per a l'aplicació mòbil, podeu utilitzar `https://demo.immich.app` com a "URL de punt final del servidor".
|
||||
|
||||
@@ -38,7 +38,9 @@
|
||||
<a href="README_th_TH.md">ภาษาไทย</a>
|
||||
</p>
|
||||
|
||||
- ⚠️ Befolge immer die [3-2-1](https://www.backblaze.com/blog/the-3-2-1-backup-strategy/) Backup-Regel für deine wertvollen Fotos und Videos!
|
||||
> [!WARNING]
|
||||
> ⚠️ Befolge immer die [3-2-1](https://www.backblaze.com/blog/the-3-2-1-backup-strategy/) Backup-Regel für deine wertvollen Fotos und Videos!
|
||||
>
|
||||
|
||||
> [!NOTE]
|
||||
> Die Hauptdokumentation, einschließlich der Installationsanleitungen, befinden sich unter https://immich.app/.
|
||||
@@ -49,7 +51,7 @@
|
||||
- [Offizielle Dokumentation](https://docs.immich.app)
|
||||
- [Über Immich](https://docs.immich.app/overview/introduction)
|
||||
- [Installation](https://docs.immich.app/install/requirements)
|
||||
- [Roadmap](https://github.com/orgs/immich-app/projects/1)
|
||||
- [Roadmap](https://immich.app/roadmap)
|
||||
- [Demo](#demo)
|
||||
- [Funktionen](#funktionen)
|
||||
- [Übersetzungen](https://docs.immich.app/developer/translations)
|
||||
|
||||
+10
-13
@@ -37,27 +37,24 @@
|
||||
<a href="README_th_TH.md">ภาษาไทย</a>
|
||||
</p>
|
||||
|
||||
## Advertencia
|
||||
> [!WARNING]
|
||||
> ⚠️ Siempre sigue el plan de backups [3-2-1](https://www.backblaze.com/blog/the-3-2-1-backup-strategy/) para tus fotos y videos.
|
||||
>
|
||||
|
||||
- ⚠️ El proyecto está en **activo desarrollo**.
|
||||
- ⚠️ Es probable que haya errores y cambios disruptivos.
|
||||
- ⚠️ **¡No utilices la aplicación como única forma de almacenar tus fotos y videos!**
|
||||
- ⚠️ Siempre sigue el plan de backups [3-2-1](https://www.backblaze.com/blog/the-3-2-1-backup-strategy/) para tus fotos y videos.
|
||||
> [!NOTE]
|
||||
> Puedes encontrar la documentación oficial, incluidas las guías de instalación, en <https://immich.app/>.
|
||||
|
||||
## Contenido
|
||||
|
||||
- [Documentación oficial](https://docs.immich.app)
|
||||
- [Hoja de ruta](https://github.com/orgs/immich-app/projects/1)
|
||||
- [Demo](#demo)
|
||||
- [Funciones](#funciones)
|
||||
- [Documentación](https://docs.immich.app/)
|
||||
- [Introducción](https://docs.immich.app/overview/introduction)
|
||||
- [Instalación](https://docs.immich.app/install/requirements)
|
||||
- [Hoja de ruta](https://immich.app/roadmap)
|
||||
- [Demo](#demo)
|
||||
- [Funciones](#funciones)
|
||||
- [Traducciones](https://docs.immich.app/developer/translations)
|
||||
- [Directrices para contribuir](https://docs.immich.app/overview/support-the-project)
|
||||
|
||||
## Documentación
|
||||
|
||||
Puedes encontrar la documentación oficial, incluidas las guías de instalación, en <https://immich.app/>.
|
||||
|
||||
## Demo
|
||||
|
||||
Puedes acceder a la demostración web en <https://demo.immich.app>. Para la aplicación móvil, puedes usar `https://demo.immich.app` en la `URL del servidor`.
|
||||
|
||||
+10
-13
@@ -37,27 +37,24 @@
|
||||
<a href="README_th_TH.md">ภาษาไทย</a>
|
||||
</p>
|
||||
|
||||
## Clause de non-responsabilité
|
||||
> [!WARNING]
|
||||
> ⚠️ Ayez toujours un plan de sauvegarde en [3-2-1](https://www.backblaze.com/blog/the-3-2-1-backup-strategy/) pour vos précieuses photos et vidéos !
|
||||
>
|
||||
|
||||
- ⚠️ Le projet est en **très fort** développement.
|
||||
- ⚠️ Attendez-vous à rencontrer des bogues et des changements importants.
|
||||
- ⚠️ **N'utilisez pas cette application comme seul support de sauvegarde de vos photos et vos vidéos.**
|
||||
- ⚠️ Ayez toujours un plan de sauvegarde en [3-2-1](https://www.seagate.com/fr/fr/blog/what-is-a-3-2-1-backup-strategy/) pour vos précieuses photos et vidéos !
|
||||
> [!NOTE]
|
||||
> Vous pouvez trouver la documentation principale ainsi que les guides d'installation sur https://immich.app/.
|
||||
|
||||
## Sommaire
|
||||
|
||||
- [Documentation officielle](https://docs.immich.app)
|
||||
- [Feuille de route](https://github.com/orgs/immich-app/projects/1)
|
||||
- [Démo](#démo)
|
||||
- [Fonctionnalités](#fonctionnalités)
|
||||
- [Documentation](https://docs.immich.app/)
|
||||
- [Introduction](https://docs.immich.app/overview/introduction)
|
||||
- [Installation](https://docs.immich.app/install/requirements)
|
||||
- [Feuille de route](https://immich.app/roadmap)
|
||||
- [Démo](#démo)
|
||||
- [Fonctionnalités](#fonctionnalités)
|
||||
- [Traductions](https://docs.immich.app/developer/translations)
|
||||
- [Contribution](https://docs.immich.app/overview/support-the-project)
|
||||
|
||||
## Documentation
|
||||
|
||||
Vous pouvez trouver la documentation principale ainsi que les guides d'installation sur https://immich.app/.
|
||||
|
||||
## Démo
|
||||
|
||||
Vous pouvez accéder à la démo en ligne sur https://demo.immich.app. Pour l'application mobile, vous pouvez utiliser `https://demo.immich.app` dans le champ `URL du point d'accès au serveur`
|
||||
|
||||
@@ -38,12 +38,9 @@
|
||||
<a href="README_th_TH.md">ภาษาไทย</a>
|
||||
</p>
|
||||
|
||||
## Avvertenze
|
||||
|
||||
- ⚠️ Il progetto è in fase di sviluppo **molto attivo**.
|
||||
- ⚠️ Possono esserci bug o cambiamenti radicali, che possono non essere retrocompatibili (breaking changes).
|
||||
- ⚠️ **Non usare l’app come unico modo per archiviare le tue foto e i tuoi video.**
|
||||
- ⚠️ Segui sempre la regola di backup [3-2-1](https://www.backblaze.com/blog/the-3-2-1-backup-strategy/) per proteggere i tuoi ricordi e le foto a cui tieni!
|
||||
> [!WARNING]
|
||||
> ⚠️ Segui sempre la regola di backup [3-2-1](https://www.backblaze.com/blog/the-3-2-1-backup-strategy/) per proteggere i tuoi ricordi e le foto a cui tieni!
|
||||
>
|
||||
|
||||
> [!NOTE]
|
||||
> La documentazione principale, comprese le guide all’installazione, si trova su https://immich.app/.
|
||||
|
||||
+10
-13
@@ -36,27 +36,24 @@
|
||||
<a href="README_th_TH.md">ภาษาไทย</a>
|
||||
</p>
|
||||
|
||||
## 免責事項
|
||||
> [!WARNING]
|
||||
> ⚠️ 大切な写真やビデオは、常に [3-2-1](https://www.backblaze.com/blog/the-3-2-1-backup-strategy/) のバックアッププランに従ってください!
|
||||
>
|
||||
|
||||
- ⚠️ このプロジェクトは **非常に活発に** 開発中です。
|
||||
- ⚠️ バグの存在や変更が入ることも予想されます。
|
||||
- ⚠️ **写真やビデオを保存する唯一の方法としてこのアプリを使用しないでください。**
|
||||
- ⚠️ 大切な写真やビデオは、常に [3-2-1](https://www.backblaze.com/blog/the-3-2-1-backup-strategy/) のバックアッププランに従ってください!
|
||||
> [!NOTE]
|
||||
> インストールガイドを含む主なドキュメントは、https://immich.app/ です。
|
||||
|
||||
## コンテンツ
|
||||
|
||||
- [公式ドキュメント](https://docs.immich.app)
|
||||
- [ロードマップ](https://github.com/orgs/immich-app/projects/1)
|
||||
- [デモ](#デモ)
|
||||
- [機能](#機能)
|
||||
- [公式ドキュメント](https://docs.immich.app/)
|
||||
- [紹介](https://docs.immich.app/overview/introduction)
|
||||
- [インストール](https://docs.immich.app/install/requirements)
|
||||
- [ロードマップ](https://immich.app/roadmap)
|
||||
- [デモ](#デモ)
|
||||
- [機能](#機能)
|
||||
- [翻訳](https://docs.immich.app/developer/translations)
|
||||
- [コントリビューションガイド](https://docs.immich.app/overview/support-the-project)
|
||||
|
||||
## ドキュメント
|
||||
|
||||
インストールガイドを含む主なドキュメントは、https://immich.app/ です。
|
||||
|
||||
## デモ
|
||||
|
||||
web デモは https://demo.immich.app からアクセスできます。モバイルアプリの場合、`Server Endpoint URL` には `https://demo.immich.app` を使用することができます
|
||||
|
||||
@@ -39,12 +39,9 @@
|
||||
|
||||
</p>
|
||||
|
||||
## 주의 사항
|
||||
|
||||
- ⚠️ 이 프로젝트는 **매우 활발하게** 개발 중입니다.
|
||||
- ⚠️ 버그와 잦은 변경 사항이 있을 것으로 예상됩니다.
|
||||
- ⚠️ **사진과 동영상을 이 앱에만 단독으로 저장하지 마세요.**
|
||||
- ⚠️ 중요한 사진과 동영상을 위해 항상 [3-2-1](https://www.backblaze.com/blog/the-3-2-1-backup-strategy/) 백업 계획을 따르세요!
|
||||
> [!WARNING]
|
||||
> ⚠️ 중요한 사진과 동영상을 위해 항상 [3-2-1](https://www.backblaze.com/blog/the-3-2-1-backup-strategy/) 백업 계획을 따르세요!
|
||||
>
|
||||
|
||||
> [!NOTE]
|
||||
> 설치하는 방법을 포함한 주요 문서는 https://immich.app/ 에서 확인할 수 있습니다.
|
||||
@@ -57,7 +54,7 @@
|
||||
- [로드맵](https://immich.app/roadmap)
|
||||
- [데모](#데모)
|
||||
- [기능](#기능)
|
||||
- [번역](https://docs.immich.app/developer/tranlations)
|
||||
- [번역](https://docs.immich.app/developer/translations)
|
||||
- [기여](https://docs.immich.app/overview/support-the-project)
|
||||
|
||||
## 데모
|
||||
|
||||
+10
-13
@@ -37,27 +37,24 @@
|
||||
<a href="README_th_TH.md">ภาษาไทย</a>
|
||||
</p>
|
||||
|
||||
## Disclaimer
|
||||
> [!WARNING]
|
||||
> ⚠️ Volg altijd het [3-2-1](https://www.backblaze.com/blog/the-3-2-1-backup-strategy/) backup plan voor je kostbare foto's en video's!
|
||||
>
|
||||
|
||||
- ⚠️ Het project wordt momenteel **zeer actief** ontwikkeld.
|
||||
- ⚠️ Verwacht bugs en ingrijpende wijzigingen.
|
||||
- ⚠️ **Gebruik de app niet als de enige manier om uw foto's en video's op te slaan.**
|
||||
- ⚠️ Volg altijd het [3-2-1](https://www.backblaze.com/blog/the-3-2-1-backup-strategy/) backup plan voor je kostbare foto's en video's!
|
||||
> [!NOTE]
|
||||
> De belangrijkste documentatie, inclusief installatie handleidingen, zijn te vinden op https://immich.app/.
|
||||
|
||||
## Inhoud
|
||||
|
||||
- [Officiële documentatie](https://docs.immich.app)
|
||||
- [Toekomstplannen](https://github.com/orgs/immich-app/projects/1)
|
||||
- [Demo](#demo)
|
||||
- [Functies](#functies)
|
||||
- [Officiële documentatie](https://docs.immich.app/)
|
||||
- [Introductie](https://docs.immich.app/overview/introduction)
|
||||
- [Installatie](https://docs.immich.app/install/requirements)
|
||||
- [Toekomstplannen](https://immich.app/roadmap)
|
||||
- [Demo](#demo)
|
||||
- [Functies](#functies)
|
||||
- [Vertalingen](https://docs.immich.app/developer/translations)
|
||||
- [Richtlijnen voor bijdragen](https://docs.immich.app/overview/support-the-project)
|
||||
|
||||
## Documentatie
|
||||
|
||||
De belangrijkste documentatie, inclusief installatie handleidingen, zijn te vinden op https://immich.app/.
|
||||
|
||||
## Demo
|
||||
|
||||
Je kunt de demo [hier](https://demo.immich.app/) bekijken. Voor de mobiele app kun je gebruik maken van `https://demo.immich.app` voor de `Server Endpoint URL`.
|
||||
|
||||
@@ -40,16 +40,9 @@
|
||||
|
||||
</p>
|
||||
|
||||
## Avisos
|
||||
|
||||
- ⚠️ Este projeto está sob **desenvolvimento constante**.
|
||||
- ⚠️ Podem ocorrer bugs e _breaking changes_ (alterações que quebram a
|
||||
compatibilidade com versões anteriores).
|
||||
- ⚠️ **Não use esta solução como a única forma de fazer backup das suas fotos e
|
||||
vídeos.**
|
||||
- ⚠️ Sempre siga o plano
|
||||
[3-2-1](https://www.backblaze.com/blog/the-3-2-1-backup-strategy/) de backup
|
||||
para as suas mídias preciosas!
|
||||
> [!WARNING]
|
||||
> ⚠️ Sempre siga o plano [3-2-1](https://www.backblaze.com/blog/the-3-2-1-backup-strategy/) de backup para as suas mídias preciosas!
|
||||
>
|
||||
|
||||
> [!NOTE]
|
||||
> Você pode encontrar a documentação principal, incluindo guias de instalação, em https://immich.app/.
|
||||
|
||||
@@ -39,13 +39,9 @@
|
||||
<a href="README_th_TH.md">ภาษาไทย</a>
|
||||
</p>
|
||||
|
||||
## Предупреждение
|
||||
|
||||
- ⚠️ Этот проект находится **в очень активной** разработке.
|
||||
- ⚠️ Ожидайте недоработки и глобальные изменения.
|
||||
- ⚠️ **Не используйте это приложение как единственное хранилище своих фото и видео.**
|
||||
- ⚠️ Всегда следуйте [плану резервного копирования «3-2-1»](https://www.backblaze.com/blog/the-3-2-1-backup-strategy/ "Стратегии резервного копирования: Почему стратегия резервного копирования «3-2-1» — лучшая") для ваших драгоценных фотографий и видео!
|
||||
|
||||
> [!WARNING]
|
||||
> ⚠️ Всегда следуйте [плану резервного копирования «3-2-1»](https://www.backblaze.com/blog/the-3-2-1-backup-strategy/) для ваших драгоценных фотографий и видео!
|
||||
>
|
||||
|
||||
> [!NOTE]
|
||||
> Инструкции по установке и документация по ссылке https://immich.app/
|
||||
@@ -55,7 +51,7 @@
|
||||
- [Официальная документация](https://docs.immich.app)
|
||||
- [Введение](https://docs.immich.app/overview/introduction)
|
||||
- [Установка](https://docs.immich.app/install/requirements)
|
||||
- [План разработки](https://github.com/orgs/immich-app/projects/1)
|
||||
- [План разработки](https://immich.app/roadmap)
|
||||
- [Демо](#demo)
|
||||
- [Возможности](#features)
|
||||
- [Перевод](https://docs.immich.app/developer/translations)
|
||||
|
||||
+10
-13
@@ -38,27 +38,24 @@
|
||||
<a href="README_th_TH.md">ภาษาไทย</a>
|
||||
</p>
|
||||
|
||||
## Ansvarsfriskrivning
|
||||
> [!WARNING]
|
||||
> ⚠️ Tillämpa alltid [3-2-1](https://www.backblaze.com/blog/the-3-2-1-backup-strategy/)-strategin för säkerhetskopiering av dina foton och videor!
|
||||
>
|
||||
|
||||
- ⚠️ Projektet är under **mycket aktiv** utveckling.
|
||||
- ⚠️ Förvänta dig buggar och brytande förändringar.
|
||||
- ⚠️ **Använd inte appen som enda lagringssätt för dina foton och videor.**
|
||||
- ⚠️ Tillämpa alltid [3-2-1](https://www.backblaze.com/blog/the-3-2-1-backup-strategy/)-strategin för säkerhetskopiering av dina foton och videor!
|
||||
> [!NOTE]
|
||||
> Dokumentation och installationsguider hittas på https://immich.app/.
|
||||
|
||||
## Innehåll
|
||||
|
||||
- [Officiell Dokumentation](https://docs.immich.app)
|
||||
- [Roadmap](https://github.com/orgs/immich-app/projects/1)
|
||||
- [Demo](#demo)
|
||||
- [Funktioner](#features)
|
||||
- [Officiell Dokumentation](https://docs.immich.app/)
|
||||
- [Introduktion](https://docs.immich.app/overview/introduction)
|
||||
- [Installation](https://docs.immich.app/install/requirements)
|
||||
- [Roadmap](https://immich.app/roadmap)
|
||||
- [Demo](#demo)
|
||||
- [Funktioner](#funktioner)
|
||||
- [Översättningar](https://docs.immich.app/developer/translations)
|
||||
- [Riktlinjer för Bidrag](https://docs.immich.app/overview/support-the-project)
|
||||
|
||||
## Dokumentation
|
||||
|
||||
Dokumentation och installationsguider hittas på https://imiich.app/.
|
||||
|
||||
## Demo
|
||||
|
||||
Ett webb-demo finns att testa på https://demo.immich.app. Använd `https://demo.immich.app` i mobilappen som `Server Endpoint URL`
|
||||
|
||||
+11
-13
@@ -37,26 +37,24 @@
|
||||
<a href="README_th_TH.md">ภาษาไทย</a>
|
||||
</p>
|
||||
|
||||
## Feragatname
|
||||
> [!WARNING]
|
||||
> ⚠️ Always follow [3-2-1](https://www.backblaze.com/blog/the-3-2-1-backup-strategy/) backup plan for your precious photos and videos!
|
||||
>
|
||||
|
||||
- ⚠️ Proje **çok aktif** bir şekilde geliştirilmektedir.
|
||||
- ⚠️ Hatalar ve uygulama yapısını bozan değişiklikler olabilir.
|
||||
- ⚠️ **Uygulamayı, fotoğraflarınızı ve videolarınızı saklamanın tek yöntemi olarak kullanmayın!**
|
||||
> [!NOTE]
|
||||
> Kurulum dahil olmak üzere resmi belgeleri https://immich.app/ adresinde bulabilirsiniz.
|
||||
|
||||
## Content
|
||||
## Bağlantılar
|
||||
|
||||
- [Resmi Belgeler](https://docs.immich.app)
|
||||
- [Yol Haritası](https://github.com/orgs/immich-app/projects/1)
|
||||
- [Demo](#demo)
|
||||
- [Özellikler](#özellikler)
|
||||
- [Resmi Belgeler](https://docs.immich.app/)
|
||||
- [Giriş](https://docs.immich.app/overview/introduction)
|
||||
- [Kurulum](https://docs.immich.app/install/requirements)
|
||||
- [Yol Haritası](https://immich.app/roadmap)
|
||||
- [Demo](#demo)
|
||||
- [Özellikler](#özellikler)
|
||||
- [Çeviriler](https://docs.immich.app/developer/translations)
|
||||
- [Katkı Sağlama Rehberi](https://docs.immich.app/overview/support-the-project)
|
||||
|
||||
## Belgeler
|
||||
|
||||
Kurulum dahil olmak üzere resmi belgeleri https://immich.app/ adresinde bulabilirsiniz.
|
||||
|
||||
## Demo
|
||||
|
||||
Web demo adresi: https://demo.immich.app. Mobil uygulama için `Server Endpoint URL` olarak `https://demo.immich.app` adresini kullanabilirsiniz.
|
||||
|
||||
@@ -39,12 +39,9 @@
|
||||
<a href="README_th_TH.md">ภาษาไทย</a>
|
||||
</p>
|
||||
|
||||
## Застереження
|
||||
|
||||
- ⚠️ Цей проєкт перебуває **в дуже активній** розробці.
|
||||
- ⚠️ Очікуйте безліч помилок і глобальних змін.
|
||||
- ⚠️ **Не використовуйте цей застосунок як єдине сховище своїх фото та відео.**
|
||||
- ⚠️ Завжди дотримуйтесь [плану резервного копіювання 3-2-1](https://www.backblaze.com/blog/the-3-2-1-backup-strategy/) для ваших дорогоцінних фотографій та відео!
|
||||
> [!WARNING]
|
||||
> ⚠️ Завжди дотримуйтесь [плану резервного копіювання 3-2-1](https://www.backblaze.com/blog/the-3-2-1-backup-strategy/) для ваших дорогоцінних фотографій та відео!
|
||||
>
|
||||
|
||||
> [!NOTE]
|
||||
> Основну документацію, зокрема посібники зі встановлення, можна знайти за адресою https://immich.app/.
|
||||
|
||||
@@ -41,12 +41,9 @@
|
||||
|
||||
</p>
|
||||
|
||||
## Tuyên bố miễn trừ trách nhiệm
|
||||
|
||||
- ⚠️ Dự án đang được phát triển **rất tích cực**.
|
||||
- ⚠️ Dự kiến sẽ có lỗi và thay đổi đột ngột.
|
||||
- ⚠️ **Không sử dụng ứng dụng như là cách duy nhất để lưu trữ ảnh và video của bạn.**
|
||||
- ⚠️ Luôn tuân thủ kế hoạch sao lưu [3-2-1](https://www.backblaze.com/blog/the-3-2-1-backup-strategy/) cho những bức ảnh và video quý giá của bạn!
|
||||
> [!WARNING]
|
||||
> ⚠️ Luôn tuân thủ kế hoạch sao lưu [3-2-1](https://www.backblaze.com/blog/the-3-2-1-backup-strategy/) cho những bức ảnh và video quý giá của bạn!
|
||||
>
|
||||
|
||||
> [!NOTE]
|
||||
> Bạn có thể tìm thấy tài liệu chính, bao gồm hướng dẫn cài đặt, tại https://immich.app/.
|
||||
|
||||
@@ -43,12 +43,9 @@
|
||||
|
||||
</p>
|
||||
|
||||
## 免责声明
|
||||
|
||||
- ⚠️ 本项目正在 **非常活跃** 地开发中。
|
||||
- ⚠️ 可能存在 bug 或者随时有重大变更。
|
||||
- ⚠️ **不要把本软件作为您存储照片或视频的唯一方式。**
|
||||
- ⚠️ 为了您宝贵的照片与视频,请始终遵守 [3-2-1](https://www.backblaze.com/blog/the-3-2-1-backup-strategy/) 备份方案!
|
||||
> [!WARNING]
|
||||
> ⚠️ 为了您宝贵的照片与视频,请始终遵守 [3-2-1](https://www.backblaze.com/blog/the-3-2-1-backup-strategy/) 备份方案!
|
||||
>
|
||||
|
||||
> [!NOTE]
|
||||
> 完整的项目文档以及安装教程请参见:<https://immich.app/>。
|
||||
|
||||
@@ -171,8 +171,8 @@ export class JobRepository {
|
||||
options: this.getJobOptions(item) || undefined,
|
||||
} as JobItem & { data: any; options: JobsOptions | undefined };
|
||||
|
||||
if (job.options?.jobId) {
|
||||
// need to use add() instead of addBulk() for jobId deduplication
|
||||
if (job.options?.jobId || job.options?.deduplication) {
|
||||
// need to use add() instead of addBulk() for jobId/deduplication to take effect
|
||||
promises.push(this.getQueue(queueName).add(item.name, item.data, job.options));
|
||||
} else {
|
||||
itemsByQueue[queueName] = itemsByQueue[queueName] || [];
|
||||
@@ -230,10 +230,13 @@ export class JobRepository {
|
||||
return { priority: 1 };
|
||||
}
|
||||
case JobName.FacialRecognitionQueueAll: {
|
||||
return { jobId: JobName.FacialRecognitionQueueAll };
|
||||
return { deduplication: { id: JobName.FacialRecognitionQueueAll } };
|
||||
}
|
||||
case JobName.VersionCheck: {
|
||||
return { jobId: JobName.VersionCheck };
|
||||
return { deduplication: { id: JobName.VersionCheck } };
|
||||
}
|
||||
case JobName.DatabaseBackup: {
|
||||
return { deduplication: { id: JobName.DatabaseBackup } };
|
||||
}
|
||||
default: {
|
||||
return null;
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
};
|
||||
|
||||
@@ -4,15 +4,18 @@
|
||||
import OnEvents from '$lib/components/OnEvents.svelte';
|
||||
import EmptyPlaceholder from '$lib/components/shared-components/EmptyPlaceholder.svelte';
|
||||
import SingleGridRow from '$lib/components/shared-components/SingleGridRow.svelte';
|
||||
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
|
||||
import { Route } from '$lib/route';
|
||||
import { getAssetMediaUrl, getPeopleThumbnailUrl } from '$lib/utils';
|
||||
import { AssetMediaSize, type SearchExploreResponseDto } from '@immich/sdk';
|
||||
import { getAssetInfo, AssetMediaSize, type SearchExploreResponseDto } from '@immich/sdk';
|
||||
import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||
import { Icon } from '@immich/ui';
|
||||
import { mdiHeart } from '@mdi/js';
|
||||
import { t } from 'svelte-i18n';
|
||||
import type { PageData } from './$types';
|
||||
import { toTimelineAsset } from '$lib/utils/timeline-util';
|
||||
import { getAltText } from '$lib/utils/thumbnail-util';
|
||||
import Portal from '$lib/elements/Portal.svelte';
|
||||
|
||||
interface Props {
|
||||
data: PageData;
|
||||
@@ -40,6 +43,15 @@
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const onViewAsset = async (id: string) => {
|
||||
const asset = await getAssetInfo({ ...authManager.params, id });
|
||||
assetViewerManager.setAsset(asset);
|
||||
};
|
||||
|
||||
const assetCursor = $derived({
|
||||
current: assetViewerManager.asset!,
|
||||
});
|
||||
</script>
|
||||
|
||||
<OnEvents {onPersonThumbnailReady} />
|
||||
@@ -122,15 +134,20 @@
|
||||
draggable="false">{$t('view_all')}</a
|
||||
>
|
||||
</div>
|
||||
<div class="flex h-24 flex-wrap gap-x-1 overflow-hidden md:h-42">
|
||||
<div class="flex h-24 max-w-fit flex-wrap gap-x-1 overflow-hidden md:h-42">
|
||||
{#each recents as item (item.data.id)}
|
||||
<a class="relative h-full flex-auto" href={Route.viewAsset({ id: item.data.id })} draggable="false">
|
||||
<button
|
||||
type="button"
|
||||
class="relative h-full flex-auto"
|
||||
onclick={() => onViewAsset(item.data.id)}
|
||||
draggable="false"
|
||||
>
|
||||
<img
|
||||
src={getAssetMediaUrl({ id: item.data.id, size: AssetMediaSize.Thumbnail })}
|
||||
alt={$getAltText(toTimelineAsset(item.data))}
|
||||
class="size-full min-w-max rounded-xl object-cover"
|
||||
/>
|
||||
</a>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
@@ -140,3 +157,15 @@
|
||||
<EmptyPlaceholder text={$t('no_explore_results_message')} class="mx-auto mt-10" />
|
||||
{/if}
|
||||
</UserPageLayout>
|
||||
|
||||
{#if assetViewerManager.isViewing}
|
||||
{#await import('$lib/components/asset-viewer/AssetViewer.svelte') then { default: AssetViewer }}
|
||||
<Portal target="body">
|
||||
<AssetViewer
|
||||
cursor={assetCursor}
|
||||
showNavigation={false}
|
||||
onClose={() => assetViewerManager.showAssetViewer(false)}
|
||||
/>
|
||||
</Portal>
|
||||
{/await}
|
||||
{/if}
|
||||
|
||||
Reference in New Issue
Block a user