Initial commit
This commit is contained in:
@@ -0,0 +1,293 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../core/contracts/app_update.dart';
|
||||
import '../features/update/update_service.dart';
|
||||
import '../shared/utils/app_logger.dart';
|
||||
import 'shared/settings_persistence.dart';
|
||||
|
||||
enum UpdateStatus {
|
||||
idle,
|
||||
checking,
|
||||
upToDate,
|
||||
available,
|
||||
downloading,
|
||||
readyToInstall,
|
||||
error,
|
||||
}
|
||||
|
||||
class UpdateState {
|
||||
final UpdateStatus status;
|
||||
final AppUpdateInfo? info;
|
||||
final int received;
|
||||
final int total;
|
||||
|
||||
|
||||
final double speedBytesPerSec;
|
||||
final String? error;
|
||||
final bool silent;
|
||||
final String? installerPath;
|
||||
|
||||
const UpdateState({
|
||||
this.status = UpdateStatus.idle,
|
||||
this.info,
|
||||
this.received = 0,
|
||||
this.total = 0,
|
||||
this.speedBytesPerSec = 0,
|
||||
this.error,
|
||||
this.silent = false,
|
||||
this.installerPath,
|
||||
});
|
||||
|
||||
UpdateState copyWith({
|
||||
UpdateStatus? status,
|
||||
AppUpdateInfo? info,
|
||||
int? received,
|
||||
int? total,
|
||||
double? speedBytesPerSec,
|
||||
String? error,
|
||||
bool? silent,
|
||||
String? installerPath,
|
||||
bool clearInfo = false,
|
||||
bool clearError = false,
|
||||
bool clearInstallerPath = false,
|
||||
}) {
|
||||
return UpdateState(
|
||||
status: status ?? this.status,
|
||||
info: clearInfo ? null : info ?? this.info,
|
||||
received: received ?? this.received,
|
||||
total: total ?? this.total,
|
||||
speedBytesPerSec: speedBytesPerSec ?? this.speedBytesPerSec,
|
||||
error: clearError ? null : error ?? this.error,
|
||||
silent: silent ?? this.silent,
|
||||
installerPath: clearInstallerPath
|
||||
? null
|
||||
: installerPath ?? this.installerPath,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
final updateProvider = NotifierProvider<UpdateNotifier, UpdateState>(
|
||||
UpdateNotifier.new,
|
||||
);
|
||||
|
||||
class UpdateNotifier extends Notifier<UpdateState> with SettingsPersistence {
|
||||
static const _tag = 'Update';
|
||||
static const _lastCheckKey = 'smplayer.update.last_check';
|
||||
static const _silentThrottle = Duration(hours: 12);
|
||||
|
||||
final UpdateService _service;
|
||||
CancelToken? _cancelToken;
|
||||
|
||||
DateTime? _lastSpeedSampleAt;
|
||||
int _lastSpeedSampleBytes = 0;
|
||||
|
||||
UpdateNotifier({UpdateService? service})
|
||||
: _service = service ?? UpdateService();
|
||||
|
||||
@override
|
||||
UpdateState build() => const UpdateState();
|
||||
|
||||
Future<void> silentCheck() async {
|
||||
final prefs = await getPrefs();
|
||||
final lastCheck = prefs.getInt(_lastCheckKey);
|
||||
final now = DateTime.now().millisecondsSinceEpoch;
|
||||
if (lastCheck != null && now - lastCheck < _silentThrottle.inMilliseconds) {
|
||||
AppLogger.debug(_tag, 'silent check throttled');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await prefs.setInt(_lastCheckKey, now);
|
||||
state = state.copyWith(
|
||||
status: UpdateStatus.checking,
|
||||
silent: true,
|
||||
received: 0,
|
||||
total: 0,
|
||||
clearError: true,
|
||||
clearInstallerPath: true,
|
||||
);
|
||||
final result = await _service.check();
|
||||
if (result.hasUpdate && result.info != null) {
|
||||
state = state.copyWith(
|
||||
status: UpdateStatus.available,
|
||||
info: result.info,
|
||||
silent: true,
|
||||
);
|
||||
} else {
|
||||
state = state.copyWith(
|
||||
status: UpdateStatus.idle,
|
||||
silent: true,
|
||||
clearInfo: true,
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
AppLogger.warn(_tag, 'silent check failed', e);
|
||||
state = state.copyWith(
|
||||
status: UpdateStatus.idle,
|
||||
silent: true,
|
||||
clearError: true,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> manualCheck() async {
|
||||
try {
|
||||
state = state.copyWith(
|
||||
status: UpdateStatus.checking,
|
||||
silent: false,
|
||||
received: 0,
|
||||
total: 0,
|
||||
clearError: true,
|
||||
clearInstallerPath: true,
|
||||
);
|
||||
final result = await _service.check();
|
||||
await persistField(_lastCheckKey, DateTime.now().millisecondsSinceEpoch);
|
||||
if (result.hasUpdate && result.info != null) {
|
||||
state = state.copyWith(
|
||||
status: UpdateStatus.available,
|
||||
info: result.info,
|
||||
silent: false,
|
||||
);
|
||||
} else {
|
||||
state = state.copyWith(
|
||||
status: UpdateStatus.upToDate,
|
||||
silent: false,
|
||||
clearInfo: true,
|
||||
);
|
||||
}
|
||||
} catch (e, stack) {
|
||||
AppLogger.error(_tag, 'manual check failed', e, stack);
|
||||
state = state.copyWith(
|
||||
status: UpdateStatus.error,
|
||||
error: '检查更新失败,请稍后重试',
|
||||
silent: false,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> startDownload() async {
|
||||
final info = state.info;
|
||||
final asset = info?.selectedAsset;
|
||||
if (info == null || asset == null) return;
|
||||
|
||||
_cancelToken?.cancel('restart download');
|
||||
final cancelToken = CancelToken();
|
||||
_cancelToken = cancelToken;
|
||||
|
||||
_lastSpeedSampleAt = null;
|
||||
_lastSpeedSampleBytes = 0;
|
||||
|
||||
try {
|
||||
state = state.copyWith(
|
||||
status: UpdateStatus.downloading,
|
||||
received: 0,
|
||||
total: asset.size,
|
||||
speedBytesPerSec: 0,
|
||||
clearError: true,
|
||||
clearInstallerPath: true,
|
||||
);
|
||||
final file = await _service.download(
|
||||
asset,
|
||||
cancelToken: cancelToken,
|
||||
onReceiveProgress: (received, total) {
|
||||
if (cancelToken.isCancelled) return;
|
||||
state = state.copyWith(
|
||||
status: UpdateStatus.downloading,
|
||||
received: received,
|
||||
total: total > 0 ? total : asset.size,
|
||||
speedBytesPerSec: _sampleSpeed(received),
|
||||
);
|
||||
},
|
||||
);
|
||||
if (cancelToken.isCancelled) return;
|
||||
state = state.copyWith(
|
||||
status: UpdateStatus.readyToInstall,
|
||||
received: state.total,
|
||||
speedBytesPerSec: 0,
|
||||
installerPath: file.path,
|
||||
clearError: true,
|
||||
);
|
||||
} on UpdateVerificationException catch (e, stack) {
|
||||
AppLogger.error(_tag, 'update verification failed', e, stack);
|
||||
state = state.copyWith(
|
||||
status: UpdateStatus.error,
|
||||
speedBytesPerSec: 0,
|
||||
error: e.message,
|
||||
);
|
||||
return;
|
||||
} on DioException catch (e, stack) {
|
||||
if (CancelToken.isCancel(e)) {
|
||||
state = state.copyWith(
|
||||
status: UpdateStatus.available,
|
||||
received: 0,
|
||||
total: asset.size,
|
||||
speedBytesPerSec: 0,
|
||||
clearError: true,
|
||||
);
|
||||
return;
|
||||
}
|
||||
AppLogger.error(_tag, 'download failed', e, stack);
|
||||
state = state.copyWith(
|
||||
status: UpdateStatus.error,
|
||||
speedBytesPerSec: 0,
|
||||
error: '下载失败,请稍后重试',
|
||||
);
|
||||
} catch (e, stack) {
|
||||
AppLogger.error(_tag, 'download failed', e, stack);
|
||||
state = state.copyWith(
|
||||
status: UpdateStatus.error,
|
||||
speedBytesPerSec: 0,
|
||||
error: '下载失败,请稍后重试',
|
||||
);
|
||||
} finally {
|
||||
if (identical(_cancelToken, cancelToken)) {
|
||||
_cancelToken = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
double? _sampleSpeed(int received) {
|
||||
final now = DateTime.now();
|
||||
final lastAt = _lastSpeedSampleAt;
|
||||
if (lastAt == null) {
|
||||
_lastSpeedSampleAt = now;
|
||||
_lastSpeedSampleBytes = received;
|
||||
return null;
|
||||
}
|
||||
final elapsedMs = now.difference(lastAt).inMilliseconds;
|
||||
if (elapsedMs < 500) return null;
|
||||
final delta = received - _lastSpeedSampleBytes;
|
||||
_lastSpeedSampleAt = now;
|
||||
_lastSpeedSampleBytes = received;
|
||||
return delta > 0 ? delta * 1000.0 / elapsedMs : 0.0;
|
||||
}
|
||||
|
||||
void cancel() {
|
||||
_cancelToken?.cancel('cancelled by user');
|
||||
_cancelToken = null;
|
||||
final info = state.info;
|
||||
state = state.copyWith(
|
||||
status: info == null ? UpdateStatus.idle : UpdateStatus.available,
|
||||
received: 0,
|
||||
total: info?.selectedAsset?.size ?? 0,
|
||||
speedBytesPerSec: 0,
|
||||
clearError: true,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> install() async {
|
||||
final path = state.installerPath;
|
||||
if (path == null || path.isEmpty) return;
|
||||
|
||||
try {
|
||||
await _service.launchInstaller(path);
|
||||
} catch (e, stack) {
|
||||
AppLogger.error(_tag, 'launch installer failed', e, stack);
|
||||
state = state.copyWith(status: UpdateStatus.error, error: '安装程序启动失败');
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user