Merge branch 'main' into push-zunuwtznrlpm

This commit is contained in:
Alex
2026-03-10 20:07:50 -05:00
committed by GitHub
42 changed files with 440 additions and 732 deletions

View File

@@ -1,170 +0,0 @@
name: Manage release PR
on:
workflow_dispatch:
push:
branches:
- main
concurrency:
group: ${{ github.workflow }}
cancel-in-progress: true
permissions: {}
jobs:
bump:
runs-on: ubuntu-latest
steps:
- name: Generate a token
id: generate-token
uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1
with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
token: ${{ steps.generate-token.outputs.token }}
persist-credentials: true
ref: main
- name: Install uv
uses: astral-sh/setup-uv@eac588ad8def6316056a12d4907a9d4d84ff7a3b # v7.3.0
- name: Setup pnpm
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
- name: Setup Node
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
with:
node-version-file: './server/.nvmrc'
cache: 'pnpm'
cache-dependency-path: '**/pnpm-lock.yaml'
- name: Determine release type
id: bump-type
uses: ietf-tools/semver-action@c90370b2958652d71c06a3484129a4d423a6d8a8 # v1.11.0
with:
token: ${{ steps.generate-token.outputs.token }}
- name: Bump versions
env:
TYPE: ${{ steps.bump-type.outputs.bump }}
run: |
if [ "$TYPE" == "none" ]; then
exit 1 # TODO: Is there a cleaner way to abort the workflow?
fi
misc/release/pump-version.sh -s $TYPE -m true
- name: Manage Outline release document
id: outline
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
env:
OUTLINE_API_KEY: ${{ secrets.OUTLINE_API_KEY }}
NEXT_VERSION: ${{ steps.bump-type.outputs.next }}
with:
github-token: ${{ steps.generate-token.outputs.token }}
script: |
const fs = require('fs');
const outlineKey = process.env.OUTLINE_API_KEY;
const parentDocumentId = 'da856355-0844-43df-bd71-f8edce5382d9'
const collectionId = 'e2910656-714c-4871-8721-447d9353bd73';
const baseUrl = 'https://outline.immich.cloud';
const listResponse = await fetch(`${baseUrl}/api/documents.list`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${outlineKey}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({ parentDocumentId })
});
if (!listResponse.ok) {
throw new Error(`Outline list failed: ${listResponse.statusText}`);
}
const listData = await listResponse.json();
const allDocuments = listData.data || [];
const document = allDocuments.find(doc => doc.title === 'next');
let documentId;
let documentUrl;
let documentText;
if (!document) {
// Create new document
console.log('No existing document found. Creating new one...');
const notesTmpl = fs.readFileSync('misc/release/notes.tmpl', 'utf8');
const createResponse = await fetch(`${baseUrl}/api/documents.create`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${outlineKey}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
title: 'next',
text: notesTmpl,
collectionId: collectionId,
parentDocumentId: parentDocumentId,
publish: true
})
});
if (!createResponse.ok) {
throw new Error(`Failed to create document: ${createResponse.statusText}`);
}
const createData = await createResponse.json();
documentId = createData.data.id;
const urlId = createData.data.urlId;
documentUrl = `${baseUrl}/doc/next-${urlId}`;
documentText = createData.data.text || '';
console.log(`Created new document: ${documentUrl}`);
} else {
documentId = document.id;
const docPath = document.url;
documentUrl = `${baseUrl}${docPath}`;
documentText = document.text || '';
console.log(`Found existing document: ${documentUrl}`);
}
// Generate GitHub release notes
console.log('Generating GitHub release notes...');
const releaseNotesResponse = await github.rest.repos.generateReleaseNotes({
owner: context.repo.owner,
repo: context.repo.repo,
tag_name: `${process.env.NEXT_VERSION}`,
});
// Combine the content
const changelog = `
# ${process.env.NEXT_VERSION}
${documentText}
${releaseNotesResponse.data.body}
---
`
const existingChangelog = fs.existsSync('CHANGELOG.md') ? fs.readFileSync('CHANGELOG.md', 'utf8') : '';
fs.writeFileSync('CHANGELOG.md', changelog + existingChangelog, 'utf8');
core.setOutput('document_url', documentUrl);
- name: Create PR
id: create-pr
uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v8.1.0
with:
token: ${{ steps.generate-token.outputs.token }}
commit-message: 'chore: release ${{ steps.bump-type.outputs.next }}'
title: 'chore: release ${{ steps.bump-type.outputs.next }}'
body: 'Release notes: ${{ steps.outline.outputs.document_url }}'
labels: 'changelog:skip'
branch: 'release/next'
draft: true

View File

@@ -20,7 +20,7 @@
"@types/lodash-es": "^4.17.12",
"@types/micromatch": "^4.0.9",
"@types/mock-fs": "^4.13.1",
"@types/node": "^24.10.14",
"@types/node": "^24.11.0",
"@vitest/coverage-v8": "^4.0.0",
"byte-size": "^9.0.0",
"cli-progress": "^3.12.0",

View File

@@ -155,7 +155,7 @@ services:
redis:
container_name: immich_redis
image: docker.io/valkey/valkey:9@sha256:2bce660b767cb62c8c0ea020e94a230093be63dbd6af4f21b044960517a5842d
image: docker.io/valkey/valkey:9@sha256:3eeb09785cd61ec8e3be35f8804c8892080f3ca21934d628abc24ee4ed1698f6
healthcheck:
test: redis-cli ping || exit 1

View File

@@ -56,7 +56,7 @@ services:
redis:
container_name: immich_redis
image: docker.io/valkey/valkey:9@sha256:2bce660b767cb62c8c0ea020e94a230093be63dbd6af4f21b044960517a5842d
image: docker.io/valkey/valkey:9@sha256:3eeb09785cd61ec8e3be35f8804c8892080f3ca21934d628abc24ee4ed1698f6
healthcheck:
test: redis-cli ping || exit 1
restart: always

View File

@@ -61,7 +61,7 @@ services:
redis:
container_name: immich_redis
image: docker.io/valkey/valkey:9@sha256:2bce660b767cb62c8c0ea020e94a230093be63dbd6af4f21b044960517a5842d
image: docker.io/valkey/valkey:9@sha256:3eeb09785cd61ec8e3be35f8804c8892080f3ca21934d628abc24ee4ed1698f6
user: '1000:1000'
security_opt:
- no-new-privileges:true

View File

@@ -49,7 +49,7 @@ services:
redis:
container_name: immich_redis
image: docker.io/valkey/valkey:9@sha256:2bce660b767cb62c8c0ea020e94a230093be63dbd6af4f21b044960517a5842d
image: docker.io/valkey/valkey:9@sha256:3eeb09785cd61ec8e3be35f8804c8892080f3ca21934d628abc24ee4ed1698f6
healthcheck:
test: redis-cli ping || exit 1
restart: always

View File

@@ -44,7 +44,7 @@ services:
redis:
container_name: immich-e2e-redis
image: docker.io/valkey/valkey:9@sha256:2bce660b767cb62c8c0ea020e94a230093be63dbd6af4f21b044960517a5842d
image: docker.io/valkey/valkey:9@sha256:3eeb09785cd61ec8e3be35f8804c8892080f3ca21934d628abc24ee4ed1698f6
healthcheck:
test: redis-cli ping || exit 1

View File

@@ -32,7 +32,7 @@
"@playwright/test": "^1.44.1",
"@socket.io/component-emitter": "^3.1.2",
"@types/luxon": "^3.4.2",
"@types/node": "^24.10.14",
"@types/node": "^24.11.0",
"@types/pg": "^8.15.1",
"@types/pngjs": "^6.0.4",
"@types/supertest": "^6.0.2",

View File

@@ -7,6 +7,6 @@ const String defaultColorPresetName = "indigo";
const Color immichBrandColorLight = Color(0xFF4150AF);
const Color immichBrandColorDark = Color(0xFFACCBFA);
const Color whiteOpacity75 = Color.fromARGB((0.75 * 255) ~/ 1, 255, 255, 255);
const Color whiteOpacity75 = Color.fromRGBO(255, 255, 255, 0.75);
const Color red400 = Color(0xFFEF5350);
const Color grey200 = Color(0xFFEEEEEE);

View File

@@ -19,7 +19,6 @@ import 'package:immich_mobile/presentation/widgets/images/image_provider.dart';
import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart';
import 'package:immich_mobile/providers/app_settings.provider.dart';
import 'package:immich_mobile/providers/asset_viewer/is_motion_video_playing.provider.dart';
import 'package:immich_mobile/providers/asset_viewer/video_player_provider.dart';
import 'package:immich_mobile/services/app_settings.service.dart';
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
import 'package:immich_mobile/widgets/common/immich_loading_indicator.dart';
@@ -248,11 +247,6 @@ class _AssetPageState extends ConsumerState<AssetPage> {
if (scaleState != PhotoViewScaleState.initial) {
if (_dragStart == null) _viewer.setControls(false);
final heroTag = ref.read(assetViewerProvider).currentAsset?.heroTag;
if (heroTag != null) {
ref.read(videoPlayerProvider(heroTag).notifier).pause();
}
return;
}

View File

@@ -61,15 +61,27 @@ class ViewerBottomBar extends ConsumerWidget {
),
),
child: Container(
color: Colors.black.withAlpha(125),
padding: EdgeInsets.only(bottom: context.padding.bottom, top: 16),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
if (asset.isVideo) VideoControls(videoPlayerName: asset.heroTag),
if (!isReadonlyModeEnabled)
Row(mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: actions),
],
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.bottomCenter,
end: Alignment.topCenter,
colors: [Colors.black45, Colors.black12, Colors.transparent],
stops: [0.0, 0.7, 1.0],
),
),
child: SafeArea(
top: false,
child: Padding(
padding: const EdgeInsets.only(top: 16),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
if (asset.isVideo) VideoControls(videoPlayerName: asset.heroTag),
if (!isReadonlyModeEnabled)
Row(mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: actions),
],
),
),
),
),
),

View File

@@ -10,7 +10,6 @@ import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/extensions/platform_extensions.dart';
import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart';
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/video_viewer_controls.widget.dart';
import 'package:immich_mobile/providers/app_settings.provider.dart';
import 'package:immich_mobile/providers/asset_viewer/is_motion_video_playing.provider.dart';
import 'package:immich_mobile/providers/asset_viewer/video_player_provider.dart';
@@ -186,11 +185,7 @@ class _NativeVideoViewerState extends ConsumerState<NativeVideoViewer> with Widg
final source = await _videoSource;
if (source == null || !mounted) return;
unawaited(
nc.loadVideoSource(source).catchError((error) {
_log.severe('Error loading video source: $error');
}),
);
await _notifier.load(source);
final loopVideo = ref.read(appSettingsServiceProvider).getSetting<bool>(AppSettingsEnum.loopVideo);
await _notifier.setLoop(!widget.asset.isMotionPhoto && loopVideo);
await _notifier.setVolume(1);
@@ -213,21 +208,28 @@ class _NativeVideoViewerState extends ConsumerState<NativeVideoViewer> with Widg
@override
Widget build(BuildContext context) {
// Prevent the provider from being disposed whilst the widget is alive.
ref.listen(videoPlayerProvider(widget.asset.heroTag), (_, __) {});
final isCasting = ref.watch(castProvider.select((c) => c.isCasting));
final status = ref.watch(videoPlayerProvider(widget.asset.heroTag).select((v) => v.status));
return Stack(
children: [
Center(child: widget.image),
if (!isCasting)
Visibility.maintain(
visible: _isVideoReady,
child: NativeVideoPlayerView(onViewReady: _initController),
),
if (widget.showControls) Center(child: VideoViewerControls(asset: widget.asset)),
],
return IgnorePointer(
child: Stack(
children: [
Center(child: widget.image),
if (!isCasting) ...[
Visibility.maintain(
visible: _isVideoReady,
child: NativeVideoPlayerView(onViewReady: _initController),
),
Center(
child: AnimatedOpacity(
opacity: status == VideoPlaybackStatus.buffering ? 1.0 : 0.0,
duration: const Duration(milliseconds: 400),
child: const CircularProgressIndicator(),
),
),
],
],
),
);
}
}

View File

@@ -1,114 +0,0 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
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/widgets/asset_viewer/center_play_button.dart';
import 'package:immich_mobile/widgets/common/delayed_loading_indicator.dart';
class VideoViewerControls extends HookConsumerWidget {
final BaseAsset asset;
final Duration hideTimerDuration;
const VideoViewerControls({super.key, required this.asset, this.hideTimerDuration = const Duration(seconds: 5)});
@override
Widget build(BuildContext context, WidgetRef ref) {
final videoPlayerName = asset.heroTag;
final assetIsVideo = asset.isVideo;
final showControls = ref.watch(assetViewerProvider.select((s) => s.showingControls && !s.showingDetails));
final status = ref.watch(videoPlayerProvider(videoPlayerName).select((value) => value.status));
final cast = ref.watch(castProvider);
// A timer to hide the controls
final hideTimer = useTimer(hideTimerDuration, () {
if (!context.mounted) {
return;
}
final status = ref.read(videoPlayerProvider(videoPlayerName)).status;
// Do not hide on paused
if (status != VideoPlaybackStatus.paused && status != VideoPlaybackStatus.completed && assetIsVideo) {
ref.read(assetViewerProvider.notifier).setControls(false);
}
});
final showBuffering = status == VideoPlaybackStatus.buffering && !cast.isCasting;
/// Shows the controls and starts the timer to hide them
void showControlsAndStartHideTimer() {
hideTimer.reset();
ref.read(assetViewerProvider.notifier).setControls(true);
}
// When playback starts, reset the hide timer
ref.listen(videoPlayerProvider(videoPlayerName).select((v) => v.status), (previous, next) {
if (next == VideoPlaybackStatus.playing) {
hideTimer.reset();
}
});
/// Toggles between playing and pausing depending on the state of the video
void togglePlay() {
showControlsAndStartHideTimer();
if (cast.isCasting) {
switch (cast.castState) {
case CastState.playing:
ref.read(castProvider.notifier).pause();
case CastState.paused:
ref.read(castProvider.notifier).play();
default:
}
return;
}
final notifier = ref.read(videoPlayerProvider(videoPlayerName).notifier);
switch (status) {
case VideoPlaybackStatus.playing:
notifier.pause();
case VideoPlaybackStatus.completed:
notifier.restart();
default:
notifier.play();
}
}
void toggleControlsVisibility() {
if (showBuffering) return;
if (showControls) {
ref.read(assetViewerProvider.notifier).setControls(false);
} else {
showControlsAndStartHideTimer();
}
}
return GestureDetector(
behavior: HitTestBehavior.translucent,
onTap: toggleControlsVisibility,
child: IgnorePointer(
ignoring: !showControls,
child: Stack(
children: [
if (showBuffering)
const Center(child: DelayedLoadingIndicator(fadeInDuration: Duration(milliseconds: 400)))
else
CenterPlayButton(
backgroundColor: Colors.black54,
iconColor: Colors.white,
isFinished: status == VideoPlaybackStatus.completed,
isPlaying:
status == VideoPlaybackStatus.playing || (cast.isCasting && cast.castState == CastState.playing),
show: assetIsVideo && showControls,
onPressed: togglePlay,
),
],
),
),
);
}
}

View File

@@ -75,17 +75,29 @@ class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget {
child: AnimatedOpacity(
opacity: opacity,
duration: Durations.short2,
child: AppBar(
backgroundColor: showingDetails ? Colors.transparent : Colors.black.withValues(alpha: 0.5),
leading: const _AppBarBackButton(),
iconTheme: const IconThemeData(size: 22, color: Colors.white),
actionsIconTheme: const IconThemeData(size: 22, color: Colors.white),
shape: const Border(),
actions: showingDetails || isReadonlyModeEnabled
? null
: isInLockedView
? lockedViewActions
: actions,
child: DecoratedBox(
decoration: BoxDecoration(
gradient: showingDetails
? null
: const LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Colors.black45, Colors.black12, Colors.transparent],
stops: [0.0, 0.7, 1.0],
),
),
child: AppBar(
backgroundColor: Colors.transparent,
leading: const _AppBarBackButton(),
iconTheme: const IconThemeData(size: 22, color: Colors.white),
actionsIconTheme: const IconThemeData(size: 22, color: Colors.white),
shape: const Border(),
actions: showingDetails || isReadonlyModeEnabled
? null
: isInLockedView
? lockedViewActions
: actions,
),
),
),
);

View File

@@ -100,11 +100,11 @@ class AssetViewerStateNotifier extends Notifier<AssetViewerState> {
return;
}
state = state.copyWith(showingDetails: showing, showingControls: showing ? true : state.showingControls);
if (showing) {
final heroTag = state.currentAsset?.heroTag;
if (heroTag != null) {
ref.read(videoPlayerProvider(heroTag).notifier).pause();
}
final heroTag = state.currentAsset?.heroTag;
if (heroTag != null) {
final notifier = ref.read(videoPlayerProvider(heroTag).notifier);
showing ? notifier.hold() : notifier.release();
}
}

View File

@@ -44,10 +44,7 @@ class VideoPlayerNotifier extends StateNotifier<VideoPlayerState> {
NativeVideoPlayerController? _controller;
Timer? _bufferingTimer;
Timer? _seekTimer;
void attachController(NativeVideoPlayerController controller) {
_controller = controller;
}
VideoPlaybackStatus? _holdStatus;
@override
void dispose() {
@@ -59,6 +56,19 @@ class VideoPlayerNotifier extends StateNotifier<VideoPlayerState> {
super.dispose();
}
void attachController(NativeVideoPlayerController controller) {
_controller = controller;
}
Future<void> load(VideoSource source) async {
_startBufferingTimer();
try {
await _controller?.loadVideoSource(source);
} catch (e) {
_log.severe('Error loading video source: $e');
}
}
Future<void> pause() async {
if (_controller == null) return;
@@ -94,16 +104,50 @@ class VideoPlayerNotifier extends StateNotifier<VideoPlayerState> {
}
void seekTo(Duration position) {
if (_controller == null) return;
if (_controller == null || state.position == position) return;
state = state.copyWith(position: position);
_seekTimer?.cancel();
_seekTimer = Timer(const Duration(milliseconds: 100), () {
_controller?.seekTo(position.inMilliseconds);
if (_seekTimer?.isActive ?? false) return;
_seekTimer = Timer(const Duration(milliseconds: 150), () {
_controller?.seekTo(state.position.inMilliseconds);
});
}
void toggle() {
_holdStatus = null;
switch (state.status) {
case VideoPlaybackStatus.paused:
play();
case VideoPlaybackStatus.playing || VideoPlaybackStatus.buffering:
pause();
case VideoPlaybackStatus.completed:
restart();
}
}
/// Pauses playback and preserves the current status for later restoration.
void hold() {
if (_holdStatus != null) return;
_holdStatus = state.status;
pause();
}
/// Restores playback to the status before [hold] was called.
void release() {
final status = _holdStatus;
_holdStatus = null;
switch (status) {
case VideoPlaybackStatus.playing || VideoPlaybackStatus.buffering:
play();
default:
}
}
Future<void> restart() async {
seekTo(Duration.zero);
await play();
@@ -149,13 +193,12 @@ class VideoPlayerNotifier extends StateNotifier<VideoPlayerState> {
final position = Duration(milliseconds: playbackInfo.position);
if (state.position == position) return;
if (state.status == VideoPlaybackStatus.buffering) {
state = state.copyWith(position: position, status: VideoPlaybackStatus.playing);
} else {
state = state.copyWith(position: position);
}
if (state.status == VideoPlaybackStatus.playing) _startBufferingTimer();
_startBufferingTimer();
state = state.copyWith(
position: position,
status: state.status == VideoPlaybackStatus.buffering ? VideoPlaybackStatus.playing : null,
);
}
void onNativeStatusChanged() {
@@ -173,9 +216,7 @@ class VideoPlayerNotifier extends StateNotifier<VideoPlayerState> {
onNativePlaybackEnded();
}
if (state.status != newStatus) {
state = state.copyWith(status: newStatus);
}
if (state.status != newStatus) state = state.copyWith(status: newStatus);
}
void onNativePlaybackEnded() {
@@ -186,7 +227,7 @@ class VideoPlayerNotifier extends StateNotifier<VideoPlayerState> {
void _startBufferingTimer() {
_bufferingTimer?.cancel();
_bufferingTimer = Timer(const Duration(seconds: 3), () {
if (mounted && state.status == VideoPlaybackStatus.playing) {
if (mounted && state.status != VideoPlaybackStatus.completed) {
state = state.copyWith(status: VideoPlaybackStatus.buffering);
}
});

View File

@@ -91,6 +91,16 @@ class CastNotifier extends StateNotifier<CastManagerState> {
return discovered;
}
void toggle() {
switch (state.castState) {
case CastState.playing:
pause();
case CastState.paused:
play();
default:
}
}
void play() {
_gCastService.play();
}

View File

@@ -1,19 +0,0 @@
import 'package:flutter/material.dart';
import 'package:immich_mobile/extensions/duration_extensions.dart';
class FormattedDuration extends StatelessWidget {
final Duration data;
const FormattedDuration(this.data, {super.key});
@override
Widget build(BuildContext context) {
return SizedBox(
width: data.inHours > 0 ? 70 : 60, // use a fixed width to prevent jitter
child: Text(
data.format(),
style: const TextStyle(fontSize: 14.0, color: Colors.white, fontWeight: FontWeight.w500),
textAlign: TextAlign.center,
),
);
}
}

View File

@@ -1,22 +1,110 @@
import 'dart:math';
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/widgets/asset_viewer/video_position.dart';
import 'package:immich_mobile/constants/colors.dart';
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';
/// The video controls for the [videoPlayerProvider]
class VideoControls extends ConsumerWidget {
class VideoControls extends HookConsumerWidget {
final String videoPlayerName;
const VideoControls({super.key, required this.videoPlayerName});
void _toggle(WidgetRef ref, bool isCasting) {
if (isCasting) {
ref.read(castProvider.notifier).toggle();
} else {
ref.read(videoPlayerProvider(videoPlayerName).notifier).toggle();
}
}
void _onSeek(WidgetRef ref, bool isCasting, double value) {
final seekTo = Duration(microseconds: value.toInt());
if (isCasting) {
ref.read(castProvider.notifier).seekTo(seekTo);
return;
}
ref.read(videoPlayerProvider(videoPlayerName).notifier).seekTo(seekTo);
}
@override
Widget build(BuildContext context, WidgetRef ref) {
final isPortrait = context.orientation == Orientation.portrait;
return isPortrait
? VideoPosition(videoPlayerName: videoPlayerName)
: Padding(
padding: const EdgeInsets.symmetric(horizontal: 60.0),
child: VideoPosition(videoPlayerName: videoPlayerName),
);
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)));
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;
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);
final isLoaded = duration != Duration.zero;
return Padding(
padding: const EdgeInsets.all(24),
child: Column(
spacing: 16,
children: [
Row(
children: [
IconButton(
iconSize: 32,
padding: const EdgeInsets.all(12),
constraints: const BoxConstraints(),
icon: isFinished
? const Icon(Icons.replay, color: Colors.white, size: 32)
: AnimatedPlayPause(color: Colors.white, size: 32, playing: isPlaying),
onPressed: () => _toggle(ref, isCasting),
),
const Spacer(),
Text(
"${position.format()} / ${duration.format()}",
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.w500,
fontFeatures: [FontFeature.tabularFigures()],
),
),
const SizedBox(width: 16),
],
),
Slider(
value: min(position.inMicroseconds.toDouble(), duration.inMicroseconds.toDouble()),
min: 0,
max: max(duration.inMicroseconds.toDouble(), 1),
thumbColor: Colors.white,
activeColor: Colors.white,
inactiveColor: whiteOpacity75,
padding: EdgeInsets.zero,
onChangeStart: (_) => notifier.hold(),
onChangeEnd: (_) => notifier.release(),
onChanged: isLoaded ? (value) => _onSeek(ref, isCasting, value) : null,
),
],
),
);
}
}

View File

@@ -1,110 +0,0 @@
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/colors.dart';
import 'package:immich_mobile/providers/asset_viewer/video_player_provider.dart';
import 'package:immich_mobile/providers/cast.provider.dart';
import 'package:immich_mobile/widgets/asset_viewer/formatted_duration.dart';
class VideoPosition extends HookConsumerWidget {
final String videoPlayerName;
const VideoPosition({super.key, required this.videoPlayerName});
@override
Widget build(BuildContext context, WidgetRef ref) {
final isCasting = ref.watch(castProvider).isCasting;
final (position, duration) = isCasting
? ref.watch(castProvider.select((c) => (c.currentTime, c.duration)))
: ref.watch(videoPlayerProvider(videoPlayerName).select((v) => (v.position, v.duration)));
final wasPlaying = useRef<bool>(true);
return duration == Duration.zero
? const _VideoPositionPlaceholder()
: Column(
children: [
Padding(
// align with slider's inherent padding
padding: const EdgeInsets.symmetric(horizontal: 12.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [FormattedDuration(position), FormattedDuration(duration)],
),
),
Row(
children: [
Expanded(
child: Slider(
value: min(position.inMicroseconds / duration.inMicroseconds * 100, 100),
min: 0,
max: 100,
thumbColor: Colors.white,
activeColor: Colors.white,
inactiveColor: whiteOpacity75,
onChangeStart: (value) {
final status = ref.read(videoPlayerProvider(videoPlayerName)).status;
wasPlaying.value = status != VideoPlaybackStatus.paused;
ref.read(videoPlayerProvider(videoPlayerName).notifier).pause();
},
onChangeEnd: (value) {
if (wasPlaying.value) {
ref.read(videoPlayerProvider(videoPlayerName).notifier).play();
}
},
onChanged: (value) {
final seekToDuration = (duration * (value / 100.0));
if (isCasting) {
ref.read(castProvider.notifier).seekTo(seekToDuration);
return;
}
ref.read(videoPlayerProvider(videoPlayerName).notifier).seekTo(seekToDuration);
},
),
),
],
),
],
);
}
}
class _VideoPositionPlaceholder extends StatelessWidget {
const _VideoPositionPlaceholder();
static void _onChangedDummy(_) {}
@override
Widget build(BuildContext context) {
return const Column(
children: [
Padding(
padding: EdgeInsets.symmetric(horizontal: 12.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [FormattedDuration(Duration.zero), FormattedDuration(Duration.zero)],
),
),
Row(
children: [
Expanded(
child: Slider(
value: 0.0,
min: 0,
max: 100,
thumbColor: Colors.white,
activeColor: Colors.white,
inactiveColor: whiteOpacity75,
onChanged: _onChangedDummy,
),
),
],
),
],
);
}
}

View File

@@ -19,7 +19,7 @@
"@oazapfts/runtime": "^1.0.2"
},
"devDependencies": {
"@types/node": "^24.10.14",
"@types/node": "^24.11.0",
"typescript": "^5.3.3"
},
"repository": {

8
pnpm-lock.yaml generated
View File

@@ -63,7 +63,7 @@ importers:
specifier: ^4.13.1
version: 4.13.4
'@types/node':
specifier: ^24.10.14
specifier: ^24.11.0
version: 24.11.0
'@vitest/coverage-v8':
specifier: ^4.0.0
@@ -220,7 +220,7 @@ importers:
specifier: ^3.4.2
version: 3.7.1
'@types/node':
specifier: ^24.10.14
specifier: ^24.11.0
version: 24.11.0
'@types/pg':
specifier: ^8.15.1
@@ -323,7 +323,7 @@ importers:
version: 1.2.0
devDependencies:
'@types/node':
specifier: ^24.10.14
specifier: ^24.11.0
version: 24.11.0
typescript:
specifier: ^5.3.3
@@ -645,7 +645,7 @@ importers:
specifier: ^2.0.0
version: 2.0.0
'@types/node':
specifier: ^24.10.14
specifier: ^24.11.0
version: 24.11.0
'@types/nodemailer':
specifier: ^7.0.0

View File

@@ -136,7 +136,7 @@
"@types/luxon": "^3.6.2",
"@types/mock-fs": "^4.13.1",
"@types/multer": "^2.0.0",
"@types/node": "^24.10.14",
"@types/node": "^24.11.0",
"@types/nodemailer": "^7.0.0",
"@types/picomatch": "^4.0.0",
"@types/pngjs": "^6.0.5",

View File

@@ -532,7 +532,7 @@ describe(AssetService.name, () => {
});
it('should immediately queue assets for deletion if trash is disabled', async () => {
const asset = factory.asset({ isOffline: false });
const asset = AssetFactory.create();
mocks.assetJob.streamForDeletedJob.mockReturnValue(makeStream([asset]));
mocks.systemMetadata.get.mockResolvedValue({ trash: { enabled: false } });
@@ -546,7 +546,7 @@ describe(AssetService.name, () => {
});
it('should queue assets for deletion after trash duration', async () => {
const asset = factory.asset({ isOffline: false });
const asset = AssetFactory.create();
mocks.assetJob.streamForDeletedJob.mockReturnValue(makeStream([asset]));
mocks.systemMetadata.get.mockResolvedValue({ trash: { enabled: true, days: 7 } });
@@ -739,7 +739,7 @@ describe(AssetService.name, () => {
describe('upsertMetadata', () => {
it('should throw a bad request exception if duplicate keys are sent', async () => {
const asset = factory.asset();
const asset = AssetFactory.create();
const items = [
{ key: AssetMetadataKey.MobileApp, value: { iCloudId: 'id1' } },
{ key: AssetMetadataKey.MobileApp, value: { iCloudId: 'id1' } },
@@ -757,7 +757,7 @@ describe(AssetService.name, () => {
describe('upsertBulkMetadata', () => {
it('should throw a bad request exception if duplicate keys are sent', async () => {
const asset = factory.asset();
const asset = AssetFactory.create();
const items = [
{ assetId: asset.id, key: AssetMetadataKey.MobileApp, value: { iCloudId: 'id1' } },
{ assetId: asset.id, key: AssetMetadataKey.MobileApp, value: { iCloudId: 'id1' } },

View File

@@ -1,6 +1,8 @@
import { BadRequestException } from '@nestjs/common';
import { MemoryService } from 'src/services/memory.service';
import { OnThisDayData } from 'src/types';
import { AssetFactory } from 'test/factories/asset.factory';
import { MemoryFactory } from 'test/factories/memory.factory';
import { factory, newUuid, newUuids } from 'test/small.factory';
import { newTestService, ServiceMocks } from 'test/utils';
@@ -27,9 +29,9 @@ describe(MemoryService.name, () => {
describe('search', () => {
it('should search memories', async () => {
const [userId] = newUuids();
const asset = factory.asset();
const memory1 = factory.memory({ ownerId: userId, assets: [asset] });
const memory2 = factory.memory({ ownerId: userId });
const asset = AssetFactory.create();
const memory1 = MemoryFactory.from({ ownerId: userId }).asset(asset).build();
const memory2 = MemoryFactory.create({ ownerId: userId });
mocks.memory.search.mockResolvedValue([memory1, memory2]);
@@ -64,7 +66,7 @@ describe(MemoryService.name, () => {
it('should get a memory by id', async () => {
const userId = newUuid();
const memory = factory.memory({ ownerId: userId });
const memory = MemoryFactory.create({ ownerId: userId });
mocks.memory.get.mockResolvedValue(memory);
mocks.access.memory.checkOwnerAccess.mockResolvedValue(new Set([memory.id]));
@@ -81,7 +83,7 @@ describe(MemoryService.name, () => {
describe('create', () => {
it('should skip assets the user does not have access to', async () => {
const [assetId, userId] = newUuids();
const memory = factory.memory({ ownerId: userId });
const memory = MemoryFactory.create({ ownerId: userId });
mocks.memory.create.mockResolvedValue(memory);
@@ -109,8 +111,8 @@ describe(MemoryService.name, () => {
it('should create a memory', async () => {
const [assetId, userId] = newUuids();
const asset = factory.asset({ id: assetId, ownerId: userId });
const memory = factory.memory({ assets: [asset] });
const asset = AssetFactory.create({ id: assetId, ownerId: userId });
const memory = MemoryFactory.from().asset(asset).build();
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset.id]));
mocks.memory.create.mockResolvedValue(memory);
@@ -131,7 +133,7 @@ describe(MemoryService.name, () => {
});
it('should create a memory without assets', async () => {
const memory = factory.memory();
const memory = MemoryFactory.create();
mocks.memory.create.mockResolvedValue(memory);
@@ -155,7 +157,7 @@ describe(MemoryService.name, () => {
});
it('should update a memory', async () => {
const memory = factory.memory();
const memory = MemoryFactory.create();
mocks.access.memory.checkOwnerAccess.mockResolvedValue(new Set([memory.id]));
mocks.memory.update.mockResolvedValue(memory);
@@ -198,7 +200,7 @@ describe(MemoryService.name, () => {
it('should require asset access', async () => {
const assetId = newUuid();
const memory = factory.memory();
const memory = MemoryFactory.create();
mocks.access.memory.checkOwnerAccess.mockResolvedValue(new Set([memory.id]));
mocks.memory.get.mockResolvedValue(memory);
@@ -212,8 +214,8 @@ describe(MemoryService.name, () => {
});
it('should skip assets already in the memory', async () => {
const asset = factory.asset();
const memory = factory.memory({ assets: [asset] });
const asset = AssetFactory.create();
const memory = MemoryFactory.from().asset(asset).build();
mocks.access.memory.checkOwnerAccess.mockResolvedValue(new Set([memory.id]));
mocks.memory.get.mockResolvedValue(memory);
@@ -228,7 +230,7 @@ describe(MemoryService.name, () => {
it('should add assets', async () => {
const assetId = newUuid();
const memory = factory.memory();
const memory = MemoryFactory.create();
mocks.access.memory.checkOwnerAccess.mockResolvedValue(new Set([memory.id]));
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetId]));
@@ -266,8 +268,8 @@ describe(MemoryService.name, () => {
});
it('should remove assets', async () => {
const memory = factory.memory();
const asset = factory.asset();
const memory = MemoryFactory.create();
const asset = AssetFactory.create();
mocks.access.memory.checkOwnerAccess.mockResolvedValue(new Set([memory.id]));
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset.id]));

View File

@@ -0,0 +1,45 @@
import { Selectable } from 'kysely';
import { MemoryType } from 'src/enum';
import { MemoryTable } from 'src/schema/tables/memory.table';
import { AssetFactory } from 'test/factories/asset.factory';
import { build } from 'test/factories/builder.factory';
import { AssetLike, FactoryBuilder, MemoryLike } from 'test/factories/types';
import { newDate, newUuid, newUuidV7 } from 'test/small.factory';
export class MemoryFactory {
#assets: AssetFactory[] = [];
private constructor(private readonly value: Selectable<MemoryTable>) {}
static create(dto: MemoryLike = {}) {
return MemoryFactory.from(dto).build();
}
static from(dto: MemoryLike = {}) {
return new MemoryFactory({
id: newUuid(),
createdAt: newDate(),
updatedAt: newDate(),
updateId: newUuidV7(),
deletedAt: null,
ownerId: newUuid(),
type: MemoryType.OnThisDay,
data: { year: 2024 },
isSaved: false,
memoryAt: newDate(),
seenAt: null,
showAt: newDate(),
hideAt: newDate(),
...dto,
});
}
asset(asset: AssetLike, builder?: FactoryBuilder<AssetFactory>) {
this.#assets.push(build(AssetFactory.from(asset), builder));
return this;
}
build() {
return { ...this.value, assets: this.#assets.map((asset) => asset.build()) };
}
}

View File

@@ -6,6 +6,7 @@ import { AssetExifTable } from 'src/schema/tables/asset-exif.table';
import { AssetFaceTable } from 'src/schema/tables/asset-face.table';
import { AssetFileTable } from 'src/schema/tables/asset-file.table';
import { AssetTable } from 'src/schema/tables/asset.table';
import { MemoryTable } from 'src/schema/tables/memory.table';
import { PersonTable } from 'src/schema/tables/person.table';
import { SharedLinkTable } from 'src/schema/tables/shared-link.table';
import { StackTable } from 'src/schema/tables/stack.table';
@@ -24,3 +25,4 @@ export type UserLike = Partial<Selectable<UserTable>>;
export type AssetFaceLike = Partial<Selectable<AssetFaceTable>>;
export type PersonLike = Partial<Selectable<PersonTable>>;
export type StackLike = Partial<Selectable<StackTable>>;
export type MemoryLike = Partial<Selectable<MemoryTable>>;

View File

@@ -2,39 +2,24 @@ import {
Activity,
Album,
ApiKey,
AssetFace,
AssetFile,
AuthApiKey,
AuthSharedLink,
AuthUser,
Exif,
Library,
Memory,
Partner,
Person,
Session,
Stack,
Tag,
User,
UserAdmin,
} from 'src/database';
import { MapAsset } from 'src/dtos/asset-response.dto';
import { AuthDto } from 'src/dtos/auth.dto';
import { AssetEditAction, AssetEditActionItem, MirrorAxis } from 'src/dtos/editing.dto';
import { QueueStatisticsDto } from 'src/dtos/queue.dto';
import {
AssetFileType,
AssetOrder,
AssetStatus,
AssetType,
AssetVisibility,
MemoryType,
Permission,
SourceType,
UserMetadataKey,
UserStatus,
} from 'src/enum';
import { DeepPartial, OnThisDayData, UserMetadataItem } from 'src/types';
import { AssetFileType, AssetOrder, Permission, UserMetadataKey, UserStatus } from 'src/enum';
import { UserMetadataItem } from 'src/types';
import { UserFactory } from 'test/factories/user.factory';
import { v4, v7 } from 'uuid';
export const newUuid = () => v4();
@@ -123,9 +108,13 @@ const authUserFactory = (authUser: Partial<AuthUser> = {}) => {
return { id, isAdmin, name, email, quotaUsageInBytes, quotaSizeInBytes };
};
const partnerFactory = (partner: Partial<Partner> = {}) => {
const sharedBy = userFactory(partner.sharedBy || {});
const sharedWith = userFactory(partner.sharedWith || {});
const partnerFactory = ({
sharedBy: sharedByProvided,
sharedWith: sharedWithProvided,
...partner
}: Partial<Partner> = {}) => {
const sharedBy = UserFactory.create(sharedByProvided ?? {});
const sharedWith = UserFactory.create(sharedWithProvided ?? {});
return {
sharedById: sharedBy.id,
@@ -168,19 +157,6 @@ const queueStatisticsFactory = (dto?: Partial<QueueStatisticsDto>) => ({
...dto,
});
const stackFactory = ({ owner, assets, ...stack }: DeepPartial<Stack> = {}): Stack => {
const ownerId = newUuid();
return {
id: newUuid(),
primaryAssetId: assets?.[0].id ?? newUuid(),
ownerId,
owner: userFactory(owner ?? { id: ownerId }),
assets: assets?.map((asset) => assetFactory(asset)) ?? [],
...stack,
};
};
const userFactory = (user: Partial<User> = {}) => ({
id: newUuid(),
name: 'Test User',
@@ -238,44 +214,6 @@ const userAdminFactory = (user: Partial<UserAdmin> = {}) => {
};
};
const assetFactory = (
asset: Omit<DeepPartial<MapAsset>, 'exifInfo' | 'owner' | 'stack' | 'tags' | 'faces' | 'files' | 'edits'> = {},
) => {
return {
id: newUuid(),
createdAt: newDate(),
updatedAt: newDate(),
deletedAt: null,
updateId: newUuidV7(),
status: AssetStatus.Active,
checksum: newSha1(),
deviceAssetId: '',
deviceId: '',
duplicateId: null,
duration: null,
encodedVideoPath: null,
fileCreatedAt: newDate(),
fileModifiedAt: newDate(),
isExternal: false,
isFavorite: false,
isOffline: false,
libraryId: null,
livePhotoVideoId: null,
localDateTime: newDate(),
originalFileName: 'IMG_123.jpg',
originalPath: `/data/12/34/IMG_123.jpg`,
ownerId: newUuid(),
stackId: null,
thumbhash: null,
type: AssetType.Image,
visibility: AssetVisibility.Timeline,
width: null,
height: null,
isEdited: false,
...asset,
};
};
const activityFactory = (activity: Partial<Activity> = {}) => {
const userId = activity.userId || newUuid();
return {
@@ -283,7 +221,7 @@ const activityFactory = (activity: Partial<Activity> = {}) => {
comment: null,
isLiked: false,
userId,
user: userFactory({ id: userId }),
user: UserFactory.create({ id: userId }),
assetId: newUuid(),
albumId: newUuid(),
createdAt: newDate(),
@@ -319,24 +257,6 @@ const libraryFactory = (library: Partial<Library> = {}) => ({
...library,
});
const memoryFactory = (memory: Partial<Memory> = {}) => ({
id: newUuid(),
createdAt: newDate(),
updatedAt: newDate(),
updateId: newUuidV7(),
deletedAt: null,
ownerId: newUuid(),
type: MemoryType.OnThisDay,
data: { year: 2024 } as OnThisDayData,
isSaved: false,
memoryAt: newDate(),
seenAt: null,
showAt: newDate(),
hideAt: newDate(),
assets: [],
...memory,
});
const versionHistoryFactory = () => ({
id: newUuid(),
createdAt: newDate(),
@@ -403,49 +323,6 @@ const assetOcrFactory = (
...ocr,
});
const assetFileFactory = (file: Partial<AssetFile> = {}) => ({
id: newUuid(),
type: AssetFileType.Preview,
path: '/uploads/user-id/thumbs/path.jpg',
isEdited: false,
isProgressive: false,
...file,
});
const exifFactory = (exif: Partial<Exif> = {}) => ({
assetId: newUuid(),
autoStackId: null,
bitsPerSample: null,
city: 'Austin',
colorspace: null,
country: 'United States of America',
dateTimeOriginal: newDate(),
description: '',
exifImageHeight: 420,
exifImageWidth: 42,
exposureTime: null,
fileSizeInByte: 69,
fNumber: 1.7,
focalLength: 4.38,
fps: null,
iso: 947,
latitude: 30.267_334_570_570_195,
longitude: -97.789_833_534_282_07,
lensModel: null,
livePhotoCID: null,
make: 'Google',
model: 'Pixel 7',
modifyDate: newDate(),
orientation: '1',
profileDescription: null,
projectionType: null,
rating: 4,
state: 'Texas',
tags: ['parent/child'],
timeZone: 'UTC-6',
...exif,
});
const tagFactory = (tag: Partial<Tag>): Tag => ({
id: newUuid(),
color: null,
@@ -456,25 +333,6 @@ const tagFactory = (tag: Partial<Tag>): Tag => ({
...tag,
});
const faceFactory = ({ person, ...face }: DeepPartial<AssetFace> = {}): AssetFace => ({
assetId: newUuid(),
boundingBoxX1: 1,
boundingBoxX2: 2,
boundingBoxY1: 1,
boundingBoxY2: 2,
deletedAt: null,
id: newUuid(),
imageHeight: 420,
imageWidth: 42,
isVisible: true,
personId: null,
sourceType: SourceType.MachineLearning,
updatedAt: newDate(),
updateId: newUuidV7(),
person: person === null ? null : personFactory(person),
...face,
});
const assetEditFactory = (edit?: Partial<AssetEditActionItem>): AssetEditActionItem => {
switch (edit?.action) {
case AssetEditAction.Crop: {
@@ -529,26 +387,20 @@ const albumFactory = (album?: Partial<Omit<Album, 'assets'>>) => ({
export const factory = {
activity: activityFactory,
apiKey: apiKeyFactory,
asset: assetFactory,
assetFile: assetFileFactory,
assetOcr: assetOcrFactory,
auth: authFactory,
authApiKey: authApiKeyFactory,
authUser: authUserFactory,
library: libraryFactory,
memory: memoryFactory,
partner: partnerFactory,
queueStatistics: queueStatisticsFactory,
session: sessionFactory,
stack: stackFactory,
user: userFactory,
userAdmin: userAdminFactory,
versionHistory: versionHistoryFactory,
jobAssets: {
sidecarWrite: assetSidecarWriteFactory,
},
exif: exifFactory,
face: faceFactory,
person: personFactory,
assetEdit: assetEditFactory,
tag: tagFactory,

View File

@@ -13,14 +13,15 @@ export const zoomImageAction = (node: HTMLElement, options?: { disabled?: boolea
zoomInstance.subscribe(({ state }) => assetViewerManager.onZoomChange(state)),
];
const stopIfDisabled = (event: Event) => {
const onInteractionStart = (event: Event) => {
if (options?.disabled) {
event.stopImmediatePropagation();
}
assetViewerManager.cancelZoomAnimation();
};
node.addEventListener('wheel', stopIfDisabled, { capture: true });
node.addEventListener('pointerdown', stopIfDisabled, { capture: true });
node.addEventListener('wheel', onInteractionStart, { capture: true });
node.addEventListener('pointerdown', onInteractionStart, { capture: true });
node.style.overflow = 'visible';
return {
@@ -31,8 +32,8 @@ export const zoomImageAction = (node: HTMLElement, options?: { disabled?: boolea
for (const unsubscribe of unsubscribes) {
unsubscribe();
}
node.removeEventListener('wheel', stopIfDisabled, { capture: true });
node.removeEventListener('pointerdown', stopIfDisabled, { capture: true });
node.removeEventListener('wheel', onInteractionStart, { capture: true });
node.removeEventListener('pointerdown', onInteractionStart, { capture: true });
zoomInstance.cleanup();
},
};

View File

@@ -419,7 +419,7 @@
ocrManager.hasOcrData,
);
const { Tag } = $derived(getAssetActions($t, asset));
const { Tag, TagPeople } = $derived(getAssetActions($t, asset));
const showDetailPanel = $derived(
asset.hasMetadata &&
$slideshowState === SlideshowState.None &&
@@ -446,7 +446,7 @@
};
</script>
<CommandPaletteDefaultProvider name={$t('assets')} actions={[Tag]} />
<CommandPaletteDefaultProvider name={$t('assets')} actions={[Tag, TagPeople]} />
<svelte:document bind:fullscreenElement />

View File

@@ -98,7 +98,8 @@
};
const onZoom = () => {
assetViewerManager.zoom = assetViewerManager.zoom > 1 ? 1 : 2;
const targetZoom = assetViewerManager.zoom > 1 ? 1 : 2;
assetViewerManager.animatedZoom(targetZoom);
};
const onPlaySlideshow = () => ($slideshowState = SlideshowState.PlaySlideshow);

View File

@@ -2,11 +2,11 @@
import Combobox, { type ComboBoxOption } from '$lib/components/shared-components/combobox.svelte';
import { preferences } from '$lib/stores/user.store';
import { getAllTags, type TagResponseDto } from '@immich/sdk';
import { Checkbox, Icon, Label, Text } from '@immich/ui';
import { mdiClose } from '@mdi/js';
import { Checkbox, Label, Text } from '@immich/ui';
import { onMount } from 'svelte';
import { t } from 'svelte-i18n';
import { SvelteSet } from 'svelte/reactivity';
import TagPill from '../tag-pill.svelte';
interface Props {
selectedTags: SvelteSet<string> | null;
@@ -73,24 +73,7 @@
{#each selectedTags ?? [] as tagId (tagId)}
{@const tag = tagMap[tagId]}
{#if tag}
<div class="flex group transition-all">
<span
class="inline-block h-min whitespace-nowrap ps-3 pe-1 group-hover:ps-3 py-1 text-center align-baseline leading-none text-gray-100 dark:text-immich-dark-gray bg-primary roudned-s-full hover:bg-immich-primary/80 dark:hover:bg-immich-dark-primary/80 transition-all"
>
<p class="text-sm">
{tag.value}
</p>
</span>
<button
type="button"
class="text-gray-100 dark:text-immich-dark-gray bg-immich-primary/95 dark:bg-immich-dark-primary/95 rounded-e-full place-items-center place-content-center pe-2 ps-1 py-1 hover:bg-immich-primary/80 dark:hover:bg-immich-dark-primary/80 transition-all"
title={$t('remove_tag')}
onclick={() => handleRemove(tagId)}
>
<Icon icon={mdiClose} />
</button>
</div>
<TagPill label={tag.value} onRemove={() => handleRemove(tagId)} />
{/if}
{/each}
</section>

View File

@@ -0,0 +1,31 @@
<script lang="ts">
import { Icon } from '@immich/ui';
import { mdiClose } from '@mdi/js';
import { t } from 'svelte-i18n';
type Props = {
label: string;
onRemove: () => void;
};
let { label, onRemove }: Props = $props();
</script>
<div class="flex group transition-all">
<span
class="inline-block h-min whitespace-nowrap ps-3 pe-1 group-hover:ps-3 py-1 text-center align-baseline leading-none text-gray-100 dark:text-immich-dark-gray bg-primary rounded-s-full hover:bg-immich-primary/80 dark:hover:bg-immich-dark-primary/80 transition-all"
>
<p class="text-sm">
{label}
</p>
</span>
<button
type="button"
class="text-gray-100 dark:text-immich-dark-gray bg-immich-primary/95 dark:bg-immich-dark-primary/95 rounded-e-full place-items-center place-content-center pe-2 ps-1 py-1 hover:bg-immich-primary/80 dark:hover:bg-immich-dark-primary/80 transition-all"
title={$t('remove_tag')}
onclick={onRemove}
>
<Icon icon={mdiClose} />
</button>
</div>

View File

@@ -3,6 +3,7 @@ import { canCopyImageToClipboard } from '$lib/utils/asset-utils';
import { BaseEventManager } from '$lib/utils/base-event-manager.svelte';
import { PersistedLocalStorage } from '$lib/utils/persisted';
import type { ZoomImageWheelState } from '@zoom-image/core';
import { cubicOut } from 'svelte/easing';
const isShowDetailPanel = new PersistedLocalStorage<boolean>('asset-viewer-state', false);
@@ -22,6 +23,7 @@ export type Events = {
export class AssetViewerManager extends BaseEventManager<Events> {
#zoomState = $state(createDefaultZoomState());
#animationFrameId: number | null = null;
imgRef = $state<HTMLImageElement | undefined>();
imageLoaderStatus = $state<ImageLoaderStatus | undefined>();
@@ -60,6 +62,7 @@ export class AssetViewerManager extends BaseEventManager<Events> {
}
set zoom(zoom: number) {
this.cancelZoomAnimation();
this.zoomState = { ...this.zoomState, currentZoom: zoom };
}
@@ -84,7 +87,35 @@ export class AssetViewerManager extends BaseEventManager<Events> {
this.#zoomState = state;
}
cancelZoomAnimation() {
if (this.#animationFrameId !== null) {
cancelAnimationFrame(this.#animationFrameId);
this.#animationFrameId = null;
}
}
animatedZoom(targetZoom: number, duration = 300) {
this.cancelZoomAnimation();
const startZoom = this.#zoomState.currentZoom;
const startTime = performance.now();
const frame = (currentTime: number) => {
const elapsed = currentTime - startTime;
const linearProgress = Math.min(elapsed / duration, 1);
const easedProgress = cubicOut(linearProgress);
const interpolatedZoom = startZoom + (targetZoom - startZoom) * easedProgress;
this.zoomState = { ...this.#zoomState, currentZoom: interpolatedZoom };
this.#animationFrameId = linearProgress < 1 ? requestAnimationFrame(frame) : null;
};
this.#animationFrameId = requestAnimationFrame(frame);
}
resetZoomState() {
this.cancelZoomAnimation();
this.zoomState = createDefaultZoomState();
}

View File

@@ -286,6 +286,17 @@ describe('TimelineManager', () => {
expect(timelineManager.assetCount).toEqual(1);
});
it('ignores new assets that do not match the tag filter', async () => {
await timelineManager.updateOptions({ tagId: 'tag-1' });
const matching = deriveLocalDateTimeFromFileCreatedAt(timelineAssetFactory.build({ tags: ['tag-1'] }));
const unrelated = deriveLocalDateTimeFromFileCreatedAt(timelineAssetFactory.build({ tags: ['tag-2'] }));
timelineManager.upsertAssets([matching, unrelated]);
expect(await getAssets(timelineManager)).toEqual([matching]);
});
// disabled due to the wasm Justified Layout import
it('ignores trashed assets when isTrashed is true', async () => {
const asset = deriveLocalDateTimeFromFileCreatedAt(timelineAssetFactory.build({ isTrashed: false }));

View File

@@ -596,6 +596,7 @@ export class TimelineManager extends VirtualScrollManager {
isMismatched(this.#options.visibility, asset.visibility) ||
isMismatched(this.#options.isFavorite, asset.isFavorite) ||
isMismatched(this.#options.isTrashed, asset.isTrashed) ||
(this.#options.tagId && asset.tags && !asset.tags.includes(this.#options.tagId)) ||
(this.#options.assetFilter !== undefined && !this.#options.assetFilter.has(asset.id))
);
}

View File

@@ -18,6 +18,7 @@ export type Direction = 'earlier' | 'later';
export type TimelineAsset = {
id: string;
ownerId: string;
tags?: string[];
ratio: number;
thumbhash: string | null;
localDateTime: TimelineDateTime;

View File

@@ -2,12 +2,13 @@
import { eventManager } from '$lib/managers/event-manager.svelte';
import { tagAssets } from '$lib/utils/asset-utils';
import { getAllTags, upsertTags, type TagResponseDto } from '@immich/sdk';
import { FormModal, Icon } from '@immich/ui';
import { mdiClose, mdiTag } from '@mdi/js';
import { FormModal } from '@immich/ui';
import { mdiTag } from '@mdi/js';
import { onMount } from 'svelte';
import { t } from 'svelte-i18n';
import { SvelteSet } from 'svelte/reactivity';
import Combobox, { type ComboBoxOption } from '../components/shared-components/combobox.svelte';
import TagPill from '../components/shared-components/tag-pill.svelte';
interface Props {
onClose: (updated?: boolean) => void;
@@ -81,24 +82,7 @@
{#each selectedIds as tagId (tagId)}
{@const tag = tagMap[tagId]}
{#if tag}
<div class="flex group transition-all">
<span
class="inline-block h-min whitespace-nowrap ps-3 pe-1 group-hover:ps-3 py-1 text-center align-baseline leading-none text-gray-100 dark:text-immich-dark-gray bg-primary rounded-s-full hover:bg-immich-primary/80 dark:hover:bg-immich-dark-primary/80 transition-all"
>
<p class="text-sm">
{tag.value}
</p>
</span>
<button
type="button"
class="text-gray-100 dark:text-immich-dark-gray bg-immich-primary/95 dark:bg-immich-dark-primary/95 rounded-e-full place-items-center place-content-center pe-2 ps-1 py-1 hover:bg-immich-primary/80 dark:hover:bg-immich-dark-primary/80 transition-all"
title={$t('remove_tag')}
onclick={() => handleRemove(tagId)}
>
<Icon icon={mdiClose} />
</button>
</div>
<TagPill label={tag.value} onRemove={() => handleRemove(tagId)} />
{/if}
{/each}
</section>

View File

@@ -40,6 +40,7 @@
{ key: ['s'], action: $t('stack_selected_photos') },
{ key: ['l'], action: $t('add_to_album') },
{ key: ['t'], action: $t('tag_assets') },
{ key: ['p'], action: $t('tag_people') },
{ key: ['⇧', 'a'], action: $t('archive_or_unarchive_photo') },
{ key: ['⇧', 'd'], action: $t('download') },
{ key: ['Space'], action: $t('play_or_pause_video') },

View File

@@ -5,6 +5,7 @@ import { eventManager } from '$lib/managers/event-manager.svelte';
import AssetAddToAlbumModal from '$lib/modals/AssetAddToAlbumModal.svelte';
import AssetTagModal from '$lib/modals/AssetTagModal.svelte';
import SharedLinkCreateModal from '$lib/modals/SharedLinkCreateModal.svelte';
import { isFaceEditMode } from '$lib/stores/face-edit.svelte';
import { user as authUser, preferences } from '$lib/stores/user.store';
import type { AssetControlContext } from '$lib/types';
import { getSharedLink, sleep } from '$lib/utils';
@@ -31,6 +32,7 @@ import {
mdiDatabaseRefreshOutline,
mdiDownload,
mdiDownloadBox,
mdiFaceRecognition,
mdiHeadSyncOutline,
mdiHeart,
mdiHeartOutline,
@@ -223,6 +225,17 @@ export const getAssetActions = ($t: MessageFormatter, asset: AssetResponseDto) =
shortcuts: { key: 't' },
};
const TagPeople: ActionItem = {
title: $t('tag_people'),
icon: mdiFaceRecognition,
type: $t('assets'),
$if: () => isOwner && asset.type === AssetTypeEnum.Image && !asset.isTrashed,
onAction: () => {
isFaceEditMode.value = !isFaceEditMode.value;
},
shortcuts: { key: 'p' },
};
const Edit: ActionItem = {
title: $t('editor'),
icon: mdiTune,
@@ -279,6 +292,7 @@ export const getAssetActions = ($t: MessageFormatter, asset: AssetResponseDto) =
ZoomOut,
Copy,
Tag,
TagPeople,
Edit,
RefreshFacesJob,
RefreshMetadataJob,

View File

@@ -170,6 +170,7 @@ export const toTimelineAsset = (unknownAsset: AssetResponseDto | TimelineAsset):
return {
id: assetResponse.id,
ownerId: assetResponse.ownerId,
tags: assetResponse.tags?.map((tag) => tag.id),
ratio,
thumbhash: assetResponse.thumbhash,
localDateTime,

View File

@@ -37,6 +37,7 @@ export const timelineAssetFactory = Sync.makeFactory<TimelineAsset>({
id: Sync.each(() => faker.string.uuid()),
ratio: Sync.each((i) => 0.2 + ((i * 0.618_034) % 3.8)), // deterministic random float between 0.2 and 4.0
ownerId: Sync.each(() => faker.string.uuid()),
tags: [],
thumbhash: Sync.each(() => faker.string.alphanumeric(28)),
localDateTime: Sync.each(() => fromISODateTimeUTCToObject(faker.date.past().toISOString())),
fileCreatedAt: Sync.each(() => fromISODateTimeUTCToObject(faker.date.past().toISOString())),