diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 181ccce3..eea52b82 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -58,7 +58,7 @@ jobs: EOF - name: Build APK - run: flutter build apk --release + run: flutter build apk --release --dart-define=ENABLE_UPDATE_CHECK=true - name: Clean up keystore if: always() @@ -118,7 +118,7 @@ jobs: run: flutter pub get - name: Build iOS (no codesign) - run: flutter build ios --release --no-codesign + run: flutter build ios --release --no-codesign --dart-define=ENABLE_UPDATE_CHECK=true - name: Create IPA run: | @@ -176,7 +176,7 @@ jobs: run: flutter pub get - name: Build macOS - run: flutter build macos --release + run: flutter build macos --release --dart-define=ENABLE_UPDATE_CHECK=true - name: Import Code Signing Certificate env: @@ -298,7 +298,7 @@ jobs: run: flutter pub get - name: Build Windows - run: flutter build windows --release + run: flutter build windows --release --dart-define=ENABLE_UPDATE_CHECK=true - name: Build Windows Installer run: .\windows\build-installer.ps1 @@ -361,7 +361,7 @@ jobs: run: flutter pub get - name: Build Linux - run: flutter build linux --release + run: flutter build linux --release --dart-define=ENABLE_UPDATE_CHECK=true - name: Create Archive run: | diff --git a/lib/main.dart b/lib/main.dart index 28ff3b1e..5c501469 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -3,6 +3,7 @@ import 'dart:io' show Platform; import 'package:media_kit/media_kit.dart'; import 'package:window_manager/window_manager.dart'; import 'package:provider/provider.dart'; +import 'package:url_launcher/url_launcher.dart'; import 'screens/main_screen.dart'; import 'screens/auth_screen.dart'; import 'services/storage_service.dart'; @@ -10,6 +11,7 @@ import 'services/plex_auth_service.dart'; import 'services/server_connection_service.dart'; import 'services/macos_titlebar_service.dart'; import 'services/fullscreen_state_manager.dart'; +import 'services/update_service.dart'; import 'providers/user_profile_provider.dart'; import 'providers/plex_client_provider.dart'; import 'providers/theme_provider.dart'; @@ -126,6 +128,78 @@ class _SetupScreenState extends State { _loadSavedCredentials(); } + void _checkForUpdatesOnStartup() async { + // Delay slightly to allow UI to settle + await Future.delayed(const Duration(milliseconds: 500)); + + if (!mounted) return; + + try { + final updateInfo = await UpdateService.checkForUpdatesOnStartup(); + + if (updateInfo != null && updateInfo['hasUpdate'] == true && mounted) { + _showUpdateDialog(updateInfo); + } + } catch (e) { + appLogger.e('Error checking for updates', error: e); + } + } + + void _showUpdateDialog(Map updateInfo) { + showDialog( + context: context, + builder: (BuildContext dialogContext) { + return AlertDialog( + title: const Text('Update Available'), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Version ${updateInfo['latestVersion']} is available', + style: Theme.of(dialogContext).textTheme.titleMedium, + ), + const SizedBox(height: 8), + Text( + 'Current: ${updateInfo['currentVersion']}', + style: Theme.of(dialogContext).textTheme.bodySmall, + ), + ], + ), + actions: [ + TextButton( + onPressed: () { + Navigator.pop(dialogContext); + }, + child: const Text('Later'), + ), + TextButton( + onPressed: () async { + await UpdateService.skipVersion(updateInfo['latestVersion']); + if (dialogContext.mounted) { + Navigator.pop(dialogContext); + } + }, + child: const Text('Skip This Version'), + ), + FilledButton( + onPressed: () async { + final url = Uri.parse(updateInfo['releaseUrl']); + if (await canLaunchUrl(url)) { + await launchUrl(url, mode: LaunchMode.externalApplication); + } + if (dialogContext.mounted) { + Navigator.pop(dialogContext); + } + }, + child: const Text('View Release'), + ), + ], + ); + }, + ); + } + Future _loadSavedCredentials() async { final storage = await StorageService.getInstance(); @@ -173,10 +247,14 @@ class _SetupScreenState extends State { // Handle result if (result.isSuccess) { - // Success! Set client in provider and navigate to main screen + // Success! Set client in provider if (mounted) { context.plexClient.setClient(result.client!); + // Check for updates BEFORE navigation to keep context valid + _checkForUpdatesOnStartup(); + + // Navigate to main screen after update check is initiated if (mounted) { Navigator.pushReplacement( context, diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index c358ce35..062aa633 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -1,10 +1,12 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import 'package:hotkey_manager/hotkey_manager.dart'; +import 'package:url_launcher/url_launcher.dart'; import '../providers/theme_provider.dart'; import '../providers/settings_provider.dart'; import '../services/settings_service.dart' as settings; import '../services/keyboard_shortcuts_service.dart'; +import '../services/update_service.dart'; import '../widgets/desktop_app_bar.dart'; import '../widgets/hotkey_recorder_widget.dart'; import 'about_screen.dart'; @@ -27,6 +29,10 @@ class _SettingsScreenState extends State { int _seekTimeSmall = 10; int _seekTimeLarge = 30; + // Update checking state + bool _isCheckingForUpdate = false; + Map? _updateInfo; + @override void initState() { super.initState(); @@ -70,6 +76,10 @@ class _SettingsScreenState extends State { const SizedBox(height: 24), _buildAdvancedSection(), const SizedBox(height: 24), + if (UpdateService.isUpdateCheckEnabled) ...[ + _buildUpdateSection(), + const SizedBox(height: 24), + ], _buildAboutSection(), const SizedBox(height: 24), ]), @@ -259,6 +269,56 @@ class _SettingsScreenState extends State { ); } + Widget _buildUpdateSection() { + final hasUpdate = _updateInfo != null && _updateInfo!['hasUpdate'] == true; + + return Card( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.all(16), + child: Text( + 'Updates', + style: Theme.of(context) + .textTheme + .titleMedium + ?.copyWith(fontWeight: FontWeight.bold), + ), + ), + ListTile( + leading: Icon( + hasUpdate ? Icons.system_update : Icons.check_circle, + color: hasUpdate ? Colors.orange : null, + ), + title: Text( + hasUpdate ? 'Update Available' : 'Check for Updates', + ), + subtitle: hasUpdate + ? Text('Version ${_updateInfo!['latestVersion']} is available') + : const Text('Check for the latest version on GitHub'), + trailing: _isCheckingForUpdate + ? const SizedBox( + width: 24, + height: 24, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.chevron_right), + onTap: _isCheckingForUpdate + ? null + : () { + if (hasUpdate) { + _showUpdateDialog(); + } else { + _checkForUpdates(); + } + }, + ), + ], + ), + ); + } + Widget _buildAboutSection() { return Card( child: ListTile( @@ -585,6 +645,90 @@ class _SettingsScreenState extends State { ); } + Future _checkForUpdates() async { + setState(() { + _isCheckingForUpdate = true; + }); + + try { + final updateInfo = await UpdateService.checkForUpdates(); + + if (mounted) { + setState(() { + _updateInfo = updateInfo; + _isCheckingForUpdate = false; + }); + + if (updateInfo == null || updateInfo['hasUpdate'] != true) { + // Show "no updates" message + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('You are on the latest version'), + duration: Duration(seconds: 2), + ), + ); + } + } + } catch (e) { + if (mounted) { + setState(() { + _isCheckingForUpdate = false; + }); + + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Failed to check for updates'), + duration: Duration(seconds: 2), + ), + ); + } + } + } + + void _showUpdateDialog() { + if (_updateInfo == null) return; + + showDialog( + context: context, + builder: (BuildContext context) { + return AlertDialog( + title: const Text('Update Available'), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Version ${_updateInfo!['latestVersion']} is available', + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 8), + Text( + 'Current: ${_updateInfo!['currentVersion']}', + style: Theme.of(context).textTheme.bodySmall, + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Close'), + ), + FilledButton( + onPressed: () async { + final url = Uri.parse(_updateInfo!['releaseUrl']); + if (await canLaunchUrl(url)) { + await launchUrl(url, mode: LaunchMode.externalApplication); + } + if (context.mounted) Navigator.pop(context); + }, + child: const Text('View Release'), + ), + ], + ); + }, + ); + } + void _showLibraryDensityDialog() { final settingsProvider = context.read(); showDialog( diff --git a/lib/services/update_service.dart b/lib/services/update_service.dart new file mode 100644 index 00000000..a470f7a0 --- /dev/null +++ b/lib/services/update_service.dart @@ -0,0 +1,177 @@ +import 'package:package_info_plus/package_info_plus.dart'; +import 'package:dio/dio.dart'; +import 'package:logger/logger.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +/// Service to check for new versions on GitHub +/// Only enabled when ENABLE_UPDATE_CHECK build flag is set +class UpdateService { + static final Logger _logger = Logger(); + static const String _githubRepo = 'edde746/plezy'; + + // SharedPreferences keys + static const String _keySkippedVersion = 'update_skipped_version'; + static const String _keyLastCheckTime = 'update_last_check_time'; + + // Check cooldown: 6 hours + static const Duration _checkCooldown = Duration(hours: 6); + + /// Check if update checking is enabled via build flag + static bool get isUpdateCheckEnabled { + const enabled = bool.fromEnvironment('ENABLE_UPDATE_CHECK', defaultValue: false); + return enabled; + } + + /// Skip a specific version + static Future skipVersion(String version) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(_keySkippedVersion, version); + } + + /// Get the skipped version + static Future getSkippedVersion() async { + final prefs = await SharedPreferences.getInstance(); + return prefs.getString(_keySkippedVersion); + } + + /// Clear skipped version + static Future clearSkippedVersion() async { + final prefs = await SharedPreferences.getInstance(); + await prefs.remove(_keySkippedVersion); + } + + /// Check if cooldown period has passed since last check + static Future shouldCheckForUpdates() async { + final prefs = await SharedPreferences.getInstance(); + final lastCheckString = prefs.getString(_keyLastCheckTime); + + if (lastCheckString == null) return true; + + final lastCheck = DateTime.parse(lastCheckString); + final now = DateTime.now(); + final timeSinceLastCheck = now.difference(lastCheck); + + return timeSinceLastCheck >= _checkCooldown; + } + + /// Update the last check timestamp + static Future _updateLastCheckTime() async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(_keyLastCheckTime, DateTime.now().toIso8601String()); + } + + /// Check for updates on GitHub (manual check, ignores cooldown) + /// Returns a map with update info, or null if no update or error + static Future?> checkForUpdates({bool silent = false}) async { + if (!isUpdateCheckEnabled) { + return null; + } + + try { + + final packageInfo = await PackageInfo.fromPlatform(); + final currentVersion = packageInfo.version; + + final dio = Dio(); + final response = await dio.get( + 'https://api.github.com/repos/$_githubRepo/releases/latest', + options: Options( + headers: { + 'Accept': 'application/vnd.github+json', + }, + ), + ); + + if (response.statusCode == 200) { + final data = response.data; + final latestVersion = data['tag_name'] as String; + + // Remove 'v' prefix if present + final cleanVersion = latestVersion.startsWith('v') + ? latestVersion.substring(1) + : latestVersion; + + final hasUpdate = _isNewerVersion(cleanVersion, currentVersion); + + if (hasUpdate) { + // Check if this version was skipped (always check, regardless of silent mode) + final skippedVersion = await getSkippedVersion(); + if (skippedVersion == cleanVersion) { + return null; + } + + return { + 'hasUpdate': true, + 'currentVersion': currentVersion, + 'latestVersion': cleanVersion, + 'releaseUrl': data['html_url'] as String, + 'releaseName': data['name'] as String? ?? 'Version $cleanVersion', + 'releaseNotes': data['body'] as String? ?? '', + 'publishedAt': data['published_at'] as String, + }; + } + } + } catch (e) { + _logger.e('Failed to check for updates: $e'); + } + + return null; + } + + /// Check for updates on startup (respects cooldown and skipped versions) + /// Returns update info if available, null otherwise + static Future?> checkForUpdatesOnStartup() async { + if (!isUpdateCheckEnabled) { + return null; + } + + // Check cooldown + if (!await shouldCheckForUpdates()) { + return null; + } + + // Perform the check + final updateInfo = await checkForUpdates(silent: true); + + // Update last check time + await _updateLastCheckTime(); + + return updateInfo; + } + + /// Compare two version strings + /// Returns true if newVersion is newer than currentVersion + static bool _isNewerVersion(String newVersion, String currentVersion) { + try { + // Split by '.' and parse as integers + final newParts = newVersion.split('.').map((p) { + // Handle versions like "1.2.3+4" by taking only the numeric part + final numPart = p.split('+').first.split('-').first; + return int.tryParse(numPart) ?? 0; + }).toList(); + + final currentParts = currentVersion.split('.').map((p) { + final numPart = p.split('+').first.split('-').first; + return int.tryParse(numPart) ?? 0; + }).toList(); + + // Compare each part + final maxLength = newParts.length > currentParts.length + ? newParts.length + : currentParts.length; + + for (int i = 0; i < maxLength; i++) { + final newPart = i < newParts.length ? newParts[i] : 0; + final currentPart = i < currentParts.length ? currentParts[i] : 0; + + if (newPart > currentPart) return true; + if (newPart < currentPart) return false; + } + + return false; // Versions are equal + } catch (e) { + _logger.e('Error comparing versions: $e'); + return false; + } + } +}