mirror of
https://github.com/immich-app/immich.git
synced 2026-07-06 12:36:55 -07:00
Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 70c594dbeb | |||
| d734b354af | |||
| 237734bb26 | |||
| 4b54fef82e | |||
| 0050332391 |
@@ -91,12 +91,14 @@ describe('/server', () => {
|
||||
it('should respond with the server version', async () => {
|
||||
const { status, body } = await request(app).get('/server/version');
|
||||
expect(status).toBe(200);
|
||||
expect(body).toEqual({
|
||||
major: expect.any(Number),
|
||||
minor: expect.any(Number),
|
||||
patch: expect.any(Number),
|
||||
prerelease: expect.anything(),
|
||||
});
|
||||
expect(body).toEqual(
|
||||
expect.objectContaining({
|
||||
major: expect.any(Number),
|
||||
minor: expect.any(Number),
|
||||
patch: expect.any(Number),
|
||||
}),
|
||||
);
|
||||
expect(Object.keys(body)).toEqual(expect.arrayContaining(['major', 'minor', 'patch', 'prerelease']));
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -82,6 +82,9 @@ export const setupBaseMockApiRoutes = async (context: BrowserContext, adminUserI
|
||||
cast: {
|
||||
gCastEnabled: false,
|
||||
},
|
||||
recentlyAdded: {
|
||||
sidebarWeb: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -110,6 +110,7 @@ export async function enableTagsPreference(context: BrowserContext) {
|
||||
download: { archiveSize: 4_294_967_296, includeEmbeddedVideos: false },
|
||||
purchase: { showSupportBadge: true, hideBuyButtonUntil: '2100-02-12T00:00:00.000Z' },
|
||||
cast: { gCastEnabled: false },
|
||||
recentlyAdded: { sidebarWeb: false },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1718,6 +1718,7 @@
|
||||
"recent_searches": "Recent searches",
|
||||
"recently_added": "Recently added",
|
||||
"recently_added_body": "Jump straight to everything you've added lately on a dedicated page.",
|
||||
"recently_added_description": "Browse your assets sorted by when they were uploaded to Immich",
|
||||
"recently_added_page_title": "Recently Added",
|
||||
"recently_added_title": "Recently added",
|
||||
"recently_taken": "Recently taken",
|
||||
|
||||
+29
-1
@@ -6,7 +6,7 @@
|
||||
"action": "Aksion",
|
||||
"action_common_update": "Përditëso",
|
||||
"action_description": "Një grup veprimesh për t'u kryer në asetet e filtruara",
|
||||
"actions": "Aksione",
|
||||
"actions": "Veprime",
|
||||
"active": "Aktiv",
|
||||
"active_count": "Aktive: {count}",
|
||||
"activity": "Aktivitet",
|
||||
@@ -536,6 +536,11 @@
|
||||
"api_keys": "Çelësat API",
|
||||
"app_architecture_variant": "Varianta (Arkitektura)",
|
||||
"app_bar_signout_dialog_content": "A je i sigurt që dëshiron të dalësh?",
|
||||
"back": "Mbrapa",
|
||||
"close": "Mbyll",
|
||||
"copy_image": "Kopjo imazhin",
|
||||
"dark": "E errët",
|
||||
"disabled": "I çaktivizuar",
|
||||
"download_original": "Shkarko origjinalin",
|
||||
"download_paused": "Shkarkimi u pezullua",
|
||||
"download_settings": "Shkarko",
|
||||
@@ -544,6 +549,29 @@
|
||||
"downloading_asset_filename": "Duke shkarkuar asetin {filename}",
|
||||
"downloading_from_icloud": "Duke shkarkuar nga iCloud",
|
||||
"downloading_media": "Duke shkarkuar median",
|
||||
"enable": "Aktivizo",
|
||||
"error": "Gabim",
|
||||
"expired": "Skaduar",
|
||||
"image": "Imazhi",
|
||||
"info": "Info",
|
||||
"model": "Modeli",
|
||||
"name": "Emri",
|
||||
"none": "Asnjë",
|
||||
"offline": "Jashtë linje",
|
||||
"ok": "Në rregull",
|
||||
"online": "Online",
|
||||
"path": "Shtegu",
|
||||
"refresh": "Rifresko",
|
||||
"rename": "Riemërto",
|
||||
"search": "Kërko",
|
||||
"settings": "Cilësimet",
|
||||
"size": "Madhësia",
|
||||
"status": "Statusi",
|
||||
"type": "Lloji",
|
||||
"unknown": "E panjohur",
|
||||
"upload_finished": "Ngarkimi përfundoi",
|
||||
"username": "Emri i përdoruesit",
|
||||
"version": "Versioni",
|
||||
"you_dont_have_any_shared_links": "Nuk keni asnjë link të shpërndarë",
|
||||
"your_wifi_name": "Emri i Wi-Fi tuaj",
|
||||
"zoom_image": "Zmadho imazhin"
|
||||
|
||||
@@ -107,3 +107,71 @@ Java_app_alextran_immich_NativeImage_rotate(
|
||||
}
|
||||
return (jlong) dst;
|
||||
}
|
||||
|
||||
// Convert an RGBA_1010102 buffer to densely-packed RGBA_8888, matching Skia's
|
||||
// Bitmap.copy(ARGB_8888) byte-for-byte so it's a drop-in for the intermediate 8888 bitmap.
|
||||
// Each source pixel is a native (little-endian on every Android ABI) u32 with R in bits 0-9,
|
||||
// G in 10-19, B in 20-29, A in 30-31 (standard RGB10_A2). Each 10-bit channel maps to 8-bit via
|
||||
// round(v*255/1023) through a small L1-resident LUT; the 2-bit alpha maps a*85. Output is R,G,B,A
|
||||
// bytes per pixel, i.e. Android ARGB_8888 memory == Dart PixelFormat.rgba8888.
|
||||
static void convert_1010102(const uint8_t *src, int srcStride, uint32_t *dst, int w, int h) {
|
||||
uint8_t scale[1024];
|
||||
for (int v = 0; v < 1024; v++) {
|
||||
scale[v] = (uint8_t) ((v * 255 + 511) / 1023);
|
||||
}
|
||||
static const uint8_t alpha[4] = {0, 85, 170, 255};
|
||||
for (int y = 0; y < h; y++) {
|
||||
const uint32_t *srcRow = (const uint32_t *) (src + (size_t) y * srcStride);
|
||||
uint32_t *dstRow = dst + (size_t) y * w;
|
||||
for (int x = 0; x < w; x++) {
|
||||
uint32_t px = srcRow[x];
|
||||
uint32_t r = scale[px & 0x3FF];
|
||||
uint32_t g = scale[(px >> 10) & 0x3FF];
|
||||
uint32_t b = scale[(px >> 20) & 0x3FF];
|
||||
uint32_t a = alpha[(px >> 30) & 0x3];
|
||||
dstRow[x] = r | (g << 8) | (b << 16) | (a << 24);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Converts an RGBA_1010102 bitmap (what a 10-bit HEIC/AVIF decodes to on API 33+) into a freshly
|
||||
// malloc'd RGBA_8888 buffer. Fills outInfo with {width, height, rowBytes} and returns the buffer
|
||||
// address, or 0 (so the caller falls back to a Skia copy) if the bitmap isn't 1010102 or can't be
|
||||
// locked. Same ownership contract as rotate: free the returned buffer via NativeBuffer.free.
|
||||
JNIEXPORT jlong JNICALL
|
||||
Java_app_alextran_immich_NativeImage_convert1010102(
|
||||
JNIEnv *env, jclass clazz, jobject bitmap, jintArray outInfo) {
|
||||
AndroidBitmapInfo info;
|
||||
if (AndroidBitmap_getInfo(env, bitmap, &info) != ANDROID_BITMAP_RESULT_SUCCESS) {
|
||||
return 0;
|
||||
}
|
||||
if (info.format != ANDROID_BITMAP_FORMAT_RGBA_1010102) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
int w = (int) info.width;
|
||||
int h = (int) info.height;
|
||||
|
||||
uint32_t *dst = (uint32_t *) malloc((size_t) w * h * 4);
|
||||
if (dst == NULL) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
void *srcPixels = NULL;
|
||||
if (AndroidBitmap_lockPixels(env, bitmap, &srcPixels) != ANDROID_BITMAP_RESULT_SUCCESS) {
|
||||
free(dst);
|
||||
return 0;
|
||||
}
|
||||
|
||||
convert_1010102((const uint8_t *) srcPixels, (int) info.stride, dst, w, h);
|
||||
|
||||
AndroidBitmap_unlockPixels(env, bitmap);
|
||||
|
||||
jint dims[3] = {w, h, w * 4};
|
||||
(*env)->SetIntArrayRegion(env, outInfo, 0, 3, dims);
|
||||
if ((*env)->ExceptionCheck(env)) {
|
||||
free(dst);
|
||||
return 0;
|
||||
}
|
||||
return (jlong) dst;
|
||||
}
|
||||
|
||||
@@ -16,4 +16,14 @@ object NativeImage {
|
||||
*/
|
||||
@JvmStatic
|
||||
external fun rotate(bitmap: Bitmap, orientation: Int, outInfo: IntArray): Long
|
||||
|
||||
/**
|
||||
* Converts an RGBA_1010102 [bitmap] (what a 10-bit HEIC/AVIF decodes to on API 33+) to RGBA_8888,
|
||||
* writing the result into a freshly malloc'd native buffer in one pass, with no intermediate
|
||||
* ARGB_8888 bitmap. Returns the buffer address (free it with [NativeBuffer.free]) and fills
|
||||
* [outInfo] with {width, height, rowBytes}. Returns 0 when the bitmap isn't RGBA_1010102 so the
|
||||
* caller can fall back to a Skia copy.
|
||||
*/
|
||||
@JvmStatic
|
||||
external fun convert1010102(bitmap: Bitmap, outInfo: IntArray): Long
|
||||
}
|
||||
|
||||
@@ -46,22 +46,47 @@ inline fun ImageDecoder.Source.decodeBitmap(target: Size = Size(0, 0)): Bitmap {
|
||||
}
|
||||
|
||||
fun Bitmap.toNativeBuffer(): Map<String, Long> {
|
||||
val size = width * height * 4
|
||||
// Dart reads the buffer as rgba8888, but 10-bit sources decode to RGBA_1010102, which garbles
|
||||
// colors when copied verbatim. Convert those straight into the output buffer in native code -
|
||||
// one pass, no intermediate ARGB_8888 bitmap.
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && config == Bitmap.Config.RGBA_1010102) {
|
||||
val info = IntArray(3)
|
||||
val pointer = NativeImage.convert1010102(this, info)
|
||||
if (pointer != 0L) {
|
||||
recycle()
|
||||
return mapOf(
|
||||
"pointer" to pointer,
|
||||
"width" to info[0].toLong(),
|
||||
"height" to info[1].toLong(),
|
||||
"rowBytes" to info[2].toLong()
|
||||
)
|
||||
}
|
||||
// native convert declined (OOM/lock) -> fall through to the Skia copy path below.
|
||||
}
|
||||
// Other non-8888 configs (e.g. HDR F16) still need Skia's convert; 8-bit is copied as-is.
|
||||
val bitmap = if (config != Bitmap.Config.ARGB_8888) {
|
||||
val converted = copy(Bitmap.Config.ARGB_8888, false)
|
||||
recycle()
|
||||
converted ?: throw IOException("could not convert bitmap to ARGB_8888")
|
||||
} else {
|
||||
this
|
||||
}
|
||||
val size = bitmap.width * bitmap.height * 4
|
||||
val pointer = NativeBuffer.allocate(size)
|
||||
try {
|
||||
val buffer = NativeBuffer.wrap(pointer, size)
|
||||
copyPixelsToBuffer(buffer)
|
||||
bitmap.copyPixelsToBuffer(buffer)
|
||||
return mapOf(
|
||||
"pointer" to pointer,
|
||||
"width" to width.toLong(),
|
||||
"height" to height.toLong(),
|
||||
"rowBytes" to (width * 4).toLong()
|
||||
"width" to bitmap.width.toLong(),
|
||||
"height" to bitmap.height.toLong(),
|
||||
"rowBytes" to (bitmap.width * 4).toLong()
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
} catch (e: Throwable) {
|
||||
NativeBuffer.free(pointer)
|
||||
throw e
|
||||
} finally {
|
||||
recycle()
|
||||
bitmap.recycle()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package app.alextran.immich.images
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.ImageDecoder
|
||||
import android.os.Build
|
||||
import android.os.CancellationSignal
|
||||
import android.os.OperationCanceledException
|
||||
import app.alextran.immich.INITIAL_BUFFER_SIZE
|
||||
@@ -22,6 +24,7 @@ import java.io.File
|
||||
import java.io.IOException
|
||||
import java.nio.ByteBuffer
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import java.util.concurrent.Executors
|
||||
|
||||
private const val MAX_PREALLOC_BYTES = 128 * 1024 * 1024
|
||||
|
||||
@@ -36,12 +39,16 @@ class RemoteImagesImpl(context: Context) : RemoteImageApi {
|
||||
|
||||
companion object {
|
||||
val CANCELLED = Result.success<Map<String, Long>?>(null)
|
||||
|
||||
// Shared, process-lifetime pool: RemoteImagesImpl is re-created per FlutterEngine, so a
|
||||
// per-instance pool would leak threads across engine restarts.
|
||||
private val decodeExecutor = Executors.newFixedThreadPool(2)
|
||||
}
|
||||
|
||||
override fun requestImage(
|
||||
url: String,
|
||||
requestId: Long,
|
||||
@Suppress("UNUSED_PARAMETER") preferEncoded: Boolean, // always returns encoded; setting has no effect on Android
|
||||
preferEncoded: Boolean,
|
||||
callback: (Result<Map<String, Long>?>) -> Unit
|
||||
) {
|
||||
val signal = CancellationSignal()
|
||||
@@ -51,12 +58,51 @@ class RemoteImagesImpl(context: Context) : RemoteImageApi {
|
||||
url,
|
||||
signal,
|
||||
onSuccess = { buffer ->
|
||||
requestMap.remove(requestId)
|
||||
if (signal.isCanceled) {
|
||||
NativeBuffer.free(buffer.pointer)
|
||||
requestMap.remove(requestId)
|
||||
buffer.free()
|
||||
return@fetch callback(CANCELLED)
|
||||
}
|
||||
|
||||
// Decode natively when the caller wants pixels: Flutter's fallback decoder copies
|
||||
// 10-bit bitmaps (RGBA_1010102) as if they were rgba8888, garbling colors. Decode on a
|
||||
// dedicated pool - the fetch callback threads are shared with video streaming. On any
|
||||
// decode failure (including OOM on huge originals), hand Flutter the encoded bytes as
|
||||
// before.
|
||||
if (!preferEncoded && Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
decodeExecutor.execute {
|
||||
val res = if (signal.isCanceled) null else try {
|
||||
val source = ImageDecoder.createSource(NativeBuffer.wrap(buffer.pointer, buffer.offset))
|
||||
source.decodeBitmap().toNativeBuffer()
|
||||
} catch (_: Throwable) {
|
||||
null
|
||||
}
|
||||
requestMap.remove(requestId)
|
||||
when {
|
||||
// Deliver even if the request was cancelled meanwhile: re-checking here would orphan
|
||||
// res's malloc, and Dart frees the buffer itself when it sees the cancel.
|
||||
res != null -> {
|
||||
buffer.free()
|
||||
callback(Result.success(res))
|
||||
}
|
||||
signal.isCanceled -> {
|
||||
buffer.free()
|
||||
callback(CANCELLED)
|
||||
}
|
||||
else -> callback(
|
||||
Result.success(
|
||||
mapOf(
|
||||
"pointer" to buffer.pointer,
|
||||
"length" to buffer.offset.toLong()
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
return@fetch
|
||||
}
|
||||
|
||||
requestMap.remove(requestId)
|
||||
callback(
|
||||
Result.success(
|
||||
mapOf(
|
||||
|
||||
@@ -12,7 +12,7 @@ class RemoteImageRequest extends ImageRequest {
|
||||
}
|
||||
|
||||
final info = await remoteImageApi.requestImage(uri, requestId: requestId, preferEncoded: false);
|
||||
// Android always returns encoded data, so we need to check for both shapes of the response.
|
||||
// Android falls back to encoded data if native decoding fails, so check for both shapes of the response.
|
||||
final frame = switch (info) {
|
||||
{'pointer': int pointer, 'length': int length} => await _fromEncodedPlatformImage(pointer, length),
|
||||
{'pointer': int pointer, 'width': int width, 'height': int height, 'rowBytes': int rowBytes} =>
|
||||
|
||||
@@ -26,6 +26,7 @@ final Map<String, Map<String, Object?>> openApiPatches = {
|
||||
'sharedLinks': SharedLinksResponse(enabled: true, sidebarWeb: false).toJson(),
|
||||
'cast': CastResponse(gCastEnabled: false).toJson(),
|
||||
'albums': {'defaultAssetOrder': 'desc'},
|
||||
'recentlyAdded': RecentlyAddedResponse(sidebarWeb: false).toJson(),
|
||||
},
|
||||
'ServerConfigDto': {
|
||||
'mapLightStyleUrl': 'https://tiles.immich.cloud/v1/style/light.json',
|
||||
|
||||
Generated
+2
@@ -545,6 +545,8 @@ Class | Method | HTTP request | Description
|
||||
- [RatingsUpdate](doc//RatingsUpdate.md)
|
||||
- [ReactionLevel](doc//ReactionLevel.md)
|
||||
- [ReactionType](doc//ReactionType.md)
|
||||
- [RecentlyAddedResponse](doc//RecentlyAddedResponse.md)
|
||||
- [RecentlyAddedUpdate](doc//RecentlyAddedUpdate.md)
|
||||
- [ReleaseChannel](doc//ReleaseChannel.md)
|
||||
- [ReleaseEventV1](doc//ReleaseEventV1.md)
|
||||
- [ReleaseType](doc//ReleaseType.md)
|
||||
|
||||
Generated
+2
@@ -266,6 +266,8 @@ part 'model/ratings_response.dart';
|
||||
part 'model/ratings_update.dart';
|
||||
part 'model/reaction_level.dart';
|
||||
part 'model/reaction_type.dart';
|
||||
part 'model/recently_added_response.dart';
|
||||
part 'model/recently_added_update.dart';
|
||||
part 'model/release_channel.dart';
|
||||
part 'model/release_event_v1.dart';
|
||||
part 'model/release_type.dart';
|
||||
|
||||
Generated
+4
@@ -577,6 +577,10 @@ class ApiClient {
|
||||
return ReactionLevelTypeTransformer().decode(value);
|
||||
case 'ReactionType':
|
||||
return ReactionTypeTypeTransformer().decode(value);
|
||||
case 'RecentlyAddedResponse':
|
||||
return RecentlyAddedResponse.fromJson(value);
|
||||
case 'RecentlyAddedUpdate':
|
||||
return RecentlyAddedUpdate.fromJson(value);
|
||||
case 'ReleaseChannel':
|
||||
return ReleaseChannelTypeTransformer().decode(value);
|
||||
case 'ReleaseEventV1':
|
||||
|
||||
+100
@@ -0,0 +1,100 @@
|
||||
//
|
||||
// AUTO-GENERATED FILE, DO NOT MODIFY!
|
||||
//
|
||||
// @dart=2.18
|
||||
|
||||
// ignore_for_file: unused_element, unused_import
|
||||
// ignore_for_file: always_put_required_named_parameters_first
|
||||
// ignore_for_file: constant_identifier_names
|
||||
// ignore_for_file: lines_longer_than_80_chars
|
||||
|
||||
part of openapi.api;
|
||||
|
||||
class RecentlyAddedResponse {
|
||||
/// Returns a new [RecentlyAddedResponse] instance.
|
||||
RecentlyAddedResponse({
|
||||
required this.sidebarWeb,
|
||||
});
|
||||
|
||||
/// Whether the recently added page appears in the web sidebar
|
||||
bool sidebarWeb;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) => identical(this, other) || other is RecentlyAddedResponse &&
|
||||
other.sidebarWeb == sidebarWeb;
|
||||
|
||||
@override
|
||||
int get hashCode =>
|
||||
// ignore: unnecessary_parenthesis
|
||||
(sidebarWeb.hashCode);
|
||||
|
||||
@override
|
||||
String toString() => 'RecentlyAddedResponse[sidebarWeb=$sidebarWeb]';
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final json = <String, dynamic>{};
|
||||
json[r'sidebarWeb'] = this.sidebarWeb;
|
||||
return json;
|
||||
}
|
||||
|
||||
/// Returns a new [RecentlyAddedResponse] instance and imports its values from
|
||||
/// [value] if it's a [Map], null otherwise.
|
||||
// ignore: prefer_constructors_over_static_methods
|
||||
static RecentlyAddedResponse? fromJson(dynamic value) {
|
||||
upgradeDto(value, "RecentlyAddedResponse");
|
||||
if (value is Map) {
|
||||
final json = value.cast<String, dynamic>();
|
||||
|
||||
return RecentlyAddedResponse(
|
||||
sidebarWeb: mapValueOfType<bool>(json, r'sidebarWeb')!,
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static List<RecentlyAddedResponse> listFromJson(dynamic json, {bool growable = false,}) {
|
||||
final result = <RecentlyAddedResponse>[];
|
||||
if (json is List && json.isNotEmpty) {
|
||||
for (final row in json) {
|
||||
final value = RecentlyAddedResponse.fromJson(row);
|
||||
if (value != null) {
|
||||
result.add(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
return result.toList(growable: growable);
|
||||
}
|
||||
|
||||
static Map<String, RecentlyAddedResponse> mapFromJson(dynamic json) {
|
||||
final map = <String, RecentlyAddedResponse>{};
|
||||
if (json is Map && json.isNotEmpty) {
|
||||
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
|
||||
for (final entry in json.entries) {
|
||||
final value = RecentlyAddedResponse.fromJson(entry.value);
|
||||
if (value != null) {
|
||||
map[entry.key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
// maps a json object with a list of RecentlyAddedResponse-objects as value to a dart map
|
||||
static Map<String, List<RecentlyAddedResponse>> mapListFromJson(dynamic json, {bool growable = false,}) {
|
||||
final map = <String, List<RecentlyAddedResponse>>{};
|
||||
if (json is Map && json.isNotEmpty) {
|
||||
// ignore: parameter_assignments
|
||||
json = json.cast<String, dynamic>();
|
||||
for (final entry in json.entries) {
|
||||
map[entry.key] = RecentlyAddedResponse.listFromJson(entry.value, growable: growable,);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
/// The list of required keys that must be present in a JSON.
|
||||
static const requiredKeys = <String>{
|
||||
'sidebarWeb',
|
||||
};
|
||||
}
|
||||
|
||||
+108
@@ -0,0 +1,108 @@
|
||||
//
|
||||
// AUTO-GENERATED FILE, DO NOT MODIFY!
|
||||
//
|
||||
// @dart=2.18
|
||||
|
||||
// ignore_for_file: unused_element, unused_import
|
||||
// ignore_for_file: always_put_required_named_parameters_first
|
||||
// ignore_for_file: constant_identifier_names
|
||||
// ignore_for_file: lines_longer_than_80_chars
|
||||
|
||||
part of openapi.api;
|
||||
|
||||
class RecentlyAddedUpdate {
|
||||
/// Returns a new [RecentlyAddedUpdate] instance.
|
||||
RecentlyAddedUpdate({
|
||||
this.sidebarWeb = const Optional.absent(),
|
||||
});
|
||||
|
||||
/// Whether the recently added page appears in the web sidebar
|
||||
///
|
||||
/// Please note: This property should have been non-nullable! Since the specification file
|
||||
/// does not include a default value (using the "default:" property), however, the generated
|
||||
/// source code must fall back to having a nullable type.
|
||||
/// Consider adding a "default:" property in the specification file to hide this note.
|
||||
///
|
||||
Optional<bool?> sidebarWeb;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) => identical(this, other) || other is RecentlyAddedUpdate &&
|
||||
other.sidebarWeb == sidebarWeb;
|
||||
|
||||
@override
|
||||
int get hashCode =>
|
||||
// ignore: unnecessary_parenthesis
|
||||
(sidebarWeb == null ? 0 : sidebarWeb!.hashCode);
|
||||
|
||||
@override
|
||||
String toString() => 'RecentlyAddedUpdate[sidebarWeb=$sidebarWeb]';
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final json = <String, dynamic>{};
|
||||
if (this.sidebarWeb.isPresent) {
|
||||
final value = this.sidebarWeb.value;
|
||||
json[r'sidebarWeb'] = value;
|
||||
}
|
||||
return json;
|
||||
}
|
||||
|
||||
/// Returns a new [RecentlyAddedUpdate] instance and imports its values from
|
||||
/// [value] if it's a [Map], null otherwise.
|
||||
// ignore: prefer_constructors_over_static_methods
|
||||
static RecentlyAddedUpdate? fromJson(dynamic value) {
|
||||
upgradeDto(value, "RecentlyAddedUpdate");
|
||||
if (value is Map) {
|
||||
final json = value.cast<String, dynamic>();
|
||||
|
||||
return RecentlyAddedUpdate(
|
||||
sidebarWeb: json.containsKey(r'sidebarWeb') ? Optional.present(mapValueOfType<bool>(json, r'sidebarWeb')) : const Optional.absent(),
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static List<RecentlyAddedUpdate> listFromJson(dynamic json, {bool growable = false,}) {
|
||||
final result = <RecentlyAddedUpdate>[];
|
||||
if (json is List && json.isNotEmpty) {
|
||||
for (final row in json) {
|
||||
final value = RecentlyAddedUpdate.fromJson(row);
|
||||
if (value != null) {
|
||||
result.add(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
return result.toList(growable: growable);
|
||||
}
|
||||
|
||||
static Map<String, RecentlyAddedUpdate> mapFromJson(dynamic json) {
|
||||
final map = <String, RecentlyAddedUpdate>{};
|
||||
if (json is Map && json.isNotEmpty) {
|
||||
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
|
||||
for (final entry in json.entries) {
|
||||
final value = RecentlyAddedUpdate.fromJson(entry.value);
|
||||
if (value != null) {
|
||||
map[entry.key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
// maps a json object with a list of RecentlyAddedUpdate-objects as value to a dart map
|
||||
static Map<String, List<RecentlyAddedUpdate>> mapListFromJson(dynamic json, {bool growable = false,}) {
|
||||
final map = <String, List<RecentlyAddedUpdate>>{};
|
||||
if (json is Map && json.isNotEmpty) {
|
||||
// ignore: parameter_assignments
|
||||
json = json.cast<String, dynamic>();
|
||||
for (final entry in json.entries) {
|
||||
map[entry.key] = RecentlyAddedUpdate.listFromJson(entry.value, growable: growable,);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
/// The list of required keys that must be present in a JSON.
|
||||
static const requiredKeys = <String>{
|
||||
};
|
||||
}
|
||||
|
||||
+9
-1
@@ -22,6 +22,7 @@ class UserPreferencesResponseDto {
|
||||
required this.people,
|
||||
required this.purchase,
|
||||
required this.ratings,
|
||||
required this.recentlyAdded,
|
||||
required this.sharedLinks,
|
||||
required this.tags,
|
||||
});
|
||||
@@ -44,6 +45,8 @@ class UserPreferencesResponseDto {
|
||||
|
||||
RatingsResponse ratings;
|
||||
|
||||
RecentlyAddedResponse recentlyAdded;
|
||||
|
||||
SharedLinksResponse sharedLinks;
|
||||
|
||||
TagsResponse tags;
|
||||
@@ -59,6 +62,7 @@ class UserPreferencesResponseDto {
|
||||
other.people == people &&
|
||||
other.purchase == purchase &&
|
||||
other.ratings == ratings &&
|
||||
other.recentlyAdded == recentlyAdded &&
|
||||
other.sharedLinks == sharedLinks &&
|
||||
other.tags == tags;
|
||||
|
||||
@@ -74,11 +78,12 @@ class UserPreferencesResponseDto {
|
||||
(people.hashCode) +
|
||||
(purchase.hashCode) +
|
||||
(ratings.hashCode) +
|
||||
(recentlyAdded.hashCode) +
|
||||
(sharedLinks.hashCode) +
|
||||
(tags.hashCode);
|
||||
|
||||
@override
|
||||
String toString() => 'UserPreferencesResponseDto[albums=$albums, cast=$cast, download=$download, emailNotifications=$emailNotifications, folders=$folders, memories=$memories, people=$people, purchase=$purchase, ratings=$ratings, sharedLinks=$sharedLinks, tags=$tags]';
|
||||
String toString() => 'UserPreferencesResponseDto[albums=$albums, cast=$cast, download=$download, emailNotifications=$emailNotifications, folders=$folders, memories=$memories, people=$people, purchase=$purchase, ratings=$ratings, recentlyAdded=$recentlyAdded, sharedLinks=$sharedLinks, tags=$tags]';
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final json = <String, dynamic>{};
|
||||
@@ -91,6 +96,7 @@ class UserPreferencesResponseDto {
|
||||
json[r'people'] = this.people;
|
||||
json[r'purchase'] = this.purchase;
|
||||
json[r'ratings'] = this.ratings;
|
||||
json[r'recentlyAdded'] = this.recentlyAdded;
|
||||
json[r'sharedLinks'] = this.sharedLinks;
|
||||
json[r'tags'] = this.tags;
|
||||
return json;
|
||||
@@ -114,6 +120,7 @@ class UserPreferencesResponseDto {
|
||||
people: PeopleResponse.fromJson(json[r'people'])!,
|
||||
purchase: PurchaseResponse.fromJson(json[r'purchase'])!,
|
||||
ratings: RatingsResponse.fromJson(json[r'ratings'])!,
|
||||
recentlyAdded: RecentlyAddedResponse.fromJson(json[r'recentlyAdded'])!,
|
||||
sharedLinks: SharedLinksResponse.fromJson(json[r'sharedLinks'])!,
|
||||
tags: TagsResponse.fromJson(json[r'tags'])!,
|
||||
);
|
||||
@@ -172,6 +179,7 @@ class UserPreferencesResponseDto {
|
||||
'people',
|
||||
'purchase',
|
||||
'ratings',
|
||||
'recentlyAdded',
|
||||
'sharedLinks',
|
||||
'tags',
|
||||
};
|
||||
|
||||
+17
-1
@@ -23,6 +23,7 @@ class UserPreferencesUpdateDto {
|
||||
this.people = const Optional.absent(),
|
||||
this.purchase = const Optional.absent(),
|
||||
this.ratings = const Optional.absent(),
|
||||
this.recentlyAdded = const Optional.absent(),
|
||||
this.sharedLinks = const Optional.absent(),
|
||||
this.tags = const Optional.absent(),
|
||||
});
|
||||
@@ -107,6 +108,14 @@ class UserPreferencesUpdateDto {
|
||||
///
|
||||
Optional<RatingsUpdate?> ratings;
|
||||
|
||||
///
|
||||
/// Please note: This property should have been non-nullable! Since the specification file
|
||||
/// does not include a default value (using the "default:" property), however, the generated
|
||||
/// source code must fall back to having a nullable type.
|
||||
/// Consider adding a "default:" property in the specification file to hide this note.
|
||||
///
|
||||
Optional<RecentlyAddedUpdate?> recentlyAdded;
|
||||
|
||||
///
|
||||
/// Please note: This property should have been non-nullable! Since the specification file
|
||||
/// does not include a default value (using the "default:" property), however, the generated
|
||||
@@ -135,6 +144,7 @@ class UserPreferencesUpdateDto {
|
||||
other.people == people &&
|
||||
other.purchase == purchase &&
|
||||
other.ratings == ratings &&
|
||||
other.recentlyAdded == recentlyAdded &&
|
||||
other.sharedLinks == sharedLinks &&
|
||||
other.tags == tags;
|
||||
|
||||
@@ -151,11 +161,12 @@ class UserPreferencesUpdateDto {
|
||||
(people == null ? 0 : people!.hashCode) +
|
||||
(purchase == null ? 0 : purchase!.hashCode) +
|
||||
(ratings == null ? 0 : ratings!.hashCode) +
|
||||
(recentlyAdded == null ? 0 : recentlyAdded!.hashCode) +
|
||||
(sharedLinks == null ? 0 : sharedLinks!.hashCode) +
|
||||
(tags == null ? 0 : tags!.hashCode);
|
||||
|
||||
@override
|
||||
String toString() => 'UserPreferencesUpdateDto[albums=$albums, avatar=$avatar, cast=$cast, download=$download, emailNotifications=$emailNotifications, folders=$folders, memories=$memories, people=$people, purchase=$purchase, ratings=$ratings, sharedLinks=$sharedLinks, tags=$tags]';
|
||||
String toString() => 'UserPreferencesUpdateDto[albums=$albums, avatar=$avatar, cast=$cast, download=$download, emailNotifications=$emailNotifications, folders=$folders, memories=$memories, people=$people, purchase=$purchase, ratings=$ratings, recentlyAdded=$recentlyAdded, sharedLinks=$sharedLinks, tags=$tags]';
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final json = <String, dynamic>{};
|
||||
@@ -199,6 +210,10 @@ class UserPreferencesUpdateDto {
|
||||
final value = this.ratings.value;
|
||||
json[r'ratings'] = value;
|
||||
}
|
||||
if (this.recentlyAdded.isPresent) {
|
||||
final value = this.recentlyAdded.value;
|
||||
json[r'recentlyAdded'] = value;
|
||||
}
|
||||
if (this.sharedLinks.isPresent) {
|
||||
final value = this.sharedLinks.value;
|
||||
json[r'sharedLinks'] = value;
|
||||
@@ -229,6 +244,7 @@ class UserPreferencesUpdateDto {
|
||||
people: json.containsKey(r'people') ? Optional.present(PeopleUpdate.fromJson(json[r'people'])) : const Optional.absent(),
|
||||
purchase: json.containsKey(r'purchase') ? Optional.present(PurchaseUpdate.fromJson(json[r'purchase'])) : const Optional.absent(),
|
||||
ratings: json.containsKey(r'ratings') ? Optional.present(RatingsUpdate.fromJson(json[r'ratings'])) : const Optional.absent(),
|
||||
recentlyAdded: json.containsKey(r'recentlyAdded') ? Optional.present(RecentlyAddedUpdate.fromJson(json[r'recentlyAdded'])) : const Optional.absent(),
|
||||
sharedLinks: json.containsKey(r'sharedLinks') ? Optional.present(SharedLinksUpdate.fromJson(json[r'sharedLinks'])) : const Optional.absent(),
|
||||
tags: json.containsKey(r'tags') ? Optional.present(TagsUpdate.fromJson(json[r'tags'])) : const Optional.absent(),
|
||||
);
|
||||
|
||||
@@ -21991,6 +21991,27 @@
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"RecentlyAddedResponse": {
|
||||
"properties": {
|
||||
"sidebarWeb": {
|
||||
"description": "Whether the recently added page appears in the web sidebar",
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"sidebarWeb"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"RecentlyAddedUpdate": {
|
||||
"properties": {
|
||||
"sidebarWeb": {
|
||||
"description": "Whether the recently added page appears in the web sidebar",
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"ReleaseChannel": {
|
||||
"description": "Release channel",
|
||||
"enum": [
|
||||
@@ -27525,6 +27546,9 @@
|
||||
"ratings": {
|
||||
"$ref": "#/components/schemas/RatingsResponse"
|
||||
},
|
||||
"recentlyAdded": {
|
||||
"$ref": "#/components/schemas/RecentlyAddedResponse"
|
||||
},
|
||||
"sharedLinks": {
|
||||
"$ref": "#/components/schemas/SharedLinksResponse"
|
||||
},
|
||||
@@ -27542,6 +27566,7 @@
|
||||
"people",
|
||||
"purchase",
|
||||
"ratings",
|
||||
"recentlyAdded",
|
||||
"sharedLinks",
|
||||
"tags"
|
||||
],
|
||||
@@ -27579,6 +27604,9 @@
|
||||
"ratings": {
|
||||
"$ref": "#/components/schemas/RatingsUpdate"
|
||||
},
|
||||
"recentlyAdded": {
|
||||
"$ref": "#/components/schemas/RecentlyAddedUpdate"
|
||||
},
|
||||
"sharedLinks": {
|
||||
"$ref": "#/components/schemas/SharedLinksUpdate"
|
||||
},
|
||||
|
||||
@@ -342,6 +342,10 @@ export type RatingsResponse = {
|
||||
/** Whether ratings are enabled */
|
||||
enabled: boolean;
|
||||
};
|
||||
export type RecentlyAddedResponse = {
|
||||
/** Whether the recently added page appears in the web sidebar */
|
||||
sidebarWeb: boolean;
|
||||
};
|
||||
export type SharedLinksResponse = {
|
||||
/** Whether shared links are enabled */
|
||||
enabled: boolean;
|
||||
@@ -364,6 +368,7 @@ export type UserPreferencesResponseDto = {
|
||||
people: PeopleResponse;
|
||||
purchase: PurchaseResponse;
|
||||
ratings: RatingsResponse;
|
||||
recentlyAdded: RecentlyAddedResponse;
|
||||
sharedLinks: SharedLinksResponse;
|
||||
tags: TagsResponse;
|
||||
};
|
||||
@@ -421,6 +426,10 @@ export type RatingsUpdate = {
|
||||
/** Whether ratings are enabled */
|
||||
enabled?: boolean;
|
||||
};
|
||||
export type RecentlyAddedUpdate = {
|
||||
/** Whether the recently added page appears in the web sidebar */
|
||||
sidebarWeb?: boolean;
|
||||
};
|
||||
export type SharedLinksUpdate = {
|
||||
/** Whether shared links are enabled */
|
||||
enabled?: boolean;
|
||||
@@ -444,6 +453,7 @@ export type UserPreferencesUpdateDto = {
|
||||
people?: PeopleUpdate;
|
||||
purchase?: PurchaseUpdate;
|
||||
ratings?: RatingsUpdate;
|
||||
recentlyAdded?: RecentlyAddedUpdate;
|
||||
sharedLinks?: SharedLinksUpdate;
|
||||
tags?: TagsUpdate;
|
||||
};
|
||||
|
||||
@@ -98,6 +98,13 @@ const CastUpdateSchema = z
|
||||
.optional()
|
||||
.meta({ id: 'CastUpdate' });
|
||||
|
||||
const RecentlyAddedUpdateSchema = z
|
||||
.object({
|
||||
sidebarWeb: z.boolean().optional().describe('Whether the recently added page appears in the web sidebar'),
|
||||
})
|
||||
.optional()
|
||||
.meta({ id: 'RecentlyAddedUpdate' });
|
||||
|
||||
const UserPreferencesUpdateSchema = z
|
||||
.object({
|
||||
albums: AlbumsUpdateSchema,
|
||||
@@ -112,6 +119,7 @@ const UserPreferencesUpdateSchema = z
|
||||
ratings: RatingsUpdateSchema,
|
||||
sharedLinks: SharedLinksUpdateSchema,
|
||||
tags: TagsUpdateSchema,
|
||||
recentlyAdded: RecentlyAddedUpdateSchema,
|
||||
})
|
||||
.meta({ id: 'UserPreferencesUpdateDto' });
|
||||
|
||||
@@ -191,6 +199,12 @@ const CastResponseSchema = z
|
||||
})
|
||||
.meta({ id: 'CastResponse' });
|
||||
|
||||
const RecentlyAddedResponseSchema = z
|
||||
.object({
|
||||
sidebarWeb: z.boolean().describe('Whether the recently added page appears in the web sidebar'),
|
||||
})
|
||||
.meta({ id: 'RecentlyAddedResponse' });
|
||||
|
||||
const UserPreferencesResponseSchema = z
|
||||
.object({
|
||||
albums: AlbumsResponseSchema,
|
||||
@@ -204,6 +218,7 @@ const UserPreferencesResponseSchema = z
|
||||
download: DownloadResponseSchema,
|
||||
purchase: PurchaseResponseSchema,
|
||||
cast: CastResponseSchema,
|
||||
recentlyAdded: RecentlyAddedResponseSchema,
|
||||
})
|
||||
.meta({ id: 'UserPreferencesResponseDto' });
|
||||
|
||||
|
||||
@@ -619,6 +619,9 @@ export type UserPreferences = {
|
||||
cast: {
|
||||
gCastEnabled: boolean;
|
||||
};
|
||||
recentlyAdded: {
|
||||
sidebarWeb: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
export type UserMetadataItem<T extends keyof UserMetadata = UserMetadataKey> = {
|
||||
|
||||
@@ -50,6 +50,9 @@ const getDefaultPreferences = (): UserPreferences => {
|
||||
cast: {
|
||||
gCastEnabled: false,
|
||||
},
|
||||
recentlyAdded: {
|
||||
sidebarWeb: false,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -31,6 +31,7 @@
|
||||
mdiToolboxOutline,
|
||||
mdiTrashCan,
|
||||
mdiTrashCanOutline,
|
||||
mdiUploadOutline,
|
||||
} from '@mdi/js';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { fly } from 'svelte/transition';
|
||||
@@ -83,6 +84,14 @@
|
||||
<NavbarItem title={$t('tags')} href={Route.tags()} icon={{ icon: mdiTagMultipleOutline, flipped: true }} />
|
||||
{/if}
|
||||
|
||||
{#if authManager.preferences.recentlyAdded.sidebarWeb}
|
||||
<NavbarItem
|
||||
title={$t('recently_added')}
|
||||
href={Route.recentlyAdded()}
|
||||
icon={{ icon: mdiUploadOutline, flipped: true }}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if authManager.preferences.folders.enabled && authManager.preferences.folders.sidebarWeb}
|
||||
<NavbarItem title={$t('folders')} href={Route.folders()} icon={{ icon: mdiFolderOutline, flipped: true }} />
|
||||
{/if}
|
||||
|
||||
@@ -38,6 +38,9 @@
|
||||
// Cast
|
||||
let gCastEnabled = $state(authManager.preferences.cast?.gCastEnabled ?? false);
|
||||
|
||||
// Recently added
|
||||
let recentlyAddedSidebar = $state(authManager.preferences.recentlyAdded?.sidebarWeb ?? false);
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
const response = await updateMyPreferences({
|
||||
@@ -50,6 +53,7 @@
|
||||
sharedLinks: { enabled: sharedLinksEnabled, sidebarWeb: sharedLinkSidebar },
|
||||
tags: { enabled: tagsEnabled, sidebarWeb: tagsSidebar },
|
||||
cast: { gCastEnabled },
|
||||
recentlyAdded: { sidebarWeb: recentlyAddedSidebar },
|
||||
},
|
||||
});
|
||||
|
||||
@@ -170,6 +174,14 @@
|
||||
</div>
|
||||
</SettingAccordion>
|
||||
|
||||
<SettingAccordion key="recentlyAdded" title={$t('recently_added')} subtitle={$t('recently_added_description')}>
|
||||
<div class="mt-4 flex flex-col gap-4 sm:ms-4">
|
||||
<Field label={$t('sidebar')} description={$t('sidebar_display_description')}>
|
||||
<Switch bind:checked={recentlyAddedSidebar} />
|
||||
</Field>
|
||||
</div>
|
||||
</SettingAccordion>
|
||||
|
||||
<div class="mt-4 flex justify-end">
|
||||
<Button shape="round" type="submit" size="small" onclick={() => handleSave()}>{$t('save')}</Button>
|
||||
</div>
|
||||
|
||||
@@ -44,4 +44,7 @@ export const preferencesFactory = Sync.makeFactory<UserPreferencesResponseDto>({
|
||||
enabled: false,
|
||||
sidebarWeb: false,
|
||||
},
|
||||
recentlyAdded: {
|
||||
sidebarWeb: false,
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user