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.new, ); class UpdateNotifier extends Notifier 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 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 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 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 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: '安装程序启动失败'); } } }