Compare commits

..

1 Commits

Author SHA1 Message Date
Thomas Way 0651612d46 fix(mobile): fix stale refs in use timer
The timer hook preserved the values of the original local variables,
which caused issues when hiding controls for videos. The callback can be
changed so that it always sees the latest value with useRef, and it can
be simplified significantly using a function rather than state class.
2026-04-05 04:03:32 +01:00
34 changed files with 364 additions and 574 deletions
+14 -14
View File
@@ -28,17 +28,17 @@ For the full list, refer to the [Immich source code](https://github.com/immich-a
## Video formats
| Format | Extension(s) | Supported? | Notes |
| :---------- | :-------------------------- | :----------------: | :---- |
| `3GPP` | `.3gp` `.3gpp` | :white_check_mark: | |
| `AVI` | `.avi` | :white_check_mark: | |
| `FLV` | `.flv` | :white_check_mark: | |
| `M4V` | `.m4v` | :white_check_mark: | |
| `MATROSKA` | `.mkv` | :white_check_mark: | |
| `MP2T` | `.mts` `.m2ts` `.m2t` `.ts` | :white_check_mark: | |
| `MP4` | `.mp4` `.insv` | :white_check_mark: | |
| `MPEG` | `.mpg` `.mpe` `.mpeg` | :white_check_mark: | |
| `MXF` | `.mxf` | :white_check_mark: | |
| `QUICKTIME` | `.mov` | :white_check_mark: | |
| `WEBM` | `.webm` | :white_check_mark: | |
| `WMV` | `.wmv` | :white_check_mark: | |
| Format | Extension(s) | Supported? | Notes |
| :---------- | :-------------------- | :----------------: | :---- |
| `3GPP` | `.3gp` `.3gpp` | :white_check_mark: | |
| `AVI` | `.avi` | :white_check_mark: | |
| `FLV` | `.flv` | :white_check_mark: | |
| `M4V` | `.m4v` | :white_check_mark: | |
| `MATROSKA` | `.mkv` | :white_check_mark: | |
| `MP2T` | `.mts` `.m2ts` `.m2t` | :white_check_mark: | |
| `MP4` | `.mp4` `.insv` | :white_check_mark: | |
| `MPEG` | `.mpg` `.mpe` `.mpeg` | :white_check_mark: | |
| `MXF` | `.mxf` | :white_check_mark: | |
| `QUICKTIME` | `.mov` | :white_check_mark: | |
| `WEBM` | `.webm` | :white_check_mark: | |
| `WMV` | `.wmv` | :white_check_mark: | |
-1
View File
@@ -173,7 +173,6 @@ export const setupBaseMockApiRoutes = async (context: BrowserContext, adminUserI
'.mpeg',
'.mpg',
'.mts',
'.ts',
'.vob',
'.webm',
'.wmv',
@@ -6,7 +6,6 @@ import {
generateTimelineData,
TimelineAssetConfig,
TimelineData,
toAssetResponseDto,
} from 'src/ui/generators/timeline';
import { setupBaseMockApiRoutes } from 'src/ui/mock-network/base-network';
import { setupTimelineMockApiRoutes, TimelineTestContext } from 'src/ui/mock-network/timeline-network';
@@ -31,10 +30,6 @@ test.describe('search gallery-viewer', () => {
};
test.beforeAll(async () => {
test.fail(
process.env.PW_EXPERIMENTAL_SERVICE_WORKER_NETWORK_EVENTS !== '1',
'This test requires env var: PW_EXPERIMENTAL_SERVICE_WORKER_NETWORK_EVENTS=1',
);
adminUserId = faker.string.uuid();
testContext.adminId = adminUserId;
timelineRestData = generateTimelineData({ ...createDefaultTimelineConfig(), ownerId: adminUserId });
@@ -49,10 +44,7 @@ test.describe('search gallery-viewer', () => {
await context.route('**/api/search/metadata', async (route, request) => {
if (request.method() === 'POST') {
const searchAssets = assets
.slice(0, 5)
.filter((asset) => !changes.assetDeletions.includes(asset.id))
.map((asset) => toAssetResponseDto(asset));
const searchAssets = assets.slice(0, 5).filter((asset) => !changes.assetDeletions.includes(asset.id));
return route.fulfill({
status: 200,
contentType: 'application/json',
+7 -29
View File
@@ -3,7 +3,7 @@
archiveVersion = 1;
classes = {
};
objectVersion = 77;
objectVersion = 54;
objects = {
/* Begin PBXBuildFile section */
@@ -16,7 +16,6 @@
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; };
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; };
A01DD69B2F7F43B40049AB63 /* ImageRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = A01DD6982F7F43B40049AB63 /* ImageRequest.swift */; };
B21E34AA2E5AFD2B0031FDB9 /* BackgroundWorkerApiImpl.swift in Sources */ = {isa = PBXBuildFile; fileRef = B21E34A92E5AFD210031FDB9 /* BackgroundWorkerApiImpl.swift */; };
B21E34AC2E5B09190031FDB9 /* BackgroundWorker.swift in Sources */ = {isa = PBXBuildFile; fileRef = B21E34AB2E5B09100031FDB9 /* BackgroundWorker.swift */; };
B25D377A2E72CA15008B6CA7 /* Connectivity.g.swift in Sources */ = {isa = PBXBuildFile; fileRef = B25D37782E72CA15008B6CA7 /* Connectivity.g.swift */; };
@@ -103,7 +102,6 @@
97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
A01DD6982F7F43B40049AB63 /* ImageRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageRequest.swift; sourceTree = "<group>"; };
B1FBA9EE014DE20271B0FE77 /* Pods-ShareExtension.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-ShareExtension.profile.xcconfig"; path = "Target Support Files/Pods-ShareExtension/Pods-ShareExtension.profile.xcconfig"; sourceTree = "<group>"; };
B21E34A92E5AFD210031FDB9 /* BackgroundWorkerApiImpl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundWorkerApiImpl.swift; sourceTree = "<group>"; };
B21E34AB2E5B09100031FDB9 /* BackgroundWorker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundWorker.swift; sourceTree = "<group>"; };
@@ -139,23 +137,20 @@
);
target = F0B57D372DF764BD00DC5BCC /* WidgetExtension */;
};
FE1BB4572F83196E0087DBF9 /* Exceptions for "Utility" folder in "Runner" target */ = {
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
membershipExceptions = (
Mutex.swift,
);
target = 97C146ED1CF9000F007C117D /* Runner */;
};
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
/* Begin PBXFileSystemSynchronizedRootGroup section */
B231F52D2E93A44A00BC45D1 /* Core */ = {
isa = PBXFileSystemSynchronizedRootGroup;
exceptions = (
);
path = Core;
sourceTree = "<group>";
};
B2CF7F8C2DDE4EBB00744BF6 /* Sync */ = {
isa = PBXFileSystemSynchronizedRootGroup;
exceptions = (
);
path = Sync;
sourceTree = "<group>";
};
@@ -167,16 +162,10 @@
path = WidgetExtension;
sourceTree = "<group>";
};
FE1BB4562F8319560087DBF9 /* Utility */ = {
isa = PBXFileSystemSynchronizedRootGroup;
exceptions = (
FE1BB4572F83196E0087DBF9 /* Exceptions for "Utility" folder in "Runner" target */,
);
path = Utility;
sourceTree = "<group>";
};
FEE084F22EC172080045228E /* Schemas */ = {
isa = PBXFileSystemSynchronizedRootGroup;
exceptions = (
);
path = Schemas;
sourceTree = "<group>";
};
@@ -284,7 +273,6 @@
97C146F01CF9000F007C117D /* Runner */ = {
isa = PBXGroup;
children = (
FE1BB4562F8319560087DBF9 /* Utility */,
FEE084F22EC172080045228E /* Schemas */,
B231F52D2E93A44A00BC45D1 /* Core */,
B25D37792E72CA15008B6CA7 /* Connectivity */,
@@ -339,7 +327,6 @@
FED3B1952E253E9B0030FD97 /* Images */ = {
isa = PBXGroup;
children = (
A01DD6982F7F43B40049AB63 /* ImageRequest.swift */,
FE5FE4AD2F30FBC000A71243 /* ImageProcessing.swift */,
FE5499F72F1198DE006016CB /* RemoteImagesImpl.swift */,
FE5499F52F11980E006016CB /* LocalImagesImpl.swift */,
@@ -571,14 +558,10 @@
inputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-input-files.xcfilelist",
);
inputPaths = (
);
name = "[CP] Copy Pods Resources";
outputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-output-files.xcfilelist",
);
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh\"\n";
@@ -607,14 +590,10 @@
inputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist",
);
inputPaths = (
);
name = "[CP] Embed Pods Frameworks";
outputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist",
);
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n";
@@ -629,7 +608,6 @@
files = (
65F32F31299BD2F800CE9261 /* BackgroundServicePlugin.swift in Sources */,
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */,
A01DD69B2F7F43B40049AB63 /* ImageRequest.swift in Sources */,
B21E34AC2E5B09190031FDB9 /* BackgroundWorker.swift in Sources */,
FE5499F32F1197D8006016CB /* LocalImages.g.swift in Sources */,
FE5499F62F11980E006016CB /* LocalImagesImpl.swift in Sources */,
@@ -1,12 +1,7 @@
import Foundation
enum ImageProcessing {
static let queue = {
let q = OperationQueue()
q.name = "thumbnail.processing"
q.qualityOfService = .userInitiated
q.maxConcurrentOperationCount = ProcessInfo.processInfo.activeProcessorCount * 2
return q
}()
static let queue = DispatchQueue(label: "thumbnail.processing", qos: .userInitiated, attributes: .concurrent)
static let semaphore = DispatchSemaphore(value: ProcessInfo.processInfo.activeProcessorCount * 2)
static let cancelledResult = Result<[String: Int64]?, any Error>.success(nil)
}
@@ -1,14 +0,0 @@
import Foundation
struct RequestRegistry<T: AnyObject & Sendable>: ~Copyable, Sendable {
private let requests = Mutex<[Int64: T]>([:])
func add(requestId: Int64, request: T) {
requests.withLock { $0[requestId] = request }
}
@discardableResult
func remove(requestId: Int64) -> T? {
requests.withLock { $0.removeValue(forKey: requestId) }
}
}
+52 -24
View File
@@ -4,18 +4,13 @@ import MobileCoreServices
import Photos
class LocalImageRequest {
weak var operation: Operation?
weak var workItem: DispatchWorkItem?
var isCancelled = false
let callback: (Result<[String: Int64]?, any Error>) -> Void
init(callback: @escaping (Result<[String: Int64]?, any Error>) -> Void) {
self.callback = callback
}
func cancel() {
isCancelled = true
operation?.cancel()
}
}
class LocalImageApiImpl: LocalImageApi {
@@ -36,7 +31,9 @@ class LocalImageApiImpl: LocalImageApi {
return requestOptions
}()
private static let registry = RequestRegistry<LocalImageRequest>()
private static let assetQueue = DispatchQueue(label: "thumbnail.assets", qos: .userInitiated)
private static let requestQueue = DispatchQueue(label: "thumbnail.requests", qos: .userInitiated)
private static let cancelQueue = DispatchQueue(label: "thumbnail.cancellation", qos: .default)
private static var rgbaFormat = vImage_CGImageFormat(
bitsPerComponent: 8,
@@ -45,6 +42,7 @@ class LocalImageApiImpl: LocalImageApi {
bitmapInfo: CGBitmapInfo(rawValue: CGImageAlphaInfo.premultipliedLast.rawValue),
renderingIntent: .defaultIntent
)!
private static var requests = [Int64: LocalImageRequest]()
private static let assetCache = {
let assetCache = NSCache<NSString, PHAsset>()
assetCache.countLimit = 10000
@@ -52,7 +50,7 @@ class LocalImageApiImpl: LocalImageApi {
}()
func getThumbhash(thumbhash: String, completion: @escaping (Result<[String : Int64], any Error>) -> Void) {
ImageProcessing.queue.addOperation {
ImageProcessing.queue.async {
guard let data = Data(base64Encoded: thumbhash)
else { return completion(.failure(PigeonError(code: "", message: "Invalid base64 string: \(thumbhash)", details: nil)))}
@@ -68,14 +66,23 @@ class LocalImageApiImpl: LocalImageApi {
func requestImage(assetId: String, requestId: Int64, width: Int64, height: Int64, isVideo: Bool, preferEncoded: Bool, completion: @escaping (Result<[String: Int64]?, any Error>) -> Void) {
let request = LocalImageRequest(callback: completion)
let operation = BlockOperation {
let item = DispatchWorkItem {
if request.isCancelled {
return completion(ImageProcessing.cancelledResult)
}
ImageProcessing.semaphore.wait()
defer {
ImageProcessing.semaphore.signal()
}
if request.isCancelled {
return completion(ImageProcessing.cancelledResult)
}
guard let asset = Self.requestAsset(assetId: assetId)
else {
Self.registry.remove(requestId: requestId)
Self.remove(requestId: requestId)
completion(.failure(PigeonError(code: "", message: "Could not get asset data for \(assetId)", details: nil)))
return
}
@@ -100,11 +107,12 @@ class LocalImageApiImpl: LocalImageApi {
)
if request.isCancelled {
Self.remove(requestId: requestId)
return completion(ImageProcessing.cancelledResult)
}
guard let data = imageData else {
Self.registry.remove(requestId: requestId)
Self.remove(requestId: requestId)
return completion(.failure(PigeonError(code: "", message: "Could not get image data for \(assetId)", details: nil)))
}
@@ -114,6 +122,7 @@ class LocalImageApiImpl: LocalImageApi {
if request.isCancelled {
free(pointer)
Self.remove(requestId: requestId)
return completion(ImageProcessing.cancelledResult)
}
@@ -121,7 +130,7 @@ class LocalImageApiImpl: LocalImageApi {
"pointer": Int64(Int(bitPattern: pointer)),
"length": Int64(length),
]))
Self.registry.remove(requestId: requestId)
Self.remove(requestId: requestId)
return
}
@@ -142,7 +151,7 @@ class LocalImageApiImpl: LocalImageApi {
guard let image = image,
let cgImage = image.cgImage else {
Self.registry.remove(requestId: requestId)
Self.remove(requestId: requestId)
return completion(.failure(PigeonError(code: "", message: "Could not get pixel data for \(assetId)", details: nil)))
}
@@ -162,32 +171,51 @@ class LocalImageApiImpl: LocalImageApi {
"pointer": Int64(Int(bitPattern: buffer.data)),
"width": Int64(buffer.width),
"height": Int64(buffer.height),
"rowBytes": Int64(buffer.rowBytes),
"rowBytes": Int64(buffer.rowBytes)
]))
Self.registry.remove(requestId: requestId)
Self.remove(requestId: requestId)
} catch {
Self.registry.remove(requestId: requestId)
Self.remove(requestId: requestId)
return completion(.failure(PigeonError(code: "", message: "Failed to convert image for \(assetId): \(error)", details: nil)))
}
}
request.operation = operation
Self.registry.add(requestId: requestId, request: request)
ImageProcessing.queue.addOperation(operation)
request.workItem = item
Self.add(requestId: requestId, request: request)
ImageProcessing.queue.async(execute: item)
}
func cancelRequest(requestId: Int64) {
Self.registry.remove(requestId: requestId)?.cancel()
Self.cancel(requestId: requestId)
}
private static func add(requestId: Int64, request: LocalImageRequest) -> Void {
requestQueue.sync { requests[requestId] = request }
}
private static func remove(requestId: Int64) -> Void {
requestQueue.sync { requests[requestId] = nil }
}
private static func cancel(requestId: Int64) -> Void {
requestQueue.async {
guard let request = requests.removeValue(forKey: requestId) else { return }
request.isCancelled = true
guard let item = request.workItem else { return }
if item.isCancelled {
cancelQueue.async { request.callback(ImageProcessing.cancelledResult) }
}
}
}
private static func requestAsset(assetId: String) -> PHAsset? {
if let cached = assetCache.object(forKey: assetId as NSString) {
return cached
}
var asset: PHAsset?
assetQueue.sync { asset = assetCache.object(forKey: assetId as NSString) }
if asset != nil { return asset }
guard let asset = PHAsset.fetchAssets(withLocalIdentifiers: [assetId], options: Self.fetchOptions).firstObject
else { return nil }
assetCache.setObject(asset, forKey: assetId as NSString)
assetQueue.async { assetCache.setObject(asset, forKey: assetId as NSString) }
return asset
}
}
+21 -11
View File
@@ -14,15 +14,11 @@ class RemoteImageRequest {
self.task = task
self.completion = completion
}
func cancel() {
isCancelled = true
task?.cancel()
}
}
class RemoteImageApiImpl: NSObject, RemoteImageApi {
private static let registry = RequestRegistry<RemoteImageRequest>()
private static var lock = os_unfair_lock()
private static var requests = [Int64: RemoteImageRequest]()
private static var rgbaFormat = vImage_CGImageFormat(
bitsPerComponent: 8,
bitsPerPixel: 32,
@@ -47,15 +43,20 @@ class RemoteImageApiImpl: NSObject, RemoteImageApi {
let request = RemoteImageRequest(id: requestId, task: task, completion: completion)
Self.registry.add(requestId: requestId, request: request)
os_unfair_lock_lock(&Self.lock)
Self.requests[requestId] = request
os_unfair_lock_unlock(&Self.lock)
task.resume()
}
private static func handleCompletion(requestId: Int64, encoded: Bool, data: Data?, response: URLResponse?, error: Error?) {
guard let request = registry.remove(requestId: requestId) else {
return
os_unfair_lock_lock(&Self.lock)
guard let request = requests[requestId] else {
return os_unfair_lock_unlock(&Self.lock)
}
requests[requestId] = nil
os_unfair_lock_unlock(&Self.lock)
if let error = error {
if request.isCancelled || (error as NSError).code == NSURLErrorCancelled {
@@ -72,7 +73,10 @@ class RemoteImageApiImpl: NSObject, RemoteImageApi {
return request.completion(.failure(PigeonError(code: "", message: "No data received", details: nil)))
}
ImageProcessing.queue.addOperation {
ImageProcessing.queue.async {
ImageProcessing.semaphore.wait()
defer { ImageProcessing.semaphore.signal() }
if request.isCancelled {
return request.completion(ImageProcessing.cancelledResult)
}
@@ -126,7 +130,13 @@ class RemoteImageApiImpl: NSObject, RemoteImageApi {
}
func cancelRequest(requestId: Int64) {
Self.registry.remove(requestId: requestId)?.cancel()
os_unfair_lock_lock(&Self.lock)
let request = Self.requests[requestId]
os_unfair_lock_unlock(&Self.lock)
guard let request = request else { return }
request.isCancelled = true
request.task?.cancel()
}
func clearCache(completion: @escaping (Result<Int64, any Error>) -> Void) {
-54
View File
@@ -1,54 +0,0 @@
import Darwin
// Can be replaced with std Mutex when the deployment target is iOS 18+
struct Mutex<Value: ~Copyable>: ~Copyable, @unchecked Sendable {
struct _Buffer: ~Copyable {
var lock: os_unfair_lock = .init()
var value: Value
init(value: consuming Value) {
self.value = value
}
deinit {}
}
let _buffer: UnsafeMutablePointer<_Buffer>
init(_ initialValue: consuming sending Value) {
_buffer = .allocate(capacity: 1)
_buffer.initialize(to: _Buffer(value: initialValue))
}
deinit {
_buffer.deinitialize(count: 1)
_buffer.deallocate()
}
@discardableResult
borrowing func withLock<Result: ~Copyable, E: Error>(
_ body: (inout sending Value) throws(E) -> sending Result
) throws(E) -> sending Result {
os_unfair_lock_lock(&_buffer.pointee.lock)
defer { os_unfair_lock_unlock(&_buffer.pointee.lock) }
return try body(&_buffer.pointee.value)
}
}
// Can be replaced with OSAllocatedUnfairLock when the deployment target is iOS 16+
typealias UnfairLock = Mutex<Void>
extension Mutex where Value == Void {
init() {
self.init(())
}
@discardableResult
borrowing func withLock<Result: ~Copyable, E: Error>(
_ body: () throws(E) -> sending Result
) throws(E) -> sending Result {
os_unfair_lock_lock(&_buffer.pointee.lock)
defer { os_unfair_lock_unlock(&_buffer.pointee.lock) }
return try body()
}
}
@@ -10,19 +10,20 @@ class TrashBottomBar extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
return Align(
alignment: Alignment.bottomCenter,
child: Container(
color: context.themeData.canvasColor,
padding: const EdgeInsets.symmetric(vertical: 8),
child: const SafeArea(
top: false,
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
DeleteTrashActionButton(source: ActionSource.timeline),
RestoreTrashActionButton(source: ActionSource.timeline),
],
return SafeArea(
child: Align(
alignment: Alignment.bottomCenter,
child: SizedBox(
height: 64,
child: Container(
color: context.themeData.canvasColor,
child: const Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
DeleteTrashActionButton(source: ActionSource.timeline),
RestoreTrashActionButton(source: ActionSource.timeline),
],
),
),
),
),
+13 -32
View File
@@ -1,36 +1,17 @@
import 'package:async/async.dart';
import 'package:flutter/material.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
RestartableTimer useTimer(Duration duration, void Function() callback) {
return use(_TimerHook(duration: duration, callback: callback));
}
class _TimerHook extends Hook<RestartableTimer> {
final Duration duration;
final void Function() callback;
const _TimerHook({required this.duration, required this.callback});
@override
HookState<RestartableTimer, Hook<RestartableTimer>> createState() => _TimerHookState();
}
class _TimerHookState extends HookState<RestartableTimer, _TimerHook> {
late RestartableTimer timer;
@override
void initHook() {
super.initHook();
timer = RestartableTimer(hook.duration, hook.callback);
}
@override
RestartableTimer build(BuildContext context) {
return timer;
}
@override
void dispose() {
timer.cancel();
super.dispose();
}
RestartableTimer useTimer(Duration duration, VoidCallback callback) {
final latest = useRef(callback);
latest.value = callback;
final timer = useMemoized(
() => RestartableTimer(duration, () => latest.value()),
[duration],
);
useEffect(() => timer.cancel, [timer]);
return timer;
}
@@ -1,6 +1,5 @@
import 'dart:math';
import 'package:async/async.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/colors.dart';
@@ -8,63 +7,26 @@ import 'package:immich_mobile/models/cast/cast_manager_state.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/cast.provider.dart';
import 'package:immich_mobile/utils/hooks/timer_hook.dart';
import 'package:immich_mobile/extensions/duration_extensions.dart';
import 'package:immich_mobile/widgets/asset_viewer/animated_play_pause.dart';
class VideoControls extends ConsumerStatefulWidget {
class VideoControls extends HookConsumerWidget {
final String videoPlayerName;
static const List<Shadow> _controlShadows = [Shadow(color: Colors.black87, blurRadius: 6, offset: Offset(0, 1))];
const VideoControls({super.key, required this.videoPlayerName});
@override
ConsumerState<VideoControls> createState() => _VideoControlsState();
}
class _VideoControlsState extends ConsumerState<VideoControls> {
late final RestartableTimer _hideTimer;
AutoDisposeStateNotifierProvider<VideoPlayerNotifier, VideoPlayerState> get _provider =>
videoPlayerProvider(widget.videoPlayerName);
@override
void initState() {
super.initState();
_hideTimer = RestartableTimer(const Duration(seconds: 5), _onHideTimer);
}
@override
void didUpdateWidget(covariant VideoControls oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.videoPlayerName != widget.videoPlayerName) {
_hideTimer.reset();
}
}
@override
void dispose() {
_hideTimer.cancel();
super.dispose();
}
void _onHideTimer() {
if (!mounted) return;
if (ref.read(_provider).status == VideoPlaybackStatus.playing) {
ref.read(assetViewerProvider.notifier).setControls(false);
}
}
void _toggle(bool isCasting) {
void _toggle(WidgetRef ref, bool isCasting) {
if (isCasting) {
ref.read(castProvider.notifier).toggle();
return;
} else {
ref.read(videoPlayerProvider(videoPlayerName).notifier).toggle();
}
ref.read(_provider.notifier).toggle();
}
void _onSeek(bool isCasting, double value) {
void _onSeek(WidgetRef ref, bool isCasting, double value) {
final seekTo = Duration(microseconds: value.toInt());
if (isCasting) {
@@ -72,30 +34,38 @@ class _VideoControlsState extends ConsumerState<VideoControls> {
return;
}
ref.read(_provider.notifier).seekTo(seekTo);
ref.read(videoPlayerProvider(videoPlayerName).notifier).seekTo(seekTo);
}
@override
Widget build(BuildContext context) {
Widget build(BuildContext context, WidgetRef ref) {
final provider = videoPlayerProvider(videoPlayerName);
final cast = ref.watch(castProvider);
final isCasting = cast.isCasting;
final (position, duration) = isCasting
? ref.watch(castProvider.select((c) => (c.currentTime, c.duration)))
: ref.watch(_provider.select((v) => (v.position, v.duration)));
: ref.watch(provider.select((v) => (v.position, v.duration)));
final videoStatus = ref.watch(_provider.select((v) => v.status));
final videoStatus = ref.watch(provider.select((v) => v.status));
final isPlaying = isCasting
? cast.castState == CastState.playing
: videoStatus == VideoPlaybackStatus.playing || videoStatus == VideoPlaybackStatus.buffering;
final isFinished = !isCasting && videoStatus == VideoPlaybackStatus.completed;
ref.listen(assetViewerProvider.select((v) => v.showingControls), (prev, showing) {
if (showing && prev != showing) _hideTimer.reset();
final hideTimer = useTimer(const Duration(seconds: 5), () {
if (!context.mounted) return;
if (ref.read(provider).status == VideoPlaybackStatus.playing) {
ref.read(assetViewerProvider.notifier).setControls(false);
}
});
ref.listen(_provider.select((v) => v.status), (_, __) => _hideTimer.reset());
final notifier = ref.read(_provider.notifier);
ref.listen(assetViewerProvider.select((v) => v.showingControls), (prev, showing) {
if (showing && prev != showing) hideTimer.reset();
});
ref.listen(provider.select((v) => v.status), (_, __) => hideTimer.reset());
final notifier = ref.read(provider.notifier);
final isLoaded = duration != Duration.zero;
return Padding(
@@ -110,13 +80,9 @@ class _VideoControlsState extends ConsumerState<VideoControls> {
padding: const EdgeInsets.all(12),
constraints: const BoxConstraints(),
icon: isFinished
? const Icon(Icons.replay, color: Colors.white, shadows: VideoControls._controlShadows)
: AnimatedPlayPause(
color: Colors.white,
playing: isPlaying,
shadows: VideoControls._controlShadows,
),
onPressed: () => _toggle(isCasting),
? const Icon(Icons.replay, color: Colors.white, shadows: _controlShadows)
: AnimatedPlayPause(color: Colors.white, playing: isPlaying, shadows: _controlShadows),
onPressed: () => _toggle(ref, isCasting),
),
const Spacer(),
Text(
@@ -125,7 +91,7 @@ class _VideoControlsState extends ConsumerState<VideoControls> {
color: Colors.white,
fontWeight: FontWeight.w500,
fontFeatures: [FontFeature.tabularFigures()],
shadows: VideoControls._controlShadows,
shadows: _controlShadows,
),
),
const SizedBox(width: 12),
@@ -141,7 +107,7 @@ class _VideoControlsState extends ConsumerState<VideoControls> {
padding: EdgeInsets.zero,
onChangeStart: (_) => notifier.hold(),
onChangeEnd: (_) => notifier.release(),
onChanged: isLoaded ? (value) => _onSeek(isCasting, value) : null,
onChanged: isLoaded ? (value) => _onSeek(ref, isCasting, value) : null,
),
],
),
+3 -11
View File
@@ -15,36 +15,30 @@ class DatabaseBackupDto {
DatabaseBackupDto({
required this.filename,
required this.filesize,
required this.timezone,
});
String filename;
num filesize;
String timezone;
@override
bool operator ==(Object other) => identical(this, other) || other is DatabaseBackupDto &&
other.filename == filename &&
other.filesize == filesize &&
other.timezone == timezone;
other.filesize == filesize;
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(filename.hashCode) +
(filesize.hashCode) +
(timezone.hashCode);
(filesize.hashCode);
@override
String toString() => 'DatabaseBackupDto[filename=$filename, filesize=$filesize, timezone=$timezone]';
String toString() => 'DatabaseBackupDto[filename=$filename, filesize=$filesize]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
json[r'filename'] = this.filename;
json[r'filesize'] = this.filesize;
json[r'timezone'] = this.timezone;
return json;
}
@@ -59,7 +53,6 @@ class DatabaseBackupDto {
return DatabaseBackupDto(
filename: mapValueOfType<String>(json, r'filename')!,
filesize: num.parse('${json[r'filesize']}'),
timezone: mapValueOfType<String>(json, r'timezone')!,
);
}
return null;
@@ -109,7 +102,6 @@ class DatabaseBackupDto {
static const requiredKeys = <String>{
'filename',
'filesize',
'timezone',
};
}
+1 -5
View File
@@ -17721,15 +17721,11 @@
},
"filesize": {
"type": "number"
},
"timezone": {
"type": "string"
}
},
"required": [
"filename",
"filesize",
"timezone"
"filesize"
],
"type": "object"
},
@@ -63,7 +63,6 @@ export type DatabaseBackupDeleteDto = {
export type DatabaseBackupDto = {
filename: string;
filesize: number;
timezone: string;
};
export type DatabaseBackupListResponseDto = {
backups: DatabaseBackupDto[];
-1
View File
@@ -4,7 +4,6 @@ import { IsString } from 'class-validator';
export class DatabaseBackupDto {
filename!: string;
filesize!: number;
timezone!: string;
}
export class DatabaseBackupListResponseDto {
@@ -75,10 +75,6 @@ export interface EnvData {
server: string;
};
versionCheck: {
url: string;
};
network: {
trustedProxies: string[];
};
@@ -324,10 +320,6 @@ const getEnv = (): EnvData => {
licensePublicKey: isProd ? productionKeys : stagingKeys,
versionCheck: {
url: isProd ? 'https://version.immich.cloud/version' : 'https://version.dev.immich.cloud/version',
},
network: {
trustedProxies: dto.IMMICH_TRUSTED_PROXIES ?? ['linklocal', 'uniquelocal'],
},
@@ -66,8 +66,7 @@ export class ServerInfoRepository {
async getLatestRelease(): Promise<VersionResponse> {
try {
const { versionCheck } = this.configRepository.getEnv();
const response = await fetch(versionCheck.url);
const response = await fetch('https://version.immich.cloud/version');
if (!response.ok) {
throw new Error(`Version check request failed with status ${response.status}: ${await response.text()}`);
@@ -111,7 +111,6 @@ const validVideos = [
'.mpg',
'.mts',
'.mxf',
'.ts',
'.vob',
'.webm',
'.wmv',
@@ -283,7 +283,6 @@ export class DatabaseBackupService {
async listBackups(): Promise<DatabaseBackupListResponseDto> {
const backupsFolder = StorageCore.getBaseFolder(StorageFolder.Backups);
const files = await this.storageRepository.readdir(backupsFolder);
const timezone = DateTime.local().zoneName;
const validFiles = files
.filter((fn) => isValidDatabaseBackupName(fn))
@@ -293,7 +292,7 @@ export class DatabaseBackupService {
const backups = await Promise.all(
validFiles.map(async (filename) => {
const stats = await this.storageRepository.stat(path.join(backupsFolder, filename));
return { filename, filesize: stats.size, timezone };
return { filename, filesize: stats.size };
}),
);
+11 -1
View File
@@ -2,8 +2,9 @@ import { DateTime } from 'luxon';
import { SemVer } from 'semver';
import { defaults } from 'src/config';
import { serverVersion } from 'src/constants';
import { JobName, JobStatus, SystemMetadataKey } from 'src/enum';
import { ImmichEnvironment, JobName, JobStatus, SystemMetadataKey } from 'src/enum';
import { VersionService } from 'src/services/version.service';
import { mockEnvData } from 'test/repositories/config.repository.mock';
import { factory } from 'test/small.factory';
import { newTestService, ServiceMocks } from 'test/utils';
@@ -72,6 +73,15 @@ describe(VersionService.name, () => {
});
describe('handVersionCheck', () => {
beforeEach(() => {
mocks.config.getEnv.mockReturnValue(mockEnvData({ environment: ImmichEnvironment.Production }));
});
it('should not run in dev mode', async () => {
mocks.config.getEnv.mockReturnValue(mockEnvData({ environment: ImmichEnvironment.Development }));
await expect(sut.handleVersionCheck()).resolves.toEqual(JobStatus.Skipped);
});
it('should not run if the last check was < 60 minutes ago', async () => {
mocks.systemMetadata.get.mockResolvedValue({
checkedAt: DateTime.utc().minus({ minutes: 5 }).toISO(),
+6 -1
View File
@@ -4,7 +4,7 @@ import semver, { SemVer } from 'semver';
import { serverVersion } from 'src/constants';
import { OnEvent, OnJob } from 'src/decorators';
import { ReleaseNotification, ServerVersionResponseDto } from 'src/dtos/server.dto';
import { DatabaseLock, JobName, JobStatus, QueueName, SystemMetadataKey } from 'src/enum';
import { DatabaseLock, ImmichEnvironment, JobName, JobStatus, QueueName, SystemMetadataKey } from 'src/enum';
import { ArgOf } from 'src/repositories/event.repository';
import { BaseService } from 'src/services/base.service';
import { VersionCheckMetadata } from 'src/types';
@@ -71,6 +71,11 @@ export class VersionService extends BaseService {
try {
this.logger.debug('Running version check');
const { environment } = this.configRepository.getEnv();
if (environment === ImmichEnvironment.Development) {
return JobStatus.Skipped;
}
const { newVersionCheck } = await this.getConfig({ withCache: true });
if (!newVersionCheck.enabled) {
return JobStatus.Skipped;
-1
View File
@@ -83,7 +83,6 @@ describe('mimeTypes', () => {
{ mimetype: 'video/mp2t', extension: '.m2t' },
{ mimetype: 'video/mp2t', extension: '.m2ts' },
{ mimetype: 'video/mp2t', extension: '.mts' },
{ mimetype: 'video/mp2t', extension: '.ts' },
{ mimetype: 'video/mp4', extension: '.mp4' },
{ mimetype: 'video/mpeg', extension: '.mpe' },
{ mimetype: 'video/mpeg', extension: '.mpeg' },
-1
View File
@@ -114,7 +114,6 @@ const video: Record<string, string[]> = {
'.mpg': ['video/mpeg'],
'.mts': ['video/mp2t'],
'.mxf': ['application/mxf'],
'.ts': ['video/mp2t'],
'.vob': ['video/mpeg'],
'.webm': ['video/webm'],
'.wmv': ['video/x-ms-wmv'],
@@ -44,10 +44,6 @@ const envData: EnvData = {
server: 'server-public-key',
},
versionCheck: {
url: 'https://version.immich.cloud/version',
},
network: {
trustedProxies: [],
},
@@ -276,6 +276,7 @@
const handleStopSlideshow = async () => {
try {
if (document.fullscreenElement) {
document.body.style.cursor = '';
await document.exitFullscreen();
}
} catch (error) {
@@ -337,7 +338,7 @@
onAction?.(action);
};
let isFullScreen = $derived(!!fullscreenElement);
let isFullScreen = $derived(fullscreenElement !== null);
$effect(() => {
if (album && !album.isActivityEnabled && activityManager.commentCount === 0) {
@@ -85,7 +85,6 @@
});
onDestroy(() => {
setCursorStyle('');
if (unsubscribeRestart) {
unsubscribeRestart();
}
@@ -1,54 +0,0 @@
import { locale } from '$lib/stores/preferences.store';
import { renderWithTooltips } from '$tests/helpers';
import { screen } from '@testing-library/svelte';
import { DateTime } from 'luxon';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import MaintenanceBackupEntry from './MaintenanceBackupEntry.svelte';
vi.mock('$lib/services/database-backups.service', () => ({
getDatabaseBackupActions: () => ({
Download: { type: 'command', title: 'Download', onAction: vi.fn() },
Delete: { type: 'command', title: 'Delete', onAction: vi.fn() },
}),
handleRestoreDatabaseBackup: vi.fn(),
}));
describe('MaintenanceBackupEntry', () => {
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-03-24T12:00:00Z'));
locale.set('en');
});
afterEach(() => {
vi.useRealTimers();
});
it('renders relative backup time using the user timezone instead of UTC', () => {
const backupTimestamp = '20260324T110000';
const expectedRelativeTime = DateTime.fromFormat(backupTimestamp, "yyyyMMdd'T'HHmmss", {
zone: 'Asia/Tokyo',
})
.toLocal()
.toRelative({ locale: 'en' });
const utcRelativeTime = DateTime.fromFormat(backupTimestamp, "yyyyMMdd'T'HHmmss", {
zone: 'UTC',
})
.toLocal()
.toRelative({ locale: 'en' });
expect(expectedRelativeTime).toBeTruthy();
expect(expectedRelativeTime).not.toEqual(utcRelativeTime);
renderWithTooltips(MaintenanceBackupEntry, {
expectedVersion: '1.2.3',
filename: 'immich-db-backup-20260324T110000-v1.2.3-snapshot.sql.gz',
filesize: 1024,
timezone: 'Asia/Tokyo',
});
expect(screen.getByText(expectedRelativeTime!)).toBeInTheDocument();
});
});
@@ -13,17 +13,16 @@
filename: string;
filesize: number;
expectedVersion: string;
timezone?: string;
};
const { filename, filesize, expectedVersion, timezone }: Props = $props();
const { filename, filesize, expectedVersion }: Props = $props();
const filesizeText = $derived(getBytesWithUnit(filesize, 1));
const backupDateTime = $derived.by(() => {
const dateMatch = filename.match(/\d+T\d+/);
if (dateMatch) {
return DateTime.fromFormat(dateMatch[0], "yyyyMMdd'T'HHmmss", { zone: timezone }).toLocal();
return DateTime.fromFormat(dateMatch[0], "yyyyMMdd'T'HHmmss", { zone: 'utc' }).toLocal();
}
return null;
});
@@ -51,13 +51,12 @@
const unknownDateKey = $t('unknown_date');
for (const backup of backups) {
const timezone = backup.timezone;
const dateMatch = backup.filename.match(/\d+T\d+/);
let dateKey: string;
let dt: DateTime;
if (dateMatch) {
dt = DateTime.fromFormat(dateMatch[0], "yyyyMMdd'T'HHmmss", { zone: timezone });
dt = DateTime.fromFormat(dateMatch[0], "yyyyMMdd'T'HHmmss", { zone: 'utc' });
dateKey = dt.toFormat('LLLL d, yyyy');
} else {
dt = DateTime.fromMillis(0);
@@ -129,7 +128,6 @@
filename={backup.filename}
filesize={backup.filesize}
expectedVersion={props.expectedVersion}
timezone={backup.timezone}
/>
{/each}
</Stack>
@@ -145,7 +145,7 @@ describe('TimelineManager', () => {
it('cancels month loading', async () => {
const month = getTimelineMonthByDate(timelineManager, { year: 2024, month: 1 })!;
void timelineManager.loadTimelineMonth({ year: 2024, month: 1 });
const abortSpy = vi.spyOn(month!.loader!.abortController!, 'abort');
const abortSpy = vi.spyOn(month!.loader!.cancelToken!, 'abort');
month?.cancel();
expect(abortSpy).toBeCalledTimes(1);
await timelineManager.loadTimelineMonth({ year: 2024, month: 1 });
@@ -638,8 +638,12 @@ describe('TimelineManager', () => {
const previousMonth = getTimelineMonthByDate(timelineManager, { year: 2024, month: 3 });
const a = month!.getFirstAsset();
const b = previousMonth!.getFirstAsset();
const loadTimelineMonthSpy = vi.spyOn(month!.loader!, 'execute');
const previousMonthSpy = vi.spyOn(previousMonth!.loader!, 'execute');
const previous = await timelineManager.getLaterAsset(a);
expect(previous).toEqual(b);
expect(loadTimelineMonthSpy).toBeCalledTimes(0);
expect(previousMonthSpy).toBeCalledTimes(0);
});
it('skips removed assets', async () => {
@@ -307,8 +307,8 @@ export class TimelineManager extends VirtualScrollManager {
return;
}
if (!this.initTask.succeeded) {
await (this.initTask.running ? this.initTask.waitUntilCompletion() : this.#init(this.#options));
if (!this.initTask.executed) {
await (this.initTask.loading ? this.initTask.waitUntilCompletion() : this.#init(this.#options));
}
const changedWidth = viewport.width !== this.viewportWidth;
@@ -351,10 +351,14 @@ export class TimelineManager extends VirtualScrollManager {
return;
}
if (timelineMonth.loader?.executed) {
return;
}
const executionStatus = await timelineMonth.loader?.execute(async (signal: AbortSignal) => {
await loadFromTimeBuckets(this, timelineMonth, this.#options, signal);
}, cancelable);
if (executionStatus === 'SUCCESS') {
if (executionStatus === 'LOADED') {
updateGeometry(this, timelineMonth, { invalidateHeight: false });
this.updateViewportProximities();
}
@@ -368,7 +372,7 @@ export class TimelineManager extends VirtualScrollManager {
async findTimelineMonthForAsset(asset: AssetDescriptor | AssetResponseDto) {
if (!this.isInitialized) {
await this.initTask.waitUntilSucceeded();
await this.initTask.waitUntilExecution();
}
const { id } = asset;
+124 -138
View File
@@ -2,39 +2,39 @@ import { CancellableTask } from '$lib/utils/cancellable-task';
describe('CancellableTask', () => {
describe('execute', () => {
it('should execute task successfully and return SUCCESS', async () => {
it('should execute task successfully and return LOADED', async () => {
const task = new CancellableTask();
const taskFunction = vi.fn(async (_: AbortSignal) => {
const taskFn = vi.fn(async (_: AbortSignal) => {
await new Promise((resolve) => setTimeout(resolve, 10));
});
const result = await task.execute(taskFunction, true);
const result = await task.execute(taskFn, true);
expect(result).toBe('SUCCESS');
expect(task.succeeded).toBe(true);
expect(task.running).toBe(false);
expect(taskFunction).toHaveBeenCalledTimes(1);
expect(result).toBe('LOADED');
expect(task.executed).toBe(true);
expect(task.loading).toBe(false);
expect(taskFn).toHaveBeenCalledTimes(1);
});
it('should call succeededCallback when task completes successfully', async () => {
const succeededCallback = vi.fn();
const task = new CancellableTask(succeededCallback);
const taskFunction = vi.fn(async () => {});
it('should call loadedCallback when task completes successfully', async () => {
const loadedCallback = vi.fn();
const task = new CancellableTask(loadedCallback);
const taskFn = vi.fn(async () => {});
await task.execute(taskFunction, true);
await task.execute(taskFn, true);
expect(succeededCallback).toHaveBeenCalledTimes(1);
expect(loadedCallback).toHaveBeenCalledTimes(1);
});
it('should return DONE if task is already executed', async () => {
const task = new CancellableTask();
const taskFunction = vi.fn(async () => {});
const taskFn = vi.fn(async () => {});
await task.execute(taskFunction, true);
const result = await task.execute(taskFunction, true);
await task.execute(taskFn, true);
const result = await task.execute(taskFn, true);
expect(result).toBe('DONE');
expect(taskFunction).toHaveBeenCalledTimes(1);
expect(taskFn).toHaveBeenCalledTimes(1);
});
it('should wait if task is already running', async () => {
@@ -43,42 +43,42 @@ describe('CancellableTask', () => {
const taskPromise = new Promise<void>((resolve) => {
resolveTask = resolve;
});
const taskFunction = vi.fn(async () => {
const taskFn = vi.fn(async () => {
await taskPromise;
});
const promise1 = task.execute(taskFunction, true);
const promise2 = task.execute(taskFunction, true);
const promise1 = task.execute(taskFn, true);
const promise2 = task.execute(taskFn, true);
expect(task.running).toBe(true);
expect(task.loading).toBe(true);
resolveTask!();
const [result1, result2] = await Promise.all([promise1, promise2]);
expect(result1).toBe('SUCCESS');
expect(result1).toBe('LOADED');
expect(result2).toBe('WAITED');
expect(taskFunction).toHaveBeenCalledTimes(1);
expect(taskFn).toHaveBeenCalledTimes(1);
});
it('should pass AbortSignal to task function', async () => {
const task = new CancellableTask();
let capturedSignal: AbortSignal | null = null;
const taskFunction = async (signal: AbortSignal) => {
const taskFn = async (signal: AbortSignal) => {
await Promise.resolve();
capturedSignal = signal;
};
await task.execute(taskFunction, true);
await task.execute(taskFn, true);
expect(capturedSignal).toBeInstanceOf(AbortSignal);
});
it('should set cancellable flag correctly', async () => {
const task = new CancellableTask();
const taskFunction = vi.fn(async () => {});
const taskFn = vi.fn(async () => {});
expect(task.cancellable).toBe(true);
const promise = task.execute(taskFunction, false);
const promise = task.execute(taskFn, false);
expect(task.cancellable).toBe(false);
await promise;
});
@@ -89,14 +89,14 @@ describe('CancellableTask', () => {
const taskPromise = new Promise<void>((resolve) => {
resolveTask = resolve;
});
const taskFunction = vi.fn(async () => {
const taskFn = vi.fn(async () => {
await taskPromise;
});
const promise1 = task.execute(taskFunction, false);
const promise1 = task.execute(taskFn, false);
expect(task.cancellable).toBe(false);
const promise2 = task.execute(taskFunction, true);
const promise2 = task.execute(taskFn, true);
expect(task.cancellable).toBe(false);
resolveTask!();
@@ -108,7 +108,7 @@ describe('CancellableTask', () => {
it('should cancel a running task', async () => {
const task = new CancellableTask();
let taskStarted = false;
const taskFunction = async (signal: AbortSignal) => {
const taskFn = async (signal: AbortSignal) => {
taskStarted = true;
await new Promise((resolve) => setTimeout(resolve, 100));
if (signal.aborted) {
@@ -116,7 +116,9 @@ describe('CancellableTask', () => {
}
};
const promise = task.execute(taskFunction, true);
const promise = task.execute(taskFn, true);
// Wait a bit to ensure task has started
await new Promise((resolve) => setTimeout(resolve, 10));
expect(taskStarted).toBe(true);
@@ -124,20 +126,20 @@ describe('CancellableTask', () => {
const result = await promise;
expect(result).toBe('CANCELED');
expect(task.succeeded).toBe(false);
expect(task.executed).toBe(false);
});
it('should call canceledCallback when task is canceled', async () => {
const canceledCallback = vi.fn();
const task = new CancellableTask(undefined, canceledCallback);
const taskFunction = async (signal: AbortSignal) => {
const taskFn = async (signal: AbortSignal) => {
await new Promise((resolve) => setTimeout(resolve, 100));
if (signal.aborted) {
throw new DOMException('Aborted', 'AbortError');
}
};
const promise = task.execute(taskFunction, true);
const promise = task.execute(taskFn, true);
await new Promise((resolve) => setTimeout(resolve, 10));
task.cancel();
await promise;
@@ -147,79 +149,55 @@ describe('CancellableTask', () => {
it('should not cancel if task is not cancellable', async () => {
const task = new CancellableTask();
const taskFunction = vi.fn(async () => {
const taskFn = vi.fn(async () => {
await new Promise((resolve) => setTimeout(resolve, 50));
});
const promise = task.execute(taskFunction, false);
const promise = task.execute(taskFn, false);
task.cancel();
const result = await promise;
expect(result).toBe('SUCCESS');
expect(task.succeeded).toBe(true);
});
it('should return CANCELED when concurrent caller is waiting and task is canceled', async () => {
const task = new CancellableTask();
let resolveTask: () => void;
const taskPromise = new Promise<void>((resolve) => {
resolveTask = resolve;
});
const taskFunction = async (signal: AbortSignal) => {
await taskPromise;
if (signal.aborted) {
throw new DOMException('Aborted', 'AbortError');
}
};
const promise1 = task.execute(taskFunction, true);
const promise2 = task.execute(taskFunction, true);
task.cancel();
resolveTask!();
const [result1, result2] = await Promise.all([promise1, promise2]);
expect(result1).toBe('CANCELED');
expect(result2).toBe('CANCELED');
expect(result).toBe('LOADED');
expect(task.executed).toBe(true);
});
it('should not cancel if task is already executed', async () => {
const task = new CancellableTask();
const taskFunction = vi.fn(async () => {});
const taskFn = vi.fn(async () => {});
await task.execute(taskFunction, true);
expect(task.succeeded).toBe(true);
await task.execute(taskFn, true);
expect(task.executed).toBe(true);
task.cancel();
expect(task.succeeded).toBe(true);
expect(task.executed).toBe(true);
});
});
describe('reset', () => {
it('should reset task to initial state', async () => {
const task = new CancellableTask();
const taskFunction = vi.fn(async () => {});
const taskFn = vi.fn(async () => {});
await task.execute(taskFunction, true);
expect(task.succeeded).toBe(true);
await task.execute(taskFn, true);
expect(task.executed).toBe(true);
await task.reset();
expect(task.succeeded).toBe(false);
expect(task.abortController).toBe(null);
expect(task.running).toBe(false);
expect(task.executed).toBe(false);
expect(task.cancelToken).toBe(null);
expect(task.loading).toBe(false);
});
it('should cancel running task before resetting', async () => {
const task = new CancellableTask();
const taskFunction = async (signal: AbortSignal) => {
const taskFn = async (signal: AbortSignal) => {
await new Promise((resolve) => setTimeout(resolve, 100));
if (signal.aborted) {
throw new DOMException('Aborted', 'AbortError');
}
};
const promise = task.execute(taskFunction, true);
const promise = task.execute(taskFn, true);
await new Promise((resolve) => setTimeout(resolve, 10));
const resetPromise = task.reset();
@@ -227,30 +205,30 @@ describe('CancellableTask', () => {
await promise;
await resetPromise;
expect(task.succeeded).toBe(false);
expect(task.running).toBe(false);
expect(task.executed).toBe(false);
expect(task.loading).toBe(false);
});
it('should allow re-execution after reset', async () => {
const task = new CancellableTask();
const taskFunction = vi.fn(async () => {});
const taskFn = vi.fn(async () => {});
await task.execute(taskFunction, true);
await task.execute(taskFn, true);
await task.reset();
const result = await task.execute(taskFunction, true);
const result = await task.execute(taskFn, true);
expect(result).toBe('SUCCESS');
expect(task.succeeded).toBe(true);
expect(taskFunction).toHaveBeenCalledTimes(2);
expect(result).toBe('LOADED');
expect(task.executed).toBe(true);
expect(taskFn).toHaveBeenCalledTimes(2);
});
});
describe('waitUntilCompletion', () => {
it('should return DONE if task is already executed', async () => {
const task = new CancellableTask();
const taskFunction = vi.fn(async () => {});
const taskFn = vi.fn(async () => {});
await task.execute(taskFunction, true);
await task.execute(taskFn, true);
const result = await task.waitUntilCompletion();
expect(result).toBe('DONE');
@@ -262,11 +240,11 @@ describe('CancellableTask', () => {
const taskPromise = new Promise<void>((resolve) => {
resolveTask = resolve;
});
const taskFunction = async () => {
const taskFn = async () => {
await taskPromise;
};
const executePromise = task.execute(taskFunction, true);
const executePromise = task.execute(taskFn, true);
const waitPromise = task.waitUntilCompletion();
resolveTask!();
@@ -278,14 +256,14 @@ describe('CancellableTask', () => {
it('should return CANCELED if task is canceled', async () => {
const task = new CancellableTask();
const taskFunction = async (signal: AbortSignal) => {
const taskFn = async (signal: AbortSignal) => {
await new Promise((resolve) => setTimeout(resolve, 100));
if (signal.aborted) {
throw new DOMException('Aborted', 'AbortError');
}
};
const executePromise = task.execute(taskFunction, true);
const executePromise = task.execute(taskFn, true);
const waitPromise = task.waitUntilCompletion();
await new Promise((resolve) => setTimeout(resolve, 10));
@@ -297,13 +275,13 @@ describe('CancellableTask', () => {
});
});
describe('waitUntilSucceeded', () => {
describe('waitUntilExecution', () => {
it('should return DONE if task is already executed', async () => {
const task = new CancellableTask();
const taskFunction = vi.fn(async () => {});
const taskFn = vi.fn(async () => {});
await task.execute(taskFunction, true);
const result = await task.waitUntilSucceeded();
await task.execute(taskFn, true);
const result = await task.waitUntilExecution();
expect(result).toBe('DONE');
});
@@ -314,12 +292,12 @@ describe('CancellableTask', () => {
const taskPromise = new Promise<void>((resolve) => {
resolveTask = resolve;
});
const taskFunction = async () => {
const taskFn = async () => {
await taskPromise;
};
const executePromise = task.execute(taskFunction, true);
const waitPromise = task.waitUntilSucceeded();
const executePromise = task.execute(taskFn, true);
const waitPromise = task.waitUntilExecution();
resolveTask!();
@@ -333,7 +311,7 @@ describe('CancellableTask', () => {
const task = new CancellableTask();
let attempt = 0;
const taskFunction = async (signal: AbortSignal) => {
const taskFn = async (signal: AbortSignal) => {
attempt++;
await new Promise((resolve) => setTimeout(resolve, 100));
if (signal.aborted && attempt === 1) {
@@ -342,8 +320,8 @@ describe('CancellableTask', () => {
};
// Start first execution
const executePromise1 = task.execute(taskFunction, true);
const waitPromise = task.waitUntilSucceeded();
const executePromise1 = task.execute(taskFn, true);
const waitPromise = task.waitUntilExecution();
// Cancel the first execution
vi.advanceTimersByTime(10);
@@ -352,12 +330,12 @@ describe('CancellableTask', () => {
await executePromise1;
// Start second execution
const executePromise2 = task.execute(taskFunction, true);
const executePromise2 = task.execute(taskFn, true);
vi.advanceTimersByTime(100);
const [executeResult, waitResult] = await Promise.all([executePromise2, waitPromise]);
expect(executeResult).toBe('SUCCESS');
expect(executeResult).toBe('LOADED');
expect(waitResult).toBe('WAITED');
expect(attempt).toBe(2);
@@ -369,98 +347,98 @@ describe('CancellableTask', () => {
it('should return ERRORED when task throws non-abort error', async () => {
const task = new CancellableTask();
const error = new Error('Task failed');
const taskFunction = async () => {
const taskFn = async () => {
await Promise.resolve();
throw error;
};
const result = await task.execute(taskFunction, true);
const result = await task.execute(taskFn, true);
expect(result).toBe('ERRORED');
expect(task.succeeded).toBe(false);
expect(task.executed).toBe(false);
});
it('should call errorCallback when task throws non-abort error', async () => {
const errorCallback = vi.fn();
const task = new CancellableTask(undefined, undefined, errorCallback);
const error = new Error('Task failed');
const taskFunction = async () => {
const taskFn = async () => {
await Promise.resolve();
throw error;
};
await task.execute(taskFunction, true);
await task.execute(taskFn, true);
expect(errorCallback).toHaveBeenCalledTimes(1);
expect(errorCallback).toHaveBeenCalledWith(error);
});
it('should return ERRORED when task throws AbortError without signal being aborted', async () => {
it('should return CANCELED when task throws AbortError', async () => {
const task = new CancellableTask();
const taskFunction = async () => {
const taskFn = async () => {
await Promise.resolve();
throw new DOMException('Aborted', 'AbortError');
};
const result = await task.execute(taskFunction, true);
const result = await task.execute(taskFn, true);
expect(result).toBe('ERRORED');
expect(task.succeeded).toBe(false);
expect(result).toBe('CANCELED');
expect(task.executed).toBe(false);
});
it('should allow re-execution after error', async () => {
const task = new CancellableTask();
const taskFunction1 = async () => {
const taskFn1 = async () => {
await Promise.resolve();
throw new Error('Failed');
};
const taskFunction2 = vi.fn(async () => {});
const taskFn2 = vi.fn(async () => {});
const result1 = await task.execute(taskFunction1, true);
const result1 = await task.execute(taskFn1, true);
expect(result1).toBe('ERRORED');
const result2 = await task.execute(taskFunction2, true);
expect(result2).toBe('SUCCESS');
expect(task.succeeded).toBe(true);
const result2 = await task.execute(taskFn2, true);
expect(result2).toBe('LOADED');
expect(task.executed).toBe(true);
});
});
describe('running property', () => {
describe('loading property', () => {
it('should return true when task is running', async () => {
const task = new CancellableTask();
let resolveTask: () => void;
const taskPromise = new Promise<void>((resolve) => {
resolveTask = resolve;
});
const taskFunction = async () => {
const taskFn = async () => {
await taskPromise;
};
expect(task.running).toBe(false);
expect(task.loading).toBe(false);
const promise = task.execute(taskFunction, true);
expect(task.running).toBe(true);
const promise = task.execute(taskFn, true);
expect(task.loading).toBe(true);
resolveTask!();
await promise;
expect(task.running).toBe(false);
expect(task.loading).toBe(false);
});
});
describe('complete promise', () => {
it('should resolve when task completes successfully', async () => {
const task = new CancellableTask();
const taskFunction = vi.fn(async () => {});
const taskFn = vi.fn(async () => {});
const completePromise = task.complete;
await task.execute(taskFunction, true);
await task.execute(taskFn, true);
await expect(completePromise).resolves.toBeUndefined();
});
it('should reject when task is canceled', async () => {
const task = new CancellableTask();
const taskFunction = async (signal: AbortSignal) => {
const taskFn = async (signal: AbortSignal) => {
await new Promise((resolve) => setTimeout(resolve, 100));
if (signal.aborted) {
throw new DOMException('Aborted', 'AbortError');
@@ -468,7 +446,7 @@ describe('CancellableTask', () => {
};
const completePromise = task.complete;
const promise = task.execute(taskFunction, true);
const promise = task.execute(taskFn, true);
await new Promise((resolve) => setTimeout(resolve, 10));
task.cancel();
await promise;
@@ -478,13 +456,13 @@ describe('CancellableTask', () => {
it('should reject when task errors', async () => {
const task = new CancellableTask();
const taskFunction = async () => {
const taskFn = async () => {
await Promise.resolve();
throw new Error('Failed');
};
const completePromise = task.complete;
await task.execute(taskFunction, true);
await task.execute(taskFn, true);
await expect(completePromise).rejects.toBeUndefined();
});
@@ -494,22 +472,27 @@ describe('CancellableTask', () => {
it('should automatically call abort() on signal when task is canceled', async () => {
const task = new CancellableTask();
let capturedSignal: AbortSignal | null = null;
const taskFunction = async (signal: AbortSignal) => {
const taskFn = async (signal: AbortSignal) => {
capturedSignal = signal;
// Simulate a long-running task
await new Promise((resolve) => setTimeout(resolve, 100));
if (signal.aborted) {
throw new DOMException('Aborted', 'AbortError');
}
};
const promise = task.execute(taskFunction, true);
const promise = task.execute(taskFn, true);
// Wait a bit to ensure task has started
await new Promise((resolve) => setTimeout(resolve, 10));
expect(capturedSignal).not.toBeNull();
expect(capturedSignal!.aborted).toBe(false);
// Cancel the task
task.cancel();
// Verify the signal was aborted
expect(capturedSignal!.aborted).toBe(true);
const result = await promise;
@@ -519,22 +502,25 @@ describe('CancellableTask', () => {
it('should detect if signal was aborted after task completes', async () => {
const task = new CancellableTask();
let controller: AbortController | null = null;
const taskFunction = async (_: AbortSignal) => {
// Capture the controller to abort it externally before the function returns
controller = task.abortController;
const taskFn = async (_: AbortSignal) => {
// Capture the controller to abort it externally
controller = task.cancelToken;
// Simulate some work
await new Promise((resolve) => setTimeout(resolve, 10));
// Now abort before the function returns
controller?.abort();
};
const result = await task.execute(taskFunction, true);
const result = await task.execute(taskFn, true);
expect(result).toBe('CANCELED');
expect(task.succeeded).toBe(false);
expect(task.executed).toBe(false);
});
it('should handle abort signal in async operations', async () => {
const task = new CancellableTask();
const taskFunction = async (signal: AbortSignal) => {
const taskFn = async (signal: AbortSignal) => {
// Simulate listening to abort signal during async operation
return new Promise<void>((resolve, reject) => {
signal.addEventListener('abort', () => {
reject(new DOMException('Aborted', 'AbortError'));
@@ -543,7 +529,7 @@ describe('CancellableTask', () => {
});
};
const promise = task.execute(taskFunction, true);
const promise = task.execute(taskFn, true);
await new Promise((resolve) => setTimeout(resolve, 10));
task.cancel();
+48 -61
View File
@@ -1,60 +1,47 @@
/**
* A one-shot async task with cancellation support via AbortController/AbortSignal.
*
* State machine:
*
* IDLE ──execute()──▶ RUNNING ──task succeeds──▶ SUCCEEDED (terminal)
* │
* ├──cancel()/abort──▶ CANCELED ──▶ IDLE
* └──task throws─────▶ ERRORED ──▶ IDLE
*
* SUCCEEDED is terminal — further execute() calls return 'DONE'.
* Call reset() to move from SUCCEEDED back to IDLE for re-execution.
*
* execute() return values: 'SUCCESS' | 'DONE' | 'WAITED' | 'CANCELED' | 'ERRORED'
*/
export class CancellableTask {
abortController: AbortController | null = null;
cancelToken: AbortController | null = null;
cancellable: boolean = true;
/**
* A promise that resolves once the task completes, and rejects if the task is canceled or errored.
* A promise that resolves once the bucket is loaded, and rejects if bucket is canceled.
*/
complete!: Promise<unknown>;
succeeded: boolean = false;
executed: boolean = false;
private completeResolve: (() => void) | undefined;
private completeReject: (() => void) | undefined;
private loadedSignal: (() => void) | undefined;
private canceledSignal: (() => void) | undefined;
constructor(
private succeededCallback?: () => void,
private loadedCallback?: () => void,
private canceledCallback?: () => void,
private errorCallback?: (error: unknown) => void,
) {
this.init();
}
get running() {
return !!this.abortController;
get loading() {
return !!this.cancelToken;
}
async waitUntilCompletion() {
if (this.succeeded) {
if (this.executed) {
return 'DONE';
}
// The `complete` promise resolves when executed, rejects when canceled/errored.
try {
await this.complete;
const complete = this.complete;
await complete;
return 'WAITED';
} catch {
// expected when canceled
// ignore
}
return 'CANCELED';
}
async waitUntilSucceeded() {
async waitUntilExecution() {
// Keep retrying until the task completes successfully (not canceled)
for (;;) {
try {
if (this.succeeded) {
if (this.executed) {
return 'DONE';
}
await this.complete;
@@ -65,60 +52,59 @@ export class CancellableTask {
}
}
async execute(task: (abortSignal: AbortSignal) => Promise<void>, cancellable: boolean) {
if (this.succeeded) {
async execute<F extends (abortSignal: AbortSignal) => Promise<void>>(f: F, cancellable: boolean) {
if (this.executed) {
return 'DONE';
}
// if promise is pending, wait on previous request instead.
if (this.abortController) {
if (!cancellable) {
this.cancellable = false;
}
try {
await this.complete;
return 'WAITED';
} catch {
return 'CANCELED';
if (this.cancelToken) {
// if promise is pending, and preventCancel is requested,
// do not allow transition from prevent cancel to allow cancel.
if (this.cancellable && !cancellable) {
this.cancellable = cancellable;
}
await this.complete;
return 'WAITED';
}
this.cancellable = cancellable;
const abortController = (this.abortController = new AbortController());
const cancelToken = (this.cancelToken = new AbortController());
try {
await task(abortController.signal);
if (abortController.signal.aborted) {
await f(cancelToken.signal);
if (cancelToken.signal.aborted) {
return 'CANCELED';
}
this.#transitionToSucceeded();
return 'SUCCESS';
this.#transitionToExecuted();
return 'LOADED';
} catch (error) {
if (abortController.signal.aborted) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
if ((error as any).name === 'AbortError') {
// abort error is not treated as an error, but as a cancellation.
return 'CANCELED';
}
this.#transitionToErrored(error);
return 'ERRORED';
} finally {
if (this.abortController === abortController) {
this.abortController = null;
}
this.cancelToken = null;
}
}
private init() {
this.abortController = null;
this.succeeded = false;
this.complete = new Promise<void>((resolve, reject) => {
this.completeResolve = resolve;
this.completeReject = reject;
this.cancelToken = null;
this.executed = false;
this.loadedSignal = resolve;
this.canceledSignal = reject;
});
// Suppress unhandled rejection warning
this.complete.catch(() => {});
}
// will reset this job back to the initial state (isLoaded=false, no errors, etc)
async reset() {
this.#transitionToCancelled();
if (this.abortController) {
if (this.cancelToken) {
await this.waitUntilCompletion();
}
this.init();
@@ -129,26 +115,27 @@ export class CancellableTask {
}
#transitionToCancelled() {
if (this.succeeded) {
if (this.executed) {
return;
}
if (!this.cancellable) {
return;
}
this.abortController?.abort();
this.completeReject?.();
this.cancelToken?.abort();
this.canceledSignal?.();
this.init();
this.canceledCallback?.();
}
#transitionToSucceeded() {
this.succeeded = true;
this.completeResolve?.();
this.succeededCallback?.();
#transitionToExecuted() {
this.executed = true;
this.loadedSignal?.();
this.loadedCallback?.();
}
#transitionToErrored(error: unknown) {
this.completeReject?.();
this.cancelToken = null;
this.canceledSignal?.();
this.init();
this.errorCallback?.(error);
}