Compare commits

..

23 Commits

Author SHA1 Message Date
Mees Frensel
81b6720a06 layout: don't shrink buttons, tabular time text 2026-04-25 00:17:50 +02:00
Mees Frensel
49a831ff40 Merge branch 'main' into feat/video-player 2026-04-24 23:24:51 +02:00
Yaros
39cfad7136 feat(mobile): action bottom sheet on map timeline (#27515) 2026-04-24 09:30:10 -05:00
renovate[bot]
350056dd1a fix(deps): update dependency uuid to v14 [security] (#28046)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-23 11:24:33 +02:00
Mees Frensel
92a9cb1bcb Merge branch 'main' into feat/video-player 2026-04-20 12:03:02 +02:00
Mees Frensel
a16f383bd6 Merge branch 'main' into feat/video-player 2026-04-01 14:00:52 +02:00
timonrieger
86aef3ecc9 enhance video player layout by ensuring full width and maintaining aspect ratio 2026-03-17 18:08:56 +01:00
timonrieger
96fbc97032 fix full width on video player on safari 2026-03-17 12:44:37 +01:00
Mees Frensel
d608fde175 update memory viewer 2026-03-16 15:49:40 +01:00
Mees Frensel
d1e0552b9d change ui 2026-03-13 00:37:23 +01:00
Mees Frensel
d6cfb2b98e Merge branch 'main' into feat/video-player 2026-03-12 16:36:29 +01:00
Mees Frensel
a733584f49 remove seek buttons and center controls, and put time range above controls 2026-03-11 13:53:03 +01:00
Mees Frensel
5baf860289 Merge branch 'main' into feat/video-player 2026-03-11 13:14:00 +01:00
Mees Frensel
b67ff2c19a always display time range 2026-02-23 13:09:43 +01:00
timonrieger
f45eb1e7e4 fix black screen issue 2026-02-23 11:23:28 +01:00
Mees Frensel
3396180d62 re-add playsinline for safari iphone playback 2026-02-21 00:38:36 +01:00
Mees Frensel
3359c971d4 disable video shortcut keys 2026-02-21 00:32:24 +01:00
Mees Frensel
2e17f1af16 fix memories 2026-02-21 00:27:02 +01:00
Mees Frensel
e408cd3601 Merge branch 'main' into feat/video-player 2026-02-20 20:58:45 +01:00
Mees Frensel
7f5ba33ab5 wrap memory viewer in media-controller for muted/volume store 2026-02-16 13:06:47 +01:00
Mees Frensel
21b539be5d Merge branch 'main' into feat/video-player 2026-02-16 12:26:13 +01:00
Mees Frensel
0a347d84b2 add seek & rate buttons 2026-02-16 11:54:43 +01:00
Mees Frensel
a99631b12f feat(web): custom video player controls 2026-02-13 12:27:03 +01:00
18 changed files with 329 additions and 179 deletions

View File

@@ -1761,6 +1761,7 @@
"play_original_video": "Play original video",
"play_original_video_setting_description": "Prefer playback of original videos rather than transcoded videos. If original asset is not compatible it may not playback correctly.",
"play_transcoded_video": "Play transcoded video",
"playback_speed": "Playback speed",
"please_auth_to_access": "Please authenticate to access",
"port": "Port",
"preferences_settings_subtitle": "Manage the app's preferences",
@@ -2436,6 +2437,7 @@
"workflows": "Workflows",
"workflows_help_text": "Workflows automate actions on your assets based on triggers and filters",
"wrong_pin_code": "Wrong PIN code",
"x_of_total": "{x}/{total}",
"year": "Year",
"years_ago": "{years, plural, one {# year} other {# years}} ago",
"yes": "Yes",

View File

@@ -17,7 +17,6 @@
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 */; };
B21E34B02E5B09190031FDB9 /* FileLogger.swift in Sources */ = {isa = PBXBuildFile; fileRef = B21E34B12E5B09100031FDB9 /* FileLogger.swift */; };
B25D377A2E72CA15008B6CA7 /* Connectivity.g.swift in Sources */ = {isa = PBXBuildFile; fileRef = B25D37782E72CA15008B6CA7 /* Connectivity.g.swift */; };
B25D377C2E72CA26008B6CA7 /* ConnectivityApiImpl.swift in Sources */ = {isa = PBXBuildFile; fileRef = B25D377B2E72CA20008B6CA7 /* ConnectivityApiImpl.swift */; };
B2BE315F2E5E5229006EEF88 /* BackgroundWorker.g.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2BE315E2E5E5229006EEF88 /* BackgroundWorker.g.swift */; };
@@ -104,7 +103,6 @@
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>"; };
B21E34B12E5B09100031FDB9 /* FileLogger.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileLogger.swift; sourceTree = "<group>"; };
B25D37782E72CA15008B6CA7 /* Connectivity.g.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Connectivity.g.swift; sourceTree = "<group>"; };
B25D377B2E72CA20008B6CA7 /* ConnectivityApiImpl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectivityApiImpl.swift; sourceTree = "<group>"; };
B2BE315E2E5E5229006EEF88 /* BackgroundWorker.g.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundWorker.g.swift; sourceTree = "<group>"; };
@@ -306,7 +304,6 @@
B2BE315E2E5E5229006EEF88 /* BackgroundWorker.g.swift */,
B21E34AB2E5B09100031FDB9 /* BackgroundWorker.swift */,
B21E34A92E5AFD210031FDB9 /* BackgroundWorkerApiImpl.swift */,
B21E34B12E5B09100031FDB9 /* FileLogger.swift */,
);
path = Background;
sourceTree = "<group>";
@@ -617,7 +614,6 @@
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */,
A01DD69B2F7F43B40049AB63 /* ImageRequest.swift in Sources */,
B21E34AC2E5B09190031FDB9 /* BackgroundWorker.swift in Sources */,
B21E34B02E5B09190031FDB9 /* FileLogger.swift in Sources */,
FE5499F32F1197D8006016CB /* LocalImages.g.swift in Sources */,
FE5499F62F11980E006016CB /* LocalImagesImpl.swift in Sources */,
FE5499F42F1197D8006016CB /* RemoteImages.g.swift in Sources */,

View File

@@ -80,34 +80,29 @@ class BackgroundWorker: BackgroundWorkerBgHostApi {
* starts the engine, and sets up a timeout timer if specified.
*/
func run() {
FileLogger.log("BackgroundWorker:run Starting Flutter engine for taskType=\(taskType) maxSeconds=\(maxSeconds.map(String.init) ?? "nil")")
// Start the Flutter engine with the specified callback as the entry point
let isRunning = engine.run(
withEntrypoint: "backgroundSyncNativeEntrypoint",
libraryURI: "package:immich_mobile/domain/services/background_worker.service.dart"
)
// Verify that the Flutter engine started successfully
if !isRunning {
FileLogger.log("BackgroundWorker:run Flutter engine failed to start, completing with success=false")
complete(success: false)
return
}
FileLogger.log("BackgroundWorker:run Flutter engine started")
// Register plugins in the new engine
GeneratedPluginRegistrant.register(with: engine)
// Register custom plugins
AppDelegate.registerPlugins(with: engine, messenger: engine.binaryMessenger)
flutterApi = BackgroundWorkerFlutterApi(binaryMessenger: engine.binaryMessenger)
BackgroundWorkerBgHostApiSetup.setUp(binaryMessenger: engine.binaryMessenger, api: self)
FileLogger.log("BackgroundWorker:run Plugins registered, waiting for Flutter onInitialized")
// Set up a timeout timer if maxSeconds was specified to prevent runaway background tasks
if maxSeconds != nil {
// Schedule a timer to cancel the task after the specified timeout period
Timer.scheduledTimer(withTimeInterval: TimeInterval(maxSeconds!), repeats: false) { _ in
FileLogger.log("BackgroundWorker:run maxSeconds=\(self.maxSeconds!) timer fired, closing task")
self.close()
}
}
@@ -119,7 +114,6 @@ class BackgroundWorker: BackgroundWorkerBgHostApi {
* This method acts as a bridge between the native iOS background task system and Flutter.
*/
func onInitialized() throws {
FileLogger.log("BackgroundWorker:onInitialized Flutter ready, calling onIosUpload isRefresh=\(self.taskType == .refresh)")
flutterApi?.onIosUpload(isRefresh: self.taskType == .refresh, maxSeconds: maxSeconds.map { Int64($0) }, completion: { result in
self.handleHostResult(result: result)
})
@@ -132,22 +126,16 @@ class BackgroundWorker: BackgroundWorkerBgHostApi {
*/
func close() {
if isComplete {
FileLogger.log("BackgroundWorker:close Already complete, ignoring close()")
return
}
FileLogger.log("BackgroundWorker:close Cancel requested, signaling Flutter (taskType=\(taskType))")
flutterApi?.cancel { result in
FileLogger.log("BackgroundWorker:close Flutter cancel acknowledged")
self.complete(success: false)
}
// Fallback safety mechanism: ensure completion is called within 2 seconds
// This prevents the background task from hanging indefinitely if Flutter doesn't respond
Timer.scheduledTimer(withTimeInterval: 2, repeats: false) { _ in
if !self.isComplete {
FileLogger.log("BackgroundWorker:close 2s fallback fired, Flutter did not acknowledge cancel")
}
self.complete(success: false)
}
}
@@ -161,12 +149,8 @@ class BackgroundWorker: BackgroundWorkerBgHostApi {
*/
private func handleHostResult(result: Result<Void, PigeonError>) {
switch result {
case .success():
FileLogger.log("BackgroundWorker:handleHostResult Flutter onIosUpload succeeded (taskType=\(taskType))")
self.complete(success: true)
case .failure(let error):
FileLogger.log("BackgroundWorker:handleHostResult Flutter onIosUpload failed: \(error.localizedDescription) (taskType=\(taskType))")
self.close()
case .success(): self.complete(success: true)
case .failure(_): self.close()
}
}
@@ -182,8 +166,7 @@ class BackgroundWorker: BackgroundWorkerBgHostApi {
if(isComplete) {
return
}
FileLogger.log("BackgroundWorker:complete Tearing down engine, success=\(success) (taskType=\(taskType))")
isComplete = true
AppDelegate.cancelPlugins(with: engine)
engine.destroyContext()

View File

@@ -5,7 +5,7 @@ class BackgroundWorkerApiImpl: BackgroundWorkerFgHostApi {
func enable() throws {
BackgroundWorkerApiImpl.scheduleRefreshWorker()
BackgroundWorkerApiImpl.scheduleProcessingWorker()
FileLogger.log("BackgroundWorkerApiImpl:enable Background worker scheduled")
print("BackgroundWorkerApiImpl:enable Background worker scheduled")
}
func configure(settings: BackgroundWorkerSettings) throws {
@@ -19,7 +19,7 @@ class BackgroundWorkerApiImpl: BackgroundWorkerFgHostApi {
func disable() throws {
BGTaskScheduler.shared.cancel(taskRequestWithIdentifier: BackgroundWorkerApiImpl.refreshTaskID);
BGTaskScheduler.shared.cancel(taskRequestWithIdentifier: BackgroundWorkerApiImpl.processingTaskID);
FileLogger.log("BackgroundWorkerApiImpl:disableUploadWorker Disabled background workers")
print("BackgroundWorkerApiImpl:disableUploadWorker Disabled background workers")
}
private static let refreshTaskID = "app.alextran.immich.background.refreshUpload"
@@ -30,7 +30,6 @@ class BackgroundWorkerApiImpl: BackgroundWorkerFgHostApi {
BGTaskScheduler.shared.register(
forTaskWithIdentifier: processingTaskID, using: nil) { task in
if task is BGProcessingTask {
FileLogger.log("BackgroundWorkerApiImpl:BGProcessingTask Background Processing task received")
handleBackgroundProcessing(task: task as! BGProcessingTask)
}
}
@@ -38,11 +37,9 @@ class BackgroundWorkerApiImpl: BackgroundWorkerFgHostApi {
BGTaskScheduler.shared.register(
forTaskWithIdentifier: refreshTaskID, using: nil) { task in
if task is BGAppRefreshTask {
FileLogger.log("BackgroundWorkerApiImpl:BGAppRefreshTask Background Refresh task received")
handleBackgroundRefresh(task: task as! BGAppRefreshTask)
}
}
FileLogger.log("BackgroundWorkerApiImpl:registerBackgroundWorkers Background workers registered")
}
private static func scheduleRefreshWorker() {
@@ -51,9 +48,8 @@ class BackgroundWorkerApiImpl: BackgroundWorkerFgHostApi {
do {
try BGTaskScheduler.shared.submit(backgroundRefresh)
FileLogger.log("BackgroundWorkerApiImpl:scheduleRefreshWorker Scheduled Refresh task")
} catch {
FileLogger.log("BackgroundWorkerApiImpl:scheduleRefreshWorker Could not schedule the refresh upload task \(error.localizedDescription)")
print("Could not schedule the refresh upload task \(error.localizedDescription)")
}
}
@@ -65,32 +61,25 @@ class BackgroundWorkerApiImpl: BackgroundWorkerFgHostApi {
do {
try BGTaskScheduler.shared.submit(backgroundProcessing)
FileLogger.log("BackgroundWorkerApiImpl:scheduleProcessingWorker Scheduled Processing task")
} catch {
FileLogger.log("BackgroundWorkerApiImpl:scheduleProcessingWorker Could not schedule the processing upload task \(error.localizedDescription)")
print("Could not schedule the processing upload task \(error.localizedDescription)")
}
}
private static func handleBackgroundRefresh(task: BGAppRefreshTask) {
FileLogger.log("BackgroundWorkerApiImpl:handleBackgroundRefresh Entered, re-queuing next refresh task")
scheduleRefreshWorker()
// If another task is running, cede the background time back to the OS
if taskSemaphore.wait(timeout: .now()) == .success {
FileLogger.log("BackgroundWorkerApiImpl:handleBackgroundRefresh Starting background worker")
// Restrict the refresh task to run only for a maximum of (maxSeconds) seconds
runBackgroundWorker(task: task, taskType: .refresh, maxSeconds: 20)
} else {
FileLogger.log("BackgroundWorkerApiImpl:handleBackgroundRefresh Processing task is in progress")
task.setTaskCompleted(success: true)
task.setTaskCompleted(success: false)
}
}
private static func handleBackgroundProcessing(task: BGProcessingTask) {
FileLogger.log("BackgroundWorkerApiImpl:handleBackgroundProcessing Entered, re-queuing next processing task")
scheduleProcessingWorker()
FileLogger.log("BackgroundWorkerApiImpl:handleBackgroundProcessing Waiting for taskSemaphore")
taskSemaphore.wait()
FileLogger.log("BackgroundWorkerApiImpl:handleBackgroundProcessing Semaphore acquired, starting background worker")
// There are no restrictions for processing tasks. Although, the OS could signal expiration at any time
runBackgroundWorker(task: task, taskType: .processing, maxSeconds: nil)
}
@@ -116,12 +105,11 @@ class BackgroundWorkerApiImpl: BackgroundWorkerFgHostApi {
}
task.expirationHandler = {
FileLogger.log("BackgroundWorkerApiImpl:runBackgroundWorker iOS signaled expiration (taskType=\(taskType)), closing worker")
DispatchQueue.main.async {
backgroundWorker.close()
}
isSuccess = false
// Schedule a timer to signal the semaphore after 2 seconds
Timer.scheduledTimer(withTimeInterval: 2, repeats: false) { _ in
semaphore.signal()
@@ -134,6 +122,6 @@ class BackgroundWorkerApiImpl: BackgroundWorkerFgHostApi {
semaphore.wait()
task.setTaskCompleted(success: isSuccess)
FileLogger.log("BackgroundWorkerApiImpl:runBackgroundWorker Background task completed with success: \(isSuccess)")
print("Background task completed with success: \(isSuccess)")
}
}

View File

@@ -1,33 +0,0 @@
import Foundation
enum FileLogger {
private static let queue = DispatchQueue(label: "app.alextran.immich.FileLogger")
private static let isoFormatter: ISO8601DateFormatter = {
let f = ISO8601DateFormatter()
f.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
return f
}()
private static var logFileURL: URL? {
guard let docs = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first
else { return nil }
return docs.appendingPathComponent("background_log.txt")
}
static func log(_ message: String) {
let line = "[\(isoFormatter.string(from: Date()))] \(message)\n"
print(line, terminator: "")
queue.async {
guard let url = logFileURL, let data = line.data(using: .utf8) else { return }
if FileManager.default.fileExists(atPath: url.path) {
if let handle = try? FileHandle(forWritingTo: url) {
defer { try? handle.close() }
try? handle.seekToEnd()
try? handle.write(contentsOf: data)
}
} else {
try? data.write(to: url, options: .atomic)
}
}
}
}

View File

@@ -115,9 +115,7 @@
<key>LSRequiresIPhoneOS</key>
<true/>
<key>LSSupportsOpeningDocumentsInPlace</key>
<true/>
<key>UIFileSharingEnabled</key>
<true/>
<string>No</string>
<key>MGLMapboxMetricsEnabledSettingShownInApp</key>
<true/>
<key>NSAppTransportSecurity</key>

View File

@@ -2,17 +2,21 @@ import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/presentation/widgets/bottom_sheet/base_bottom_sheet.widget.dart';
import 'package:immich_mobile/presentation/widgets/bottom_sheet/general_bottom_sheet.widget.dart';
import 'package:immich_mobile/presentation/widgets/map/map.state.dart';
import 'package:immich_mobile/presentation/widgets/timeline/timeline.widget.dart';
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
class MapBottomSheet extends StatelessWidget {
const MapBottomSheet({super.key});
final Key? sheetKey;
const MapBottomSheet({super.key, this.sheetKey});
@override
Widget build(BuildContext context) {
return BaseBottomSheet(
key: sheetKey,
initialChildSize: 0.25,
maxChildSize: 0.75,
shouldCloseOnMinExtent: false,
@@ -49,7 +53,7 @@ class _ScopedMapTimeline extends StatelessWidget {
return timelineService;
}),
],
child: const Timeline(appBar: null, bottomSheet: null, withScrubber: false),
child: const Timeline(appBar: null, bottomSheet: GeneralBottomSheet(minChildSize: 0.23), withScrubber: false),
);
}
}

View File

@@ -11,6 +11,7 @@ import 'package:immich_mobile/domain/utils/event_stream.dart';
import 'package:immich_mobile/extensions/asyncvalue_extensions.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/presentation/widgets/bottom_sheet/base_bottom_sheet.widget.dart';
import 'package:immich_mobile/presentation/widgets/bottom_sheet/map_bottom_sheet.widget.dart';
import 'package:immich_mobile/presentation/widgets/map/map.state.dart';
import 'package:immich_mobile/presentation/widgets/map/map_utils.dart';
@@ -53,6 +54,7 @@ class _DriftMapState extends ConsumerState<DriftMap> {
final _reloadMutex = AsyncMutex();
final _debouncer = Debouncer(interval: const Duration(milliseconds: 500), maxWaitTime: const Duration(seconds: 2));
final ValueNotifier<double> bottomSheetOffset = ValueNotifier(0.25);
final GlobalKey _bottomSheetKey = GlobalKey();
StreamSubscription? _eventSubscription;
@override
@@ -184,7 +186,7 @@ class _DriftMapState extends ConsumerState<DriftMap> {
return Stack(
children: [
_Map(initialLocation: widget.initialLocation, onMapCreated: onMapCreated, onMapReady: onMapReady),
_DynamicBottomSheet(bottomSheetOffset: bottomSheetOffset),
_DynamicBottomSheet(bottomSheetOffset: bottomSheetOffset, sheetKey: _bottomSheetKey),
_DynamicMyLocationButton(onZoomToLocation: onZoomToLocation, bottomSheetOffset: bottomSheetOffset),
],
);
@@ -224,8 +226,9 @@ class _Map extends StatelessWidget {
class _DynamicBottomSheet extends StatefulWidget {
final ValueNotifier<double> bottomSheetOffset;
final GlobalKey sheetKey;
const _DynamicBottomSheet({required this.bottomSheetOffset});
const _DynamicBottomSheet({required this.bottomSheetOffset, required this.sheetKey});
@override
State<_DynamicBottomSheet> createState() => _DynamicBottomSheetState();
@@ -236,10 +239,13 @@ class _DynamicBottomSheetState extends State<_DynamicBottomSheet> {
Widget build(BuildContext context) {
return NotificationListener<DraggableScrollableNotification>(
onNotification: (notification) {
widget.bottomSheetOffset.value = notification.extent;
return true;
final sheet = notification.context.findAncestorWidgetOfExactType<BaseBottomSheet>();
if (sheet?.key == widget.sheetKey) {
widget.bottomSheetOffset.value = notification.extent;
}
return false;
},
child: const MapBottomSheet(),
child: MapBottomSheet(sheetKey: widget.sheetKey),
);
}
}

View File

@@ -469,6 +469,7 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
ref.read(timelineStateProvider.notifier).setScrolling(true);
},
child: Stack(
clipBehavior: Clip.none,
children: [
timeline,
if (isBottomWidgetVisible)

31
pnpm-lock.yaml generated
View File

@@ -570,8 +570,8 @@ importers:
specifier: ^2.0.0
version: 2.0.9
uuid:
specifier: ^11.1.0
version: 11.1.0
specifier: ^14.0.0
version: 14.0.0
validator:
specifier: ^13.12.0
version: 13.15.35
@@ -804,6 +804,9 @@ importers:
maplibre-gl:
specifier: ^5.6.2
version: 5.23.0
media-chrome:
specifier: ^4.19.0
version: 4.19.0(react@19.2.5)
pmtiles:
specifier: ^4.3.0
version: 4.4.1
@@ -6173,6 +6176,11 @@ packages:
ccount@2.0.1:
resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==}
ce-la-react@0.3.2:
resolution: {integrity: sha512-QJ6k4lOD/btI08xG8jBPxRCGXvCnusGGkTsiXk0u3NqUu/W+BXRnFD4PYjwtqh8AWmGa5LDbGk0fLQsqr0nSMA==}
peerDependencies:
react: '>=17.0.0'
chai@5.3.3:
resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==}
engines: {node: '>=18'}
@@ -9065,6 +9073,9 @@ packages:
mdn-data@2.0.30:
resolution: {integrity: sha512-GaqWWShW4kv/G9IEucWScBx9G1/vsFZZJUO+tD26M8J8z3Kw5RDQjaoZe03YAClgeS/SWPOcb4nkFBTEi5DUEA==}
media-chrome@4.19.0:
resolution: {integrity: sha512-HWhDTwts+BSbdPkkB1VsJXp5kvL0IxY7xFT5tBwliM2+89kTPVTnHnev+9it2f9PweANjT/C8/C/S0PW9oyZbA==}
media-typer@0.3.0:
resolution: {integrity: sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==}
engines: {node: '>= 0.6'}
@@ -12110,6 +12121,10 @@ packages:
resolution: {integrity: sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==}
hasBin: true
uuid@14.0.0:
resolution: {integrity: sha512-Qo+uWgilfSmAhXCMav1uYFynlQO7fMFiMVZsQqZRMIXp0O7rR7qjkj+cPvBHLgBqi960QCoo/PH2/6ZtVqKvrg==}
hasBin: true
uuid@8.3.2:
resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==}
hasBin: true
@@ -18737,6 +18752,10 @@ snapshots:
ccount@2.0.1: {}
ce-la-react@0.3.2(react@19.2.5):
dependencies:
react: 19.2.5
chai@5.3.3:
dependencies:
assertion-error: 2.0.1
@@ -22124,6 +22143,12 @@ snapshots:
mdn-data@2.0.30: {}
media-chrome@4.19.0(react@19.2.5):
dependencies:
ce-la-react: 0.3.2(react@19.2.5)
transitivePeerDependencies:
- react
media-typer@0.3.0: {}
media-typer@1.1.0: {}
@@ -25779,6 +25804,8 @@ snapshots:
uuid@11.1.0: {}
uuid@14.0.0: {}
uuid@8.3.2: {}
validator@13.15.35: {}

View File

@@ -114,7 +114,7 @@
"thumbhash": "^0.1.1",
"transformation-matrix": "^3.1.0",
"ua-parser-js": "^2.0.0",
"uuid": "^11.1.0",
"uuid": "^14.0.0",
"validator": "^13.12.0",
"zod": "^4.3.6"
},

View File

@@ -51,6 +51,7 @@
"lodash-es": "^4.17.21",
"luxon": "^3.4.4",
"maplibre-gl": "^5.6.2",
"media-chrome": "^4.19.0",
"pmtiles": "^4.3.0",
"qrcode": "^1.5.4",
"simple-icons": "^16.0.0",

View File

@@ -540,6 +540,7 @@
cacheKey={asset.thumbhash}
projectionType={asset.exifInfo?.projectionType}
loopVideo={$slideshowState !== SlideshowState.PlaySlideshow}
extendedControls
onPreviousAsset={() => navigateAsset('previous')}
onNextAsset={() => navigateAsset('next')}
onClose={closeViewer}

View File

@@ -2,26 +2,51 @@
import FaceEditor from '$lib/components/asset-viewer/face-editor/FaceEditor.svelte';
import VideoRemoteViewer from '$lib/components/asset-viewer/VideoRemoteViewer.svelte';
import { assetViewerFadeDuration } from '$lib/constants';
import { castManager } from '$lib/managers/cast-manager.svelte';
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
import {
autoPlayVideo,
loopVideo as loopVideoPreference,
videoViewerMuted,
videoViewerVolume,
} from '$lib/stores/preferences.store';
import { castManager } from '$lib/managers/cast-manager.svelte';
import { autoPlayVideo, loopVideo as loopVideoPreference } from '$lib/stores/preferences.store';
import { getAssetMediaUrl, getAssetPlaybackUrl } from '$lib/utils';
import { AssetMediaSize } from '@immich/sdk';
import { LoadingSpinner } from '@immich/ui';
import { timeToSeconds } from '$lib/utils/date-time';
import { AssetMediaSize, type AssetResponseDto } from '@immich/sdk';
import { Icon, LoadingSpinner } from '@immich/ui';
import {
mdiCheck,
mdiChevronLeft,
mdiChevronRight,
mdiFullscreen,
mdiFullscreenExit,
mdiPause,
mdiPlay,
mdiVolumeHigh,
mdiVolumeLow,
mdiVolumeMedium,
mdiVolumeMute,
} from '@mdi/js';
import 'media-chrome/media-control-bar';
import 'media-chrome/media-controller';
import 'media-chrome/media-fullscreen-button';
import 'media-chrome/media-mute-button';
import 'media-chrome/media-play-button';
import 'media-chrome/media-playback-rate-button';
import 'media-chrome/media-time-display';
import 'media-chrome/media-time-range';
import 'media-chrome/media-volume-range';
import 'media-chrome/menu/media-playback-rate-menu';
import 'media-chrome/menu/media-settings-menu';
import 'media-chrome/menu/media-settings-menu-button';
import 'media-chrome/menu/media-settings-menu-item';
import { onDestroy, onMount } from 'svelte';
import { useSwipe, type SwipeCustomEvent } from 'svelte-gestures';
import { t } from 'svelte-i18n';
import { fade } from 'svelte/transition';
interface Props {
asset: AssetResponseDto;
assetId: string;
loopVideo: boolean;
cacheKey: string | null;
playOriginalVideo: boolean;
extendedControls?: boolean;
onPreviousAsset?: () => void;
onNextAsset?: () => void;
onVideoEnded?: () => void;
@@ -30,10 +55,12 @@
}
let {
asset,
assetId,
loopVideo,
cacheKey,
playOriginalVideo,
extendedControls = false,
onPreviousAsset = () => {},
onNextAsset = () => {},
onVideoEnded = () => {},
@@ -48,12 +75,12 @@
? getAssetMediaUrl({ id: assetId, size: AssetMediaSize.Original, cacheKey })
: getAssetPlaybackUrl({ id: assetId, cacheKey }),
);
let isScrubbing = $state(false);
const duration = $derived(timeToSeconds(asset.duration!));
const aspectRatio = $derived(asset.width && asset.height ? `${asset.width} / ${asset.height}` : undefined);
let showVideo = $state(false);
let hasFocused = $state(false);
onMount(() => {
// Show video after mount to ensure fading in.
showVideo = true;
});
@@ -73,7 +100,7 @@
const handleCanPlay = async (video: HTMLVideoElement) => {
try {
if (!video.paused && !isScrubbing) {
if (!video.paused) {
await video.play();
onVideoStarted();
}
@@ -138,33 +165,83 @@
/>
</div>
{:else}
<video
bind:this={videoPlayer}
loop={$loopVideoPreference && loopVideo}
autoplay={$autoPlayVideo}
playsinline
controls
disablePictureInPicture
class="h-full object-contain"
{...useSwipe(onSwipe)}
oncanplay={(e) => handleCanPlay(e.currentTarget)}
onended={onVideoEnded}
onvolumechange={(e) => ($videoViewerMuted = e.currentTarget.muted)}
onseeking={() => (isScrubbing = true)}
onseeked={() => (isScrubbing = false)}
onplaying={(e) => {
if (!hasFocused) {
e.currentTarget.focus();
hasFocused = true;
}
}}
onclose={() => onClose()}
muted={$videoViewerMuted}
bind:volume={$videoViewerVolume}
poster={getAssetMediaUrl({ id: assetId, size: AssetMediaSize.Preview, cacheKey })}
src={assetFileUrl}
<!-- dir=ltr based on https://github.com/videojs/video.js/issues/949 -->
<media-controller
dir="ltr"
nohotkeys
class="h-full max-w-full dark"
style:aspect-ratio={aspectRatio}
defaultduration={duration}
>
</video>
<video
bind:this={videoPlayer}
slot="media"
loop={$loopVideoPreference && loopVideo}
autoplay={$autoPlayVideo}
disablePictureInPicture
playsinline
{...useSwipe(onSwipe)}
class="h-full object-contain"
oncanplay={(e) => handleCanPlay(e.currentTarget)}
onended={onVideoEnded}
onplaying={(e) => {
if (!hasFocused) {
e.currentTarget.focus();
hasFocused = true;
}
}}
onclose={onClose}
poster={getAssetMediaUrl({ id: asset.id, size: AssetMediaSize.Preview, cacheKey })}
src={assetFileUrl}
></video>
{#if extendedControls}
<media-settings-menu hidden anchor="auto" class="border-light-300 rounded-xl border shadow-sm w-3xs">
<Icon slot="checked-indicator" icon={mdiCheck} class="m-2" />
<media-settings-menu-item class="rounded-lg p-1 ps-2 mx-1">
{$t('playback_speed')}
<Icon slot="suffix" icon={mdiChevronRight} class="m-2" />
<media-playback-rate-menu slot="submenu" hidden rates="0.5 1 1.5 2">
<Icon slot="back-icon" icon={mdiChevronLeft} class="m-2" />
<span slot="title">{$t('playback_speed')}</span>
</media-playback-rate-menu>
</media-settings-menu-item>
</media-settings-menu>
{/if}
<div class="flex flex-col justify-end w-full h-32 px-4 bg-linear-to-b to-black/80">
<media-control-bar part="bottom" class="flex w-full h-10 gap-2">
<media-play-button class="rounded-full p-2 shrink-0 outline-none">
<Icon slot="play" icon={mdiPlay} />
<Icon slot="pause" icon={mdiPause} />
</media-play-button>
<media-time-display showduration class="rounded-lg p-2 outline-none"></media-time-display>
<span class="flex-grow"></span>
<div
class="volume-wrapper rounded-full shrink-0 bg-light-100/0 hover:bg-light-100 transition-colors duration-400"
>
<media-volume-range class="h-full bg-none outline-none"></media-volume-range>
<media-mute-button class="p-2 bg-none outline-none">
<Icon slot="off" icon={mdiVolumeMute} />
<Icon slot="low" icon={mdiVolumeLow} />
<Icon slot="medium" icon={mdiVolumeMedium} />
<Icon slot="high" icon={mdiVolumeHigh} />
</media-mute-button>
</div>
{#if extendedControls}
<media-fullscreen-button class="rounded-full p-2 shrink-0 outline-none">
<Icon slot="enter" icon={mdiFullscreen} />
<Icon slot="exit" icon={mdiFullscreenExit} />
</media-fullscreen-button>
<media-settings-menu-button class="rounded-full p-2 shrink-0 outline-none"></media-settings-menu-button>
{/if}
</media-control-bar>
<media-time-range class="w-full h-8 px-2 pb-3 rounded-lg outline-none"></media-time-range>
</div>
</media-controller>
{#if isLoading}
<div class="absolute flex place-content-center place-items-center">
@@ -178,3 +255,85 @@
{/if}
</div>
{/if}
<style>
media-controller {
--media-control-background: none;
--media-control-hover-background: var(--immich-ui-light-100);
--media-focus-box-shadow: 0 0 0 2px var(--immich-ui-dark);
--media-font-family: var(--font-sans);
--media-font-size: var(--text-base);
--media-font-weight: var(--font-weight-medium);
--media-menu-border-radius: var(--radius-xl);
--media-menu-gap: var(--spacing);
--media-menu-item-hover-background: var(--immich-ui-light-200);
--media-menu-item-icon-height: 1em;
--media-menu-item-indicator-height: 1em;
--media-primary-color: var(--immich-ui-dark);
--media-time-range-buffered-color: var(--immich-ui-dark-400);
--media-time-range-hover-bottom: 0;
--media-time-range-hover-height: 100%;
--media-range-thumb-box-shadow: none;
--media-range-thumb-opacity: 0;
--media-range-thumb-transition: opacity 0.15s ease;
--media-range-track-border-radius: 2px;
--media-range-track-height: 3.5px;
--media-range-padding: 0;
--media-settings-menu-background: var(--immich-ui-light-100);
--media-text-content-height: var(--text-base--line-height);
--media-tooltip-arrow-display: none;
--media-tooltip-border-radius: var(--radius-lg);
--media-tooltip-background-color: var(--immich-ui-light-200);
--media-tooltip-distance: 8px;
--media-tooltip-padding: calc(var(--spacing) * 2) calc(var(--spacing) * 3.5);
}
media-time-display {
font-variant-numeric: tabular-nums;
}
media-time-range,
media-volume-range {
--media-control-hover-background: none;
}
media-time-range:hover,
media-volume-range:hover {
--media-range-thumb-opacity: 1;
}
*::part(tooltip) {
--media-font-size: var(--text-xs);
--media-text-content-height: var(--text-xs--line-height);
color: white;
}
*[mediavolumeunavailable] {
--media-volume-range-display: none;
}
.volume-wrapper {
--media-control-hover-background: none;
}
media-volume-range:has(+ media-mute-button) {
padding: 0;
margin: 0;
width: 0;
overflow: hidden;
transition: width 0.4s ease-out;
}
/* Expand volume control in all relevant states */
.volume-wrapper:hover > media-volume-range,
media-volume-range:has(+ media-mute-button:hover),
media-volume-range:has(+ media-mute-button:focus),
media-volume-range:has(+ media-mute-button:focus-within),
media-volume-range:hover,
media-volume-range:focus,
media-volume-range:focus-within {
padding: 0 calc(var(--spacing) * 2);
margin-left: calc(var(--spacing) * 2);
width: 70px;
}
</style>

View File

@@ -11,6 +11,7 @@
cacheKey: string | null;
loopVideo: boolean;
playOriginalVideo: boolean;
extendedControls?: boolean;
onClose?: () => void;
onPreviousAsset?: () => void;
onNextAsset?: () => void;
@@ -25,6 +26,7 @@
cacheKey,
loopVideo,
playOriginalVideo,
extendedControls = false,
onPreviousAsset,
onClose,
onNextAsset,
@@ -41,8 +43,10 @@
<VideoNativeViewer
{loopVideo}
{cacheKey}
{asset}
assetId={effectiveAssetId}
{playOriginalVideo}
{extendedControls}
{onPreviousAsset}
{onNextAsset}
{onVideoEnded}

View File

@@ -51,9 +51,6 @@ const persistedObject = <T>(key: string, defaults: T) =>
export const mapSettings = persistedObject<MapSettings>('map-settings', defaultMapSettings);
export const videoViewerVolume = persisted<number>('video-viewer-volume', 1, {});
export const videoViewerMuted = persisted<boolean>('video-viewer-muted', false, {});
export interface AlbumViewSettings {
view: string;
filter: string;

View File

@@ -4,17 +4,16 @@
import { autoPlayVideo } from '$lib/stores/preferences.store';
import { getAssetMediaUrl, getAssetPlaybackUrl } from '$lib/utils';
import { AssetMediaSize } from '@immich/sdk';
import 'media-chrome/media-controller';
import { onMount } from 'svelte';
import { fade } from 'svelte/transition';
interface Props {
asset: TimelineAsset;
videoPlayer: HTMLVideoElement | undefined;
videoViewerMuted?: boolean;
videoViewerVolume?: number;
}
let { asset, videoPlayer = $bindable(), videoViewerVolume, videoViewerMuted }: Props = $props();
let { asset, videoPlayer = $bindable() }: Props = $props();
let showVideo: boolean = $state(false);
@@ -26,16 +25,19 @@
{#if showVideo}
<div class="h-full w-full bg-pink-9000" transition:fade={{ duration: assetViewerFadeDuration }}>
<video
bind:this={videoPlayer}
autoplay={$autoPlayVideo}
playsinline
class="h-full w-full rounded-2xl object-contain transition-all"
src={getAssetPlaybackUrl({ id: asset.id })}
poster={getAssetMediaUrl({ id: asset.id, size: AssetMediaSize.Preview })}
draggable="false"
muted={videoViewerMuted}
volume={videoViewerVolume}
></video>
<media-controller id="memory-video" nohotkeys class="h-full w-full rounded-2xl object-contain transition-all">
<!-- svelte-ignore a11y_media_has_caption -->
<video
bind:this={videoPlayer}
slot="media"
autoplay={$autoPlayVideo}
playsinline
disablepictureinpicture
class="h-full w-full"
src={getAssetPlaybackUrl({ id: asset.id })}
poster={getAssetMediaUrl({ id: asset.id, size: AssetMediaSize.Preview })}
draggable="false"
></video>
</media-controller>
</div>
{/if}

View File

@@ -26,11 +26,11 @@
import type { TimelineAsset, Viewport } from '$lib/managers/timeline-manager/types';
import { Route } from '$lib/route';
import { getAssetBulkActions } from '$lib/services/asset.service';
import { locale, videoViewerMuted, videoViewerVolume } from '$lib/stores/preferences.store';
import { locale } from '$lib/stores/preferences.store';
import { getAssetMediaUrl, handlePromiseError, memoryLaneTitle } from '$lib/utils';
import { fromISODateTimeUTC, toTimelineAsset } from '$lib/utils/timeline-util';
import { AssetMediaSize, AssetTypeEnum, getAssetInfo } from '@immich/sdk';
import { ActionButton, IconButton, toastManager } from '@immich/ui';
import { ActionButton, IconButton, Text, toastManager } from '@immich/ui';
import {
mdiCardsOutline,
mdiChevronDown,
@@ -50,6 +50,7 @@
} from '@mdi/js';
import type { NavigationTarget, Page } from '@sveltejs/kit';
import { DateTime } from 'luxon';
import 'media-chrome/media-mute-button';
import { t } from 'svelte-i18n';
import type { Attachment } from 'svelte/attachments';
import { Tween } from 'svelte/motion';
@@ -316,7 +317,6 @@
$effect(() => {
if (videoPlayer) {
videoPlayer.muted = $videoViewerMuted;
initPlayer();
}
});
@@ -388,42 +388,62 @@
{/if}
{/snippet}
<div class="flex place-content-center place-items-center gap-2 overflow-hidden">
<div class="w-12.5 dark">
<IconButton
shape="round"
variant="ghost"
color="secondary"
aria-label={paused ? $t('play_memories') : $t('pause_memories')}
icon={paused ? mdiPlay : mdiPause}
onclick={() => handlePromiseError(handleAction('PlayPauseButtonClick', paused ? 'play' : 'pause'))}
/>
</div>
<div class="flex place-content-center place-items-center gap-2 dark">
<IconButton
shape="round"
variant="ghost"
color="secondary"
aria-label={paused ? $t('play_memories') : $t('pause_memories')}
icon={paused ? mdiPlay : mdiPause}
onclick={() => handlePromiseError(handleAction('PlayPauseButtonClick', paused ? 'play' : 'pause'))}
/>
{#each current.memory.assets as asset, index (asset.id)}
<a class="relative w-full py-2" href={asHref(asset)} aria-label={$t('view')}>
<a class="relative grow py-2" href={asHref(asset)} aria-label={$t('view')}>
<span class="absolute start-0 h-0.5 w-full bg-gray-500"></span>
<span class="absolute start-0 h-0.5 bg-white" style:width={`${toProgressPercentage(index)}%`}></span>
</a>
{/each}
<div>
<p class="text-small">
{(current.assetIndex + 1).toLocaleString($locale)}/{current.memory.assets.length.toLocaleString($locale)}
</p>
</div>
<Text size="small">
{$t('x_of_total', {
values: {
x: (current.assetIndex + 1).toLocaleString($locale),
total: current.memory.assets.length.toLocaleString($locale),
},
})}
</Text>
{#if currentTimelineAssets.some((asset) => asset.type === AssetTypeEnum.Video)}
<div class="w-12.5 dark">
<media-mute-button
mediacontroller={videoPlayer ? 'memory-video' : ''}
disabled={!videoPlayer}
class="bg-transparent rounded-full focus-visible:outline-2 outline-offset-2 outline-dark"
style="--media-focus-box-shadow: none;"
>
<IconButton
slot="off"
disabled={!videoPlayer}
tabindex={-1}
shape="round"
variant="ghost"
color="secondary"
aria-label={$videoViewerMuted ? $t('unmute_memories') : $t('mute_memories')}
icon={$videoViewerMuted ? mdiVolumeOff : mdiVolumeHigh}
onclick={() => ($videoViewerMuted = !$videoViewerMuted)}
aria-label={$t('unmute_memories')}
icon={mdiVolumeOff}
onclick={() => {}}
/>
</div>
<IconButton
slot="high"
disabled={!videoPlayer}
tabindex={-1}
shape="round"
variant="ghost"
color="secondary"
aria-label={$t('mute_memories')}
icon={mdiVolumeHigh}
onclick={() => {}}
/>
</media-mute-button>
{/if}
</div>
</ControlAppBar>
@@ -495,12 +515,7 @@
<div class="relative h-full w-full rounded-2xl bg-black">
{#key current.asset.id}
{#if current.asset.isVideo}
<MemoryVideoViewer
asset={current.asset}
bind:videoPlayer
videoViewerMuted={$videoViewerMuted}
videoViewerVolume={$videoViewerVolume}
/>
<MemoryVideoViewer asset={current.asset} bind:videoPlayer />
{:else}
<MemoryPhotoViewer asset={current.asset} onImageLoad={resetAndPlay} />
{/if}
@@ -519,7 +534,6 @@
color="secondary"
aria-label={isSaved ? $t('unfavorite') : $t('favorite')}
onclick={() => handleSaveMemory()}
class="w-12 h-12"
/>
<!-- <IconButton
icon={mdiShareVariantOutline}