Compare commits

..

1 Commits

Author SHA1 Message Date
mertalev
9e70f14a8a override reserved metadata 2025-09-23 20:07:51 -04:00
15 changed files with 121 additions and 91 deletions

View File

@@ -1,5 +1,5 @@
allprojects {
ext.kotlin_version = '2.2.20'
ext.kotlin_version = '2.0.20'
repositories {
google()
@@ -16,8 +16,8 @@ subprojects {
if (project.plugins.hasPlugin("com.android.application") ||
project.plugins.hasPlugin("com.android.library")) {
project.android {
compileSdkVersion 36
buildToolsVersion "36.0.0"
compileSdkVersion 35
buildToolsVersion "35.0.0"
}
}
}

View File

@@ -1,6 +1,6 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-all.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-all.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME

View File

@@ -18,10 +18,10 @@ pluginManagement {
plugins {
id "dev.flutter.flutter-plugin-loader" version "1.0.0"
id "com.android.application" version '8.11.2' apply false
id "org.jetbrains.kotlin.android" version "2.2.20" apply false
id "com.android.application" version '8.7.2' apply false
id "org.jetbrains.kotlin.android" version "2.0.20" apply false
id 'org.jetbrains.kotlin.plugin.serialization' version '1.9.22' apply false
id 'com.google.devtools.ksp' version '2.2.20-2.0.3' apply false
id 'com.google.devtools.ksp' version '2.0.20-1.0.24' apply false
}
include ":app"

View File

@@ -26,7 +26,6 @@ import 'package:wakelock_plus/wakelock_plus.dart';
@RoutePage()
class NativeVideoViewerPage extends HookConsumerWidget {
static final log = Logger('NativeVideoViewer');
final Asset asset;
final bool showControls;
final int playbackDelayFactor;
@@ -60,6 +59,8 @@ class NativeVideoViewerPage extends HookConsumerWidget {
// Used to show the placeholder during hero animations for remote videos to avoid a stutter
final isVisible = useState(Platform.isIOS && asset.isLocal);
final log = Logger('NativeVideoViewerPage');
final isCasting = ref.watch(castProvider.select((c) => c.isCasting));
final isVideoReady = useState(false);
@@ -141,7 +142,7 @@ class NativeVideoViewerPage extends HookConsumerWidget {
interval: const Duration(milliseconds: 100),
maxWaitTime: const Duration(milliseconds: 200),
);
ref.listen(videoPlayerControlsProvider, (oldControls, newControls) {
ref.listen(videoPlayerControlsProvider, (oldControls, newControls) async {
final playerController = controller.value;
if (playerController == null) {
return;
@@ -152,14 +153,28 @@ class NativeVideoViewerPage extends HookConsumerWidget {
return;
}
final oldSeek = oldControls?.position.inMilliseconds;
final newSeek = newControls.position.inMilliseconds;
final oldSeek = (oldControls?.position ?? 0) ~/ 1;
final newSeek = newControls.position ~/ 1;
if (oldSeek != newSeek || newControls.restarted) {
seekDebouncer.run(() => playerController.seekTo(newSeek));
}
if (oldControls?.pause != newControls.pause || newControls.restarted) {
unawaited(_onPauseChange(context, playerController, seekDebouncer, newControls.pause));
// Make sure the last seek is complete before pausing or playing
// Otherwise, `onPlaybackPositionChanged` can receive outdated events
if (seekDebouncer.isActive) {
await seekDebouncer.drain();
}
try {
if (newControls.pause) {
await playerController.pause();
} else {
await playerController.play();
}
} catch (error) {
log.severe('Error pausing or playing video: $error');
}
}
});
@@ -216,7 +231,7 @@ class NativeVideoViewerPage extends HookConsumerWidget {
return;
}
ref.read(videoPlaybackValueProvider.notifier).position = Duration(milliseconds: playbackInfo.position);
ref.read(videoPlaybackValueProvider.notifier).position = Duration(seconds: playbackInfo.position);
// Check if the video is buffering
if (playbackInfo.status == PlaybackStatus.playing) {
@@ -373,35 +388,4 @@ class NativeVideoViewerPage extends HookConsumerWidget {
],
);
}
Future<void> _onPauseChange(
BuildContext context,
NativeVideoPlayerController controller,
Debouncer seekDebouncer,
bool isPaused,
) async {
if (!context.mounted) {
return;
}
// Make sure the last seek is complete before pausing or playing
// Otherwise, `onPlaybackPositionChanged` can receive outdated events
if (seekDebouncer.isActive) {
await seekDebouncer.drain();
}
if (!context.mounted) {
return;
}
try {
if (isPaused) {
await controller.pause();
} else {
await controller.play();
}
} catch (error) {
log.severe('Error pausing or playing video: $error');
}
}
}

View File

@@ -45,7 +45,6 @@ bool _isCurrentAsset(BaseAsset asset, BaseAsset? currentAsset) {
}
class NativeVideoViewer extends HookConsumerWidget {
static final log = Logger('NativeVideoViewer');
final BaseAsset asset;
final bool showControls;
final int playbackDelayFactor;
@@ -79,6 +78,8 @@ class NativeVideoViewer extends HookConsumerWidget {
// Used to show the placeholder during hero animations for remote videos to avoid a stutter
final isVisible = useState(Platform.isIOS && asset.hasLocal);
final log = Logger('NativeVideoViewerPage');
final isCasting = ref.watch(castProvider.select((c) => c.isCasting));
Future<VideoSource?> createSource() async {
@@ -159,7 +160,7 @@ class NativeVideoViewer extends HookConsumerWidget {
interval: const Duration(milliseconds: 100),
maxWaitTime: const Duration(milliseconds: 200),
);
ref.listen(videoPlayerControlsProvider, (oldControls, newControls) {
ref.listen(videoPlayerControlsProvider, (oldControls, newControls) async {
final playerController = controller.value;
if (playerController == null) {
return;
@@ -170,14 +171,28 @@ class NativeVideoViewer extends HookConsumerWidget {
return;
}
final oldSeek = oldControls?.position.inMilliseconds;
final newSeek = newControls.position.inMilliseconds;
final oldSeek = (oldControls?.position ?? 0) ~/ 1;
final newSeek = newControls.position ~/ 1;
if (oldSeek != newSeek || newControls.restarted) {
seekDebouncer.run(() => playerController.seekTo(newSeek));
}
if (oldControls?.pause != newControls.pause || newControls.restarted) {
unawaited(_onPauseChange(context, playerController, seekDebouncer, newControls.pause));
// Make sure the last seek is complete before pausing or playing
// Otherwise, `onPlaybackPositionChanged` can receive outdated events
if (seekDebouncer.isActive) {
await seekDebouncer.drain();
}
try {
if (newControls.pause) {
await playerController.pause();
} else {
await playerController.play();
}
} catch (error) {
log.severe('Error pausing or playing video: $error');
}
}
});
@@ -236,7 +251,7 @@ class NativeVideoViewer extends HookConsumerWidget {
return;
}
ref.read(videoPlaybackValueProvider.notifier).position = Duration(milliseconds: playbackInfo.position);
ref.read(videoPlaybackValueProvider.notifier).position = Duration(seconds: playbackInfo.position);
// Check if the video is buffering
if (playbackInfo.status == PlaybackStatus.playing) {
@@ -392,31 +407,4 @@ class NativeVideoViewer extends HookConsumerWidget {
],
);
}
Future<void> _onPauseChange(
BuildContext context,
NativeVideoPlayerController controller,
Debouncer seekDebouncer,
bool isPaused,
) async {
if (!context.mounted) {
return;
}
// Make sure the last seek is complete before pausing or playing
// Otherwise, `onPlaybackPositionChanged` can receive outdated events
if (seekDebouncer.isActive) {
await seekDebouncer.drain();
}
try {
if (isPaused) {
await controller.pause();
} else {
await controller.play();
}
} catch (error) {
log.severe('Error pausing or playing video: $error');
}
}
}

View File

@@ -4,7 +4,7 @@ import 'package:immich_mobile/providers/asset_viewer/video_player_value_provider
class VideoPlaybackControls {
const VideoPlaybackControls({required this.position, required this.pause, this.restarted = false});
final Duration position;
final double position;
final bool pause;
final bool restarted;
}
@@ -13,7 +13,7 @@ final videoPlayerControlsProvider = StateNotifierProvider<VideoPlayerControls, V
return VideoPlayerControls(ref);
});
const videoPlayerControlsDefault = VideoPlaybackControls(position: Duration.zero, pause: false);
const videoPlayerControlsDefault = VideoPlaybackControls(position: 0, pause: false);
class VideoPlayerControls extends StateNotifier<VideoPlaybackControls> {
VideoPlayerControls(this.ref) : super(videoPlayerControlsDefault);
@@ -30,10 +30,10 @@ class VideoPlayerControls extends StateNotifier<VideoPlaybackControls> {
state = videoPlayerControlsDefault;
}
Duration get position => state.position;
double get position => state.position;
bool get paused => state.pause;
set position(Duration value) {
set position(double value) {
if (state.position == value) {
return;
}
@@ -62,7 +62,7 @@ class VideoPlayerControls extends StateNotifier<VideoPlaybackControls> {
}
void restart() {
state = const VideoPlaybackControls(position: Duration.zero, pause: false, restarted: true);
state = const VideoPlaybackControls(position: 0, pause: false, restarted: true);
ref.read(videoPlaybackValueProvider.notifier).value = ref
.read(videoPlaybackValueProvider.notifier)
.value

View File

@@ -33,8 +33,8 @@ class VideoPlaybackValue {
};
return VideoPlaybackValue(
position: Duration(milliseconds: playbackInfo.position),
duration: Duration(milliseconds: videoInfo.duration),
position: Duration(seconds: playbackInfo.position),
duration: Duration(seconds: videoInfo.duration),
state: status,
volume: playbackInfo.volume,
);

View File

@@ -61,7 +61,7 @@ class VideoPosition extends HookConsumerWidget {
return;
}
ref.read(videoPlayerControlsProvider.notifier).position = seekToDuration;
ref.read(videoPlayerControlsProvider.notifier).position = seekToDuration.inSeconds.toDouble();
// This immediately updates the slider position without waiting for the video to update
ref.read(videoPlaybackValueProvider.notifier).position = seekToDuration;

View File

@@ -1208,8 +1208,8 @@ packages:
dependency: "direct main"
description:
path: "."
ref: c4cdb27
resolved-ref: c4cdb277b02d956c4effb186905329bcb9771adc
ref: "5459d54"
resolved-ref: "5459d54cdc1cf4d99e2193b310052f1ebb5dcf43"
url: "https://github.com/immich-app/native_video_player"
source: git
version: "1.3.1"

View File

@@ -77,7 +77,7 @@ dependencies:
native_video_player:
git:
url: https://github.com/immich-app/native_video_player
ref: 'c4cdb27'
ref: '5459d54'
openapi:
path: openapi
isar:

View File

@@ -203,6 +203,9 @@ export class MediaRepository {
isHDR: stream.color_transfer === 'smpte2084' || stream.color_transfer === 'arib-std-b67',
bitrate: this.parseInt(stream.bit_rate),
pixelFormat: stream.pix_fmt || 'yuv420p',
colorPrimaries: stream.color_primaries,
colorSpace: stream.color_space,
colorTransfer: stream.color_transfer,
})),
audioStreams: results.streams
.filter((stream) => stream.codec_type === 'audio')

View File

@@ -445,6 +445,7 @@ describe(MediaService.name, () => {
}),
);
});
it('should not skip intra frames for MTS file', async () => {
mocks.media.probe.mockResolvedValue(probeStub.videoStreamMTS);
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(assetStub.video);
@@ -462,6 +463,25 @@ describe(MediaService.name, () => {
);
});
it('should override reserved color metadata', async () => {
mocks.media.probe.mockResolvedValue(probeStub.videoStreamReserved);
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(assetStub.video);
await sut.handleGenerateThumbnails({ id: assetStub.video.id });
expect(mocks.media.transcode).toHaveBeenCalledWith(
'/original/path.ext',
expect.any(String),
expect.objectContaining({
inputOptions: expect.arrayContaining([
'-bsf:v hevc_metadata=colour_primaries=1:matrix_coefficients=1:transfer_characteristics=1',
]),
outputOptions: expect.any(Array),
progress: expect.any(Object),
twoPass: false,
}),
);
});
it('should use scaling divisible by 2 even when using quick sync', async () => {
mocks.media.probe.mockResolvedValue(probeStub.videoStream2160p);
mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHardwareAcceleration.Qsv } });

View File

@@ -88,6 +88,9 @@ export interface VideoStreamInfo {
isHDR: boolean;
bitrate: number;
pixelFormat: string;
colorPrimaries?: string;
colorSpace?: string;
colorTransfer?: string;
}
export interface AudioStreamInfo {

View File

@@ -392,9 +392,30 @@ export class ThumbnailConfig extends BaseConfig {
getBaseInputOptions(videoStream: VideoStreamInfo, format?: VideoFormat): string[] {
// skip_frame nointra skips all frames for some MPEG-TS files. Look at ffmpeg tickets 7950 and 7895 for more details.
return format?.formatName === 'mpegts'
? ['-sws_flags accurate_rnd+full_chroma_int']
: ['-skip_frame nointra', '-sws_flags accurate_rnd+full_chroma_int'];
const options =
format?.formatName === 'mpegts'
? ['-sws_flags accurate_rnd+full_chroma_int']
: ['-skip_frame nointra', '-sws_flags accurate_rnd+full_chroma_int'];
const metadataOverrides = [];
if (videoStream.colorPrimaries === 'reserved') {
metadataOverrides.push('colour_primaries=1');
}
if (videoStream.colorSpace === 'reserved') {
metadataOverrides.push('matrix_coefficients=1');
}
if (videoStream.colorTransfer === 'reserved') {
metadataOverrides.push('transfer_characteristics=1');
}
if (metadataOverrides.length > 0) {
// workaround for https://fftrac-bg.ffmpeg.org/ticket/11020
options.push(`-bsf:v ${videoStream.codecName}_metadata=${metadataOverrides.join(':')}`);
}
return options;
}
getBaseOutputOptions() {

View File

@@ -261,4 +261,15 @@ export const probeStub = {
bitrate: 0,
},
}),
videoStreamReserved: Object.freeze<VideoInfo>({
...probeStubDefault,
videoStreams: [
{
...probeStubDefaultVideoStream[0],
colorPrimaries: 'reserved',
colorSpace: 'reserved',
colorTransfer: 'reserved',
},
],
}),
};