Compare commits

..

2 Commits

Author SHA1 Message Date
Alex
3efbda859b feat(mobile): more info for asset viewer 2025-12-16 15:43:34 -06:00
Alex
92f8ffe722 feat(mobile): more info for asset viewer 2025-12-16 14:28:13 -06:00
12 changed files with 120 additions and 71 deletions

View File

@@ -43,7 +43,6 @@ These environment variables are used by the `docker-compose.yml` file and do **N
| `IMMICH_PROCESS_INVALID_IMAGES` | When `true`, generate thumbnails for invalid images | | server | microservices | | `IMMICH_PROCESS_INVALID_IMAGES` | When `true`, generate thumbnails for invalid images | | server | microservices |
| `IMMICH_TRUSTED_PROXIES` | List of comma-separated IPs set as trusted proxies | | server | api | | `IMMICH_TRUSTED_PROXIES` | List of comma-separated IPs set as trusted proxies | | server | api |
| `IMMICH_IGNORE_MOUNT_CHECK_ERRORS` | See [System Integrity](/administration/system-integrity) | | server | api, microservices | | `IMMICH_IGNORE_MOUNT_CHECK_ERRORS` | See [System Integrity](/administration/system-integrity) | | server | api, microservices |
| `IMMICH_ALLOW_SETUP` | When `false` disables the `/auth/admin-sign-up` endpoint | `true` | server | api |
\*1: `TZ` should be set to a `TZ identifier` from [this list][tz-list]. For example, `TZ="Etc/UTC"`. \*1: `TZ` should be set to a `TZ identifier` from [this list][tz-list]. For example, `TZ="Etc/UTC"`.
`TZ` is used by `exiftool` as a fallback in case the timezone cannot be determined from the image metadata. It is also used for logfile timestamps and cron job execution. `TZ` is used by `exiftool` as a fallback in case the timezone cannot be determined from the image metadata. It is also used for logfile timestamps and cron job execution.

View File

@@ -1286,6 +1286,7 @@
"link_to_oauth": "Link to OAuth", "link_to_oauth": "Link to OAuth",
"linked_oauth_account": "Linked OAuth account", "linked_oauth_account": "Linked OAuth account",
"list": "List", "list": "List",
"live": "Live",
"loading": "Loading", "loading": "Loading",
"loading_search_results_failed": "Loading search results failed", "loading_search_results_failed": "Loading search results failed",
"local": "Local", "local": "Local",
@@ -1415,6 +1416,7 @@
"month": "Month", "month": "Month",
"monthly_title_text_date_format": "MMMM y", "monthly_title_text_date_format": "MMMM y",
"more": "More", "more": "More",
"motion": "Motion",
"move": "Move", "move": "Move",
"move_off_locked_folder": "Move out of locked folder", "move_off_locked_folder": "Move out of locked folder",
"move_to": "Move to", "move_to": "Move to",

View File

@@ -20,6 +20,7 @@ import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_stack.widg
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart'; import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/bottom_bar.widget.dart'; import 'package:immich_mobile/presentation/widgets/asset_viewer/bottom_bar.widget.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/bottom_sheet.widget.dart'; import 'package:immich_mobile/presentation/widgets/asset_viewer/bottom_sheet.widget.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/motion_photo_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/top_app_bar.widget.dart'; import 'package:immich_mobile/presentation/widgets/asset_viewer/top_app_bar.widget.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/video_viewer.widget.dart'; import 'package:immich_mobile/presentation/widgets/asset_viewer/video_viewer.widget.dart';
import 'package:immich_mobile/presentation/widgets/images/image_provider.dart'; import 'package:immich_mobile/presentation/widgets/images/image_provider.dart';
@@ -692,6 +693,7 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
backgroundDecoration: BoxDecoration(color: backgroundColor), backgroundDecoration: BoxDecoration(color: backgroundColor),
enablePanAlways: true, enablePanAlways: true,
), ),
const Positioned(top: -40, left: 0, right: 0, child: MotionPhotoPlayButton()),
if (!showingBottomSheet) if (!showingBottomSheet)
const Positioned( const Positioned(
bottom: 0, bottom: 0,

View File

@@ -0,0 +1,79 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/platform_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart';
import 'package:immich_mobile/providers/asset_viewer/is_motion_video_playing.provider.dart';
import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart';
class MotionPhotoPlayButton extends ConsumerWidget {
const MotionPhotoPlayButton({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final asset = ref.watch(currentAssetNotifier);
final isPlaying = ref.watch(isPlayingMotionVideoProvider);
final showControls = ref.watch(assetViewerProvider.select((state) => state.showingControls));
final isShowingSheet = ref.watch(assetViewerProvider.select((state) => state.showingBottomSheet));
if (asset == null || !asset.isMotionPhoto || isShowingSheet) {
return const SizedBox.shrink();
}
return IgnorePointer(
ignoring: !showControls,
child: AnimatedOpacity(
opacity: showControls ? 1.0 : 0.0,
duration: Durations.short2,
child: SafeArea(
child: Padding(
padding: const EdgeInsets.only(top: 60),
child: Center(
child: _MotionButton(
isPlaying: isPlaying,
onPressed: ref.read(isPlayingMotionVideoProvider.notifier).toggle,
),
),
),
),
),
);
}
}
class _MotionButton extends StatelessWidget {
final bool isPlaying;
final VoidCallback onPressed;
const _MotionButton({required this.isPlaying, required this.onPressed});
@override
Widget build(BuildContext context) {
return Material(
color: Colors.grey[800]!.withValues(alpha: 0.4),
borderRadius: const BorderRadius.all(Radius.circular(24)),
child: InkWell(
onTap: onPressed,
borderRadius: const BorderRadius.all(Radius.circular(24)),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
isPlaying ? Icons.motion_photos_pause_outlined : Icons.play_circle_outline_rounded,
color: Colors.white,
size: 16,
),
const SizedBox(width: 8),
Text(
CurrentPlatform.isAndroid ? 'motion'.t(context: context) : 'live'.t(context: context),
style: const TextStyle(color: Colors.white, fontSize: 14, fontWeight: FontWeight.w500),
),
],
),
),
),
);
}
}

View File

@@ -1,4 +1,5 @@
import 'package:auto_route/auto_route.dart'; import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.dart'; import 'package:immich_mobile/constants/enums.dart';
@@ -7,7 +8,6 @@ import 'package:immich_mobile/domain/models/events.model.dart';
import 'package:immich_mobile/domain/utils/event_stream.dart'; import 'package:immich_mobile/domain/utils/event_stream.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/favorite_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/favorite_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/motion_photo_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/unfavorite_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/unfavorite_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart'; import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/viewer_kebab_menu.widget.dart'; import 'package:immich_mobile/presentation/widgets/asset_viewer/viewer_kebab_menu.widget.dart';
@@ -50,7 +50,6 @@ class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget {
final originalTheme = context.themeData; final originalTheme = context.themeData;
final actions = <Widget>[ final actions = <Widget>[
if (asset.isMotionPhoto) const MotionPhotoActionButton(iconOnly: true),
if (album != null && album.isActivityEnabled && album.isShared) if (album != null && album.isActivityEnabled && album.isShared)
IconButton( IconButton(
icon: const Icon(Icons.chat_outlined), icon: const Icon(Icons.chat_outlined),
@@ -77,6 +76,8 @@ class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget {
child: AppBar( child: AppBar(
backgroundColor: isShowingSheet ? Colors.transparent : Colors.black.withAlpha(125), backgroundColor: isShowingSheet ? Colors.transparent : Colors.black.withAlpha(125),
leading: const _AppBarBackButton(), leading: const _AppBarBackButton(),
centerTitle: true,
title: isShowingSheet ? null : _AssetInfoTitle(asset: asset),
iconTheme: const IconThemeData(size: 22, color: Colors.white), iconTheme: const IconThemeData(size: 22, color: Colors.white),
actionsIconTheme: const IconThemeData(size: 22, color: Colors.white), actionsIconTheme: const IconThemeData(size: 22, color: Colors.white),
shape: const Border(), shape: const Border(),
@@ -120,3 +121,27 @@ class _AppBarBackButton extends ConsumerWidget {
); );
} }
} }
class _AssetInfoTitle extends StatelessWidget {
final BaseAsset asset;
const _AssetInfoTitle({required this.asset});
@override
Widget build(BuildContext context) {
final dateTime = asset.createdAt.toLocal();
final currentYear = DateTime.now().year;
final isCurrentYear = dateTime.year == currentYear;
final dateFormatted = isCurrentYear ? DateFormat.MMMd().format(dateTime) : DateFormat.yMMMd().format(dateTime);
final timeFormatted = DateFormat.jm().format(dateTime);
return Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(dateFormatted, style: context.textTheme.bodyMedium?.copyWith(color: Colors.white)),
Text(timeFormatted, style: context.textTheme.labelMedium?.copyWith(color: Colors.white70)),
],
);
}
}

View File

@@ -58,7 +58,7 @@ export class EnvDto {
IMMICH_MICROSERVICES_METRICS_PORT?: number; IMMICH_MICROSERVICES_METRICS_PORT?: number;
@ValidateBoolean({ optional: true }) @ValidateBoolean({ optional: true })
IMMICH_ALLOW_EXTERNAL_PLUGINS?: boolean; IMMICH_PLUGINS_ENABLED?: boolean;
@Optional() @Optional()
@Matches(/^\//, { message: 'IMMICH_PLUGINS_INSTALL_FOLDER must be an absolute path' }) @Matches(/^\//, { message: 'IMMICH_PLUGINS_INSTALL_FOLDER must be an absolute path' })
@@ -113,9 +113,6 @@ export class EnvDto {
@Optional() @Optional()
IMMICH_THIRD_PARTY_SUPPORT_URL?: string; IMMICH_THIRD_PARTY_SUPPORT_URL?: string;
@ValidateBoolean({ optional: true })
IMMICH_ALLOW_SETUP?: boolean;
@IsIPRange({ requireCIDR: false }, { each: true }) @IsIPRange({ requireCIDR: false }, { each: true })
@Transform(({ value }) => @Transform(({ value }) =>
value && typeof value === 'string' value && typeof value === 'string'

View File

@@ -8,8 +8,6 @@ const getEnv = () => {
const resetEnv = () => { const resetEnv = () => {
for (const env of [ for (const env of [
'IMMICH_ALLOW_EXTERNAL_PLUGINS',
'IMMICH_ALLOW_SETUP',
'IMMICH_ENV', 'IMMICH_ENV',
'IMMICH_WORKERS_INCLUDE', 'IMMICH_WORKERS_INCLUDE',
'IMMICH_WORKERS_EXCLUDE', 'IMMICH_WORKERS_EXCLUDE',
@@ -77,9 +75,6 @@ describe('getEnv', () => {
configFile: undefined, configFile: undefined,
logLevel: undefined, logLevel: undefined,
}); });
expect(config.plugins.external).toEqual({ allow: false });
expect(config.setup).toEqual({ allow: true });
}); });
describe('IMMICH_MEDIA_LOCATION', () => { describe('IMMICH_MEDIA_LOCATION', () => {
@@ -89,32 +84,6 @@ describe('getEnv', () => {
}); });
}); });
describe('IMMICH_ALLOW_EXTERNAL_PLUGINS', () => {
it('should disable plugins', () => {
process.env.IMMICH_ALLOW_EXTERNAL_PLUGINS = 'false';
const config = getEnv();
expect(config.plugins.external).toEqual({ allow: false });
});
it('should throw an error for invalid value', () => {
process.env.IMMICH_ALLOW_EXTERNAL_PLUGINS = 'invalid';
expect(() => getEnv()).toThrowError('IMMICH_ALLOW_EXTERNAL_PLUGINS must be a boolean value');
});
});
describe('IMMICH_ALLOW_SETUP', () => {
it('should disable setup', () => {
process.env.IMMICH_ALLOW_SETUP = 'false';
const { setup } = getEnv();
expect(setup).toEqual({ allow: false });
});
it('should throw an error for invalid value', () => {
process.env.IMMICH_ALLOW_SETUP = 'invalid';
expect(() => getEnv()).toThrowError('IMMICH_ALLOW_SETUP must be a boolean value');
});
});
describe('database', () => { describe('database', () => {
it('should use defaults', () => { it('should use defaults', () => {
const { database } = getEnv(); const { database } = getEnv();

View File

@@ -90,10 +90,6 @@ export interface EnvData {
redis: RedisOptions; redis: RedisOptions;
setup: {
allow: boolean;
};
telemetry: { telemetry: {
apiPort: number; apiPort: number;
microservicesPort: number; microservicesPort: number;
@@ -108,10 +104,8 @@ export interface EnvData {
workers: ImmichWorker[]; workers: ImmichWorker[];
plugins: { plugins: {
external: { enabled: boolean;
allow: boolean; installFolder?: string;
installFolder?: string;
};
}; };
noColor: boolean; noColor: boolean;
@@ -319,10 +313,6 @@ const getEnv = (): EnvData => {
corePlugin: join(buildFolder, 'corePlugin'), corePlugin: join(buildFolder, 'corePlugin'),
}, },
setup: {
allow: dto.IMMICH_ALLOW_SETUP ?? true,
},
storage: { storage: {
ignoreMountCheckErrors: !!dto.IMMICH_IGNORE_MOUNT_CHECK_ERRORS, ignoreMountCheckErrors: !!dto.IMMICH_IGNORE_MOUNT_CHECK_ERRORS,
mediaLocation: dto.IMMICH_MEDIA_LOCATION, mediaLocation: dto.IMMICH_MEDIA_LOCATION,
@@ -337,10 +327,8 @@ const getEnv = (): EnvData => {
workers, workers,
plugins: { plugins: {
external: { enabled: !!dto.IMMICH_PLUGINS_ENABLED,
allow: dto.IMMICH_ALLOW_EXTERNAL_PLUGINS ?? false, installFolder: dto.IMMICH_PLUGINS_INSTALL_FOLDER,
installFolder: dto.IMMICH_PLUGINS_INSTALL_FOLDER,
},
}, },
noColor: !!dto.NO_COLOR, noColor: !!dto.NO_COLOR,

View File

@@ -165,11 +165,6 @@ export class AuthService extends BaseService {
} }
async adminSignUp(dto: SignUpDto): Promise<UserAdminResponseDto> { async adminSignUp(dto: SignUpDto): Promise<UserAdminResponseDto> {
const { setup } = this.configRepository.getEnv();
if (!setup.allow) {
throw new BadRequestException('Admin setup is disabled');
}
const adminUser = await this.userRepository.getAdmin(); const adminUser = await this.userRepository.getAdmin();
if (adminUser) { if (adminUser) {
throw new BadRequestException('The server already has an admin'); throw new BadRequestException('The server already has an admin');

View File

@@ -80,8 +80,8 @@ export class PluginService extends BaseService {
this.logger.log(`Successfully processed core plugin: ${coreManifest.name} (version ${coreManifest.version})`); this.logger.log(`Successfully processed core plugin: ${coreManifest.name} (version ${coreManifest.version})`);
// Load external plugins // Load external plugins
if (plugins.external.allow && plugins.external.installFolder) { if (plugins.enabled && plugins.installFolder) {
await this.loadExternalPlugins(plugins.external.installFolder); await this.loadExternalPlugins(plugins.installFolder);
} }
} }

View File

@@ -115,9 +115,8 @@ export class ServerService extends BaseService {
} }
async getSystemConfig(): Promise<ServerConfigDto> { async getSystemConfig(): Promise<ServerConfigDto> {
const { setup } = this.configRepository.getEnv();
const config = await this.getConfig({ withCache: false }); const config = await this.getConfig({ withCache: false });
const isInitialized = !setup.allow || (await this.userRepository.hasAdmin()); const isInitialized = await this.userRepository.hasAdmin();
const onboarding = await this.systemMetadataRepository.get(SystemMetadataKey.AdminOnboarding); const onboarding = await this.systemMetadataRepository.get(SystemMetadataKey.AdminOnboarding);
return { return {

View File

@@ -75,10 +75,6 @@ const envData: EnvData = {
corePlugin: '/build/corePlugin', corePlugin: '/build/corePlugin',
}, },
setup: {
allow: true,
},
storage: { storage: {
ignoreMountCheckErrors: false, ignoreMountCheckErrors: false,
}, },
@@ -92,10 +88,8 @@ const envData: EnvData = {
workers: [ImmichWorker.Api, ImmichWorker.Microservices], workers: [ImmichWorker.Api, ImmichWorker.Microservices],
plugins: { plugins: {
external: { enabled: true,
allow: true, installFolder: '/app/data/plugins',
installFolder: '/app/data/plugins',
},
}, },
noColor: false, noColor: false,