diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index 2168590..6491e47 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -10,7 +10,7 @@ import ( ) func main() { - log.Println("Starting Hightube Server (Phase 4)...") + log.Println("Starting Hightube Server v1.0.0-Beta3.7...") // Initialize Database and run auto-migrations db.InitDB() diff --git a/frontend/lib/main.dart b/frontend/lib/main.dart index 837605a..56b0bbc 100644 --- a/frontend/lib/main.dart +++ b/frontend/lib/main.dart @@ -8,7 +8,7 @@ import 'pages/login_page.dart'; void main() { fvp.registerWith(); - + runApp( MultiProvider( providers: [ @@ -21,6 +21,8 @@ void main() { } class HightubeApp extends StatelessWidget { + const HightubeApp({super.key}); + @override Widget build(BuildContext context) { final auth = context.watch(); @@ -42,7 +44,7 @@ class HightubeApp extends StatelessWidget { brightness: Brightness.dark, ), ), - themeMode: ThemeMode.system, // 跟随系统切换深浅色 + themeMode: settings.themeMode, home: auth.isAuthenticated ? HomePage() : LoginPage(), debugShowCheckedModeBanner: false, ); diff --git a/frontend/lib/pages/login_page.dart b/frontend/lib/pages/login_page.dart index 600707b..e49265c 100644 --- a/frontend/lib/pages/login_page.dart +++ b/frontend/lib/pages/login_page.dart @@ -8,6 +8,8 @@ import 'register_page.dart'; import 'settings_page.dart'; class LoginPage extends StatefulWidget { + const LoginPage({super.key}); + @override _LoginPageState createState() => _LoginPageState(); } @@ -19,7 +21,9 @@ class _LoginPageState extends State { void _handleLogin() async { if (_usernameController.text.isEmpty || _passwordController.text.isEmpty) { - ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text("Please fill in all fields"))); + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text("Please fill in all fields"))); return; } @@ -29,16 +33,29 @@ class _LoginPageState extends State { final api = ApiService(settings, null); try { - final response = await api.login(_usernameController.text, _passwordController.text); + final response = await api.login( + _usernameController.text, + _passwordController.text, + ); if (response.statusCode == 200) { final data = jsonDecode(response.body); await auth.login(data['token'], data['username']); } else { + if (!mounted) { + return; + } final error = jsonDecode(response.body)['error'] ?? "Login Failed"; - ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(error))); + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(error))); } } catch (e) { - ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text("Network Error: Could not connect to server"))); + if (!mounted) { + return; + } + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text("Network Error: Could not connect to server")), + ); } finally { if (mounted) setState(() => _isLoading = false); } @@ -51,7 +68,10 @@ class _LoginPageState extends State { actions: [ IconButton( icon: Icon(Icons.settings), - onPressed: () => Navigator.push(context, MaterialPageRoute(builder: (_) => SettingsPage())), + onPressed: () => Navigator.push( + context, + MaterialPageRoute(builder: (_) => const SettingsPage()), + ), ), ], ), @@ -64,7 +84,11 @@ class _LoginPageState extends State { mainAxisAlignment: MainAxisAlignment.center, children: [ // Logo & Name - Icon(Icons.flutter_dash, size: 80, color: Theme.of(context).colorScheme.primary), + Icon( + Icons.flutter_dash, + size: 80, + color: Theme.of(context).colorScheme.primary, + ), SizedBox(height: 16), Text( "HIGHTUBE", @@ -75,16 +99,21 @@ class _LoginPageState extends State { color: Theme.of(context).colorScheme.primary, ), ), - Text("Open Source Live Platform", style: TextStyle(color: Colors.grey)), + Text( + "Open Source Live Platform", + style: TextStyle(color: Colors.grey), + ), SizedBox(height: 48), - + // Fields TextField( controller: _usernameController, decoration: InputDecoration( labelText: "Username", prefixIcon: Icon(Icons.person), - border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + ), ), ), SizedBox(height: 16), @@ -94,11 +123,13 @@ class _LoginPageState extends State { decoration: InputDecoration( labelText: "Password", prefixIcon: Icon(Icons.lock), - border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + ), ), ), SizedBox(height: 32), - + // Login Button SizedBox( width: double.infinity, @@ -106,16 +137,26 @@ class _LoginPageState extends State { child: ElevatedButton( onPressed: _isLoading ? null : _handleLogin, style: ElevatedButton.styleFrom( - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), ), - child: _isLoading ? CircularProgressIndicator() : Text("LOGIN", style: TextStyle(fontWeight: FontWeight.bold)), + child: _isLoading + ? CircularProgressIndicator() + : Text( + "LOGIN", + style: TextStyle(fontWeight: FontWeight.bold), + ), ), ), SizedBox(height: 16), - + // Register Link TextButton( - onPressed: () => Navigator.push(context, MaterialPageRoute(builder: (_) => RegisterPage())), + onPressed: () => Navigator.push( + context, + MaterialPageRoute(builder: (_) => RegisterPage()), + ), child: Text("Don't have an account? Create one"), ), ], diff --git a/frontend/lib/pages/player_page.dart b/frontend/lib/pages/player_page.dart index b13cdac..83f327b 100644 --- a/frontend/lib/pages/player_page.dart +++ b/frontend/lib/pages/player_page.dart @@ -1,7 +1,9 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:provider/provider.dart'; import 'package:video_player/video_player.dart'; + import '../providers/auth_provider.dart'; import '../providers/settings_provider.dart'; import '../services/chat_service.dart'; @@ -13,11 +15,11 @@ class PlayerPage extends StatefulWidget { final String roomId; const PlayerPage({ - Key? key, + super.key, required this.title, required this.playbackUrl, required this.roomId, - }) : super(key: key); + }); @override _PlayerPageState createState() => _PlayerPageState(); @@ -32,6 +34,10 @@ class _PlayerPageState extends State { bool _isError = false; String? _errorMessage; + bool _showDanmaku = true; + bool _isRefreshing = false; + bool _isFullscreen = false; + int _playerVersion = 0; @override void initState() { @@ -42,7 +48,7 @@ class _PlayerPageState extends State { _initializeChat(); } - void _initializePlayer() async { + Future _initializePlayer() async { _controller = VideoPlayerController.networkUrl( Uri.parse(widget.playbackUrl), ); @@ -51,11 +57,21 @@ class _PlayerPageState extends State { _controller!.play(); if (mounted) setState(() {}); } catch (e) { - if (mounted) + if (mounted) { setState(() { _isError = true; _errorMessage = e.toString(); + _isRefreshing = false; }); + } + return; + } + if (mounted) { + setState(() { + _isError = false; + _errorMessage = null; + _isRefreshing = false; + }); } } @@ -72,7 +88,9 @@ class _PlayerPageState extends State { setState(() { _messages.insert(0, msg); if (!msg.isHistory && (msg.type == "chat" || msg.type == "danmaku")) { - _addDanmaku(msg.content); + if (_showDanmaku) { + _addDanmaku(msg.content); + } } }); } @@ -107,8 +125,73 @@ class _PlayerPageState extends State { } } + Future _refreshPlayer() async { + if (_isRefreshing) { + return; + } + + setState(() { + _isRefreshing = true; + _isError = false; + _errorMessage = null; + _danmakus.clear(); + _playerVersion++; + }); + + if (kIsWeb) { + await Future.delayed(const Duration(milliseconds: 150)); + if (mounted) { + setState(() => _isRefreshing = false); + } + return; + } + + if (_controller != null) { + await _controller!.dispose(); + } + _controller = null; + if (mounted) { + setState(() {}); + } + await _initializePlayer(); + } + + Future _toggleFullscreen() async { + final nextValue = !_isFullscreen; + if (!kIsWeb) { + await SystemChrome.setEnabledSystemUIMode( + nextValue ? SystemUiMode.immersiveSticky : SystemUiMode.edgeToEdge, + ); + await SystemChrome.setPreferredOrientations( + nextValue + ? const [ + DeviceOrientation.landscapeLeft, + DeviceOrientation.landscapeRight, + ] + : DeviceOrientation.values, + ); + } + + if (mounted) { + setState(() => _isFullscreen = nextValue); + } + } + + void _toggleDanmaku() { + setState(() { + _showDanmaku = !_showDanmaku; + if (!_showDanmaku) { + _danmakus.clear(); + } + }); + } + @override void dispose() { + if (!kIsWeb && _isFullscreen) { + SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge); + SystemChrome.setPreferredOrientations(DeviceOrientation.values); + } _controller?.dispose(); _chatService.dispose(); _msgController.dispose(); @@ -131,13 +214,7 @@ class _PlayerPageState extends State { crossAxisAlignment: CrossAxisAlignment.stretch, children: [ // 左侧视频区 (占比 75%) - Expanded( - flex: 3, - child: Container( - color: Colors.black, - child: _buildVideoWithDanmaku(), - ), - ), + Expanded(flex: 3, child: _buildVideoPanel()), // 右侧聊天区 (占比 25%) Container( width: 350, @@ -156,20 +233,28 @@ class _PlayerPageState extends State { Widget _buildMobileLayout() { return Column( children: [ - // 上方视频区 - Container( - color: Colors.black, + SizedBox( + height: 310, width: double.infinity, - height: 250, - child: _buildVideoWithDanmaku(), + child: _buildVideoPanel(), ), - // 下方聊天区 Expanded(child: _buildChatSection()), ], ); } - // 抽离视频播放器与弹幕组件 + Widget _buildVideoPanel() { + return Container( + color: Colors.black, + child: Column( + children: [ + Expanded(child: _buildVideoWithDanmaku()), + _buildPlaybackControls(), + ], + ), + ); + } + Widget _buildVideoWithDanmaku() { return Stack( children: [ @@ -180,7 +265,10 @@ class _PlayerPageState extends State { style: TextStyle(color: Colors.white), ) : kIsWeb - ? WebStreamPlayer(streamUrl: widget.playbackUrl) + ? WebStreamPlayer( + key: ValueKey('web-player-$_playerVersion'), + streamUrl: widget.playbackUrl, + ) : _controller != null && _controller!.value.isInitialized ? AspectRatio( aspectRatio: _controller!.value.aspectRatio, @@ -188,19 +276,91 @@ class _PlayerPageState extends State { ) : CircularProgressIndicator(), ), - // 弹幕层使用 ClipRect 裁剪,防止飘出视频区域 - ClipRect(child: Stack(children: _danmakus)), + if (_showDanmaku) ClipRect(child: Stack(children: _danmakus)), + if (_isRefreshing) + const Positioned( + top: 16, + right: 16, + child: SizedBox( + width: 24, + height: 24, + child: CircularProgressIndicator(strokeWidth: 2), + ), + ), ], ); } + Widget _buildPlaybackControls() { + return Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.92), + border: Border( + top: BorderSide(color: Colors.white.withValues(alpha: 0.08)), + ), + ), + child: Wrap( + spacing: 10, + runSpacing: 10, + children: [ + _buildControlButton( + icon: Icons.refresh, + label: "Refresh", + onPressed: _refreshPlayer, + ), + _buildControlButton( + icon: _showDanmaku ? Icons.subtitles : Icons.subtitles_off, + label: _showDanmaku ? "Danmaku On" : "Danmaku Off", + onPressed: _toggleDanmaku, + ), + _buildControlButton( + icon: _isFullscreen ? Icons.fullscreen_exit : Icons.fullscreen, + label: _isFullscreen ? "Exit Fullscreen" : "Fullscreen", + onPressed: _toggleFullscreen, + ), + _buildControlButton( + icon: Icons.high_quality, + label: "Resolution", + onPressed: () { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text( + "Resolution switching is planned for a later update.", + ), + ), + ); + }, + ), + ], + ), + ); + } + + Widget _buildControlButton({ + required IconData icon, + required String label, + required VoidCallback onPressed, + }) { + return FilledButton.tonalIcon( + onPressed: onPressed, + icon: Icon(icon, size: 18), + label: Text(label), + style: FilledButton.styleFrom( + foregroundColor: Colors.white, + backgroundColor: Colors.white.withValues(alpha: 0.12), + ), + ); + } + // 抽离聊天区域组件 Widget _buildChatSection() { return Column( children: [ Container( padding: EdgeInsets.symmetric(horizontal: 16, vertical: 12), - color: Theme.of(context).colorScheme.surfaceVariant, + color: Theme.of(context).colorScheme.surfaceContainerHighest, child: Row( children: [ Icon(Icons.chat_bubble_outline, size: 16), @@ -283,11 +443,11 @@ class _DanmakuItem extends StatefulWidget { final VoidCallback onFinished; const _DanmakuItem({ - Key? key, + super.key, required this.text, required this.top, required this.onFinished, - }) : super(key: key); + }); @override __DanmakuItemState createState() => __DanmakuItemState(); diff --git a/frontend/lib/pages/settings_page.dart b/frontend/lib/pages/settings_page.dart index 66662e2..87bbaba 100644 --- a/frontend/lib/pages/settings_page.dart +++ b/frontend/lib/pages/settings_page.dart @@ -1,11 +1,15 @@ import 'dart:convert'; + import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; -import '../providers/settings_provider.dart'; + import '../providers/auth_provider.dart'; +import '../providers/settings_provider.dart'; import '../services/api_service.dart'; class SettingsPage extends StatefulWidget { + const SettingsPage({super.key}); + @override _SettingsPageState createState() => _SettingsPageState(); } @@ -28,7 +32,9 @@ class _SettingsPageState extends State { @override void initState() { super.initState(); - _urlController = TextEditingController(text: context.read().baseUrl); + _urlController = TextEditingController( + text: context.read().baseUrl, + ); } @override @@ -44,23 +50,41 @@ class _SettingsPageState extends State { final auth = context.read(); final api = ApiService(settings, auth.token); - if (_oldPasswordController.text.isEmpty || _newPasswordController.text.isEmpty) { - ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text("Please fill in both password fields"))); + if (_oldPasswordController.text.isEmpty || + _newPasswordController.text.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text("Please fill in both password fields")), + ); return; } try { - final resp = await api.changePassword(_oldPasswordController.text, _newPasswordController.text); + final resp = await api.changePassword( + _oldPasswordController.text, + _newPasswordController.text, + ); + if (!mounted) { + return; + } final data = jsonDecode(resp.body); if (resp.statusCode == 200) { - ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text("Password updated successfully"))); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text("Password updated successfully")), + ); _oldPasswordController.clear(); _newPasswordController.clear(); } else { - ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text("Error: ${data['error']}"))); + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text("Error: ${data['error']}"))); } } catch (e) { - ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text("Failed to connect to server"))); + if (!mounted) { + return; + } + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text("Failed to connect to server"))); } } @@ -68,6 +92,7 @@ class _SettingsPageState extends State { Widget build(BuildContext context) { final auth = context.watch(); final settings = context.watch(); + final isAuthenticated = auth.isAuthenticated; return Scaffold( appBar: AppBar( @@ -79,49 +104,94 @@ class _SettingsPageState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - // User Profile Section - _buildProfileSection(auth), - SizedBox(height: 32), - - // Network Configuration + if (isAuthenticated) ...[ + _buildProfileSection(auth), + const SizedBox(height: 32), + ], _buildSectionTitle("Network Configuration"), - SizedBox(height: 16), + const SizedBox(height: 16), TextField( controller: _urlController, decoration: InputDecoration( labelText: "Backend Server URL", hintText: "http://127.0.0.1:8080", prefixIcon: Icon(Icons.lan), - border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + ), ), ), - SizedBox(height: 12), + const SizedBox(height: 12), SizedBox( width: double.infinity, child: ElevatedButton.icon( onPressed: () { - context.read().setBaseUrl(_urlController.text); - ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text("Server URL Updated"), behavior: SnackBarBehavior.floating)); + context.read().setBaseUrl( + _urlController.text, + ); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text("Server URL Updated"), + behavior: SnackBarBehavior.floating, + ), + ); }, icon: Icon(Icons.save), label: Text("Save Network Settings"), style: ElevatedButton.styleFrom( - backgroundColor: Theme.of(context).colorScheme.primaryContainer, - foregroundColor: Theme.of(context).colorScheme.onPrimaryContainer, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + backgroundColor: Theme.of( + context, + ).colorScheme.primaryContainer, + foregroundColor: Theme.of( + context, + ).colorScheme.onPrimaryContainer, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), ), ), ), - SizedBox(height: 32), + const SizedBox(height: 32), - // Theme Color Section _buildSectionTitle("Theme Customization"), - SizedBox(height: 16), + const SizedBox(height: 16), + Text( + "Appearance Mode", + style: Theme.of(context).textTheme.labelLarge, + ), + const SizedBox(height: 12), + SegmentedButton( + segments: const [ + ButtonSegment( + value: ThemeMode.system, + label: Text("System"), + icon: Icon(Icons.brightness_auto), + ), + ButtonSegment( + value: ThemeMode.light, + label: Text("Light"), + icon: Icon(Icons.light_mode), + ), + ButtonSegment( + value: ThemeMode.dark, + label: Text("Dark"), + icon: Icon(Icons.dark_mode), + ), + ], + selected: {settings.themeMode}, + onSelectionChanged: (selection) { + settings.setThemeMode(selection.first); + }, + ), + const SizedBox(height: 20), + Text("Accent Color", style: Theme.of(context).textTheme.labelLarge), + const SizedBox(height: 12), Wrap( spacing: 12, runSpacing: 12, children: _availableColors.map((color) { - bool isSelected = settings.themeColor.value == color.value; + final isSelected = + settings.themeColor.toARGB32() == color.toARGB32(); return GestureDetector( onTap: () => settings.setThemeColor(color), child: Container( @@ -130,57 +200,86 @@ class _SettingsPageState extends State { decoration: BoxDecoration( color: color, shape: BoxShape.circle, - border: isSelected ? Border.all(color: Theme.of(context).colorScheme.onSurface, width: 3) : null, + border: isSelected + ? Border.all( + color: Theme.of(context).colorScheme.onSurface, + width: 3, + ) + : null, boxShadow: [ - BoxShadow(color: Colors.black26, blurRadius: 4, offset: Offset(0, 2)), + BoxShadow( + color: Colors.black26, + blurRadius: 4, + offset: Offset(0, 2), + ), ], ), - child: isSelected ? Icon(Icons.check, color: Colors.white) : null, + child: isSelected + ? Icon(Icons.check, color: Colors.white) + : null, ), ); }).toList(), ), - SizedBox(height: 32), - - // Security Section - _buildSectionTitle("Security"), - SizedBox(height: 16), - TextField( - controller: _oldPasswordController, - obscureText: true, - decoration: InputDecoration( - labelText: "Old Password", - prefixIcon: Icon(Icons.lock_outline), - border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)), - ), - ), - SizedBox(height: 12), - TextField( - controller: _newPasswordController, - obscureText: true, - decoration: InputDecoration( - labelText: "New Password", - prefixIcon: Icon(Icons.lock_reset), - border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)), - ), - ), - SizedBox(height: 12), - SizedBox( - width: double.infinity, - child: OutlinedButton.icon( - onPressed: _handleChangePassword, - icon: Icon(Icons.update), - label: Text("Change Password"), - style: OutlinedButton.styleFrom( - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + if (isAuthenticated) ...[ + const SizedBox(height: 32), + _buildSectionTitle("Security"), + const SizedBox(height: 16), + TextField( + controller: _oldPasswordController, + obscureText: true, + decoration: InputDecoration( + labelText: "Old Password", + prefixIcon: const Icon(Icons.lock_outline), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + ), ), ), - ), - SizedBox(height: 40), + const SizedBox(height: 12), + TextField( + controller: _newPasswordController, + obscureText: true, + decoration: InputDecoration( + labelText: "New Password", + prefixIcon: const Icon(Icons.lock_reset), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + ), + const SizedBox(height: 12), + SizedBox( + width: double.infinity, + child: OutlinedButton.icon( + onPressed: _handleChangePassword, + icon: const Icon(Icons.update), + label: const Text("Change Password"), + style: OutlinedButton.styleFrom( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + ), + ), + const SizedBox(height: 16), + SizedBox( + width: double.infinity, + child: FilledButton.tonalIcon( + onPressed: auth.logout, + icon: const Icon(Icons.logout), + label: const Text("Logout"), + style: FilledButton.styleFrom( + foregroundColor: Colors.redAccent, + padding: const EdgeInsets.symmetric(vertical: 14), + ), + ), + ), + ], + const SizedBox(height: 40), - // About Section - Divider(), - SizedBox(height: 20), + const Divider(), + const SizedBox(height: 20), Center( child: Column( children: [ @@ -191,18 +290,39 @@ class _SettingsPageState extends State { color: settings.themeColor, borderRadius: BorderRadius.circular(12), ), - child: Center(child: Text("H", style: TextStyle(color: Colors.white, fontSize: 32, fontWeight: FontWeight.bold))), + child: Center( + child: Text( + "H", + style: TextStyle( + color: Colors.white, + fontSize: 32, + fontWeight: FontWeight.bold, + ), + ), + ), ), SizedBox(height: 12), - Text("Hightube", style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)), - Text("Version: 1.0.0-beta3.5", style: TextStyle(color: Colors.grey)), - Text("Author: Highground-Soft & Minimax", style: TextStyle(color: Colors.grey)), + Text( + "Hightube", + style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold), + ), + Text( + "Version: 1.0.0-beta3.5", + style: TextStyle(color: Colors.grey), + ), + Text( + "Author: Highground-Soft & Minimax", + style: TextStyle(color: Colors.grey), + ), SizedBox(height: 20), - Text("© 2026 Hightube Project", style: TextStyle(fontSize: 12, color: Colors.grey)), + Text( + "© 2026 Hightube Project", + style: TextStyle(fontSize: 12, color: Colors.grey), + ), ], ), ), - SizedBox(height: 40), + const SizedBox(height: 40), ], ), ), @@ -221,9 +341,9 @@ class _SettingsPageState extends State { Widget _buildProfileSection(AuthProvider auth) { return Container( - padding: EdgeInsets.all(20), + padding: const EdgeInsets.all(20), decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surfaceVariant, + color: Theme.of(context).colorScheme.surfaceContainerHighest, borderRadius: BorderRadius.circular(20), ), child: Row( @@ -233,10 +353,14 @@ class _SettingsPageState extends State { backgroundColor: Theme.of(context).colorScheme.primary, child: Text( (auth.username ?? "U")[0].toUpperCase(), - style: TextStyle(fontSize: 32, color: Colors.white, fontWeight: FontWeight.bold), + style: TextStyle( + fontSize: 32, + color: Colors.white, + fontWeight: FontWeight.bold, + ), ), ), - SizedBox(width: 20), + const SizedBox(width: 20), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -247,16 +371,13 @@ class _SettingsPageState extends State { ), Text( "Self-hosted Streamer", - style: TextStyle(color: Theme.of(context).colorScheme.outline), + style: TextStyle( + color: Theme.of(context).colorScheme.outline, + ), ), ], ), ), - IconButton( - onPressed: () => auth.logout(), - icon: Icon(Icons.logout, color: Colors.redAccent), - tooltip: "Logout", - ), ], ), ); diff --git a/frontend/lib/providers/settings_provider.dart b/frontend/lib/providers/settings_provider.dart index 5c78adf..d28e239 100644 --- a/frontend/lib/providers/settings_provider.dart +++ b/frontend/lib/providers/settings_provider.dart @@ -1,6 +1,6 @@ import 'package:flutter/material.dart'; -import 'package:shared_preferences/shared_preferences.dart'; import 'package:flutter/foundation.dart'; +import 'package:shared_preferences/shared_preferences.dart'; class SettingsProvider with ChangeNotifier { // Use 10.0.2.2 for Android emulator to access host's localhost @@ -11,9 +11,11 @@ class SettingsProvider with ChangeNotifier { String _baseUrl = _defaultUrl; Color _themeColor = Colors.blue; + ThemeMode _themeMode = ThemeMode.system; String get baseUrl => _baseUrl; Color get themeColor => _themeColor; + ThemeMode get themeMode => _themeMode; SettingsProvider() { _loadSettings(); @@ -26,6 +28,10 @@ class SettingsProvider with ChangeNotifier { if (colorValue != null) { _themeColor = Color(colorValue); } + final savedThemeMode = prefs.getString('themeMode'); + if (savedThemeMode != null) { + _themeMode = _themeModeFromString(savedThemeMode); + } notifyListeners(); } @@ -39,7 +45,14 @@ class SettingsProvider with ChangeNotifier { void setThemeColor(Color color) async { _themeColor = color; final prefs = await SharedPreferences.getInstance(); - await prefs.setInt('themeColor', color.value); + await prefs.setInt('themeColor', color.toARGB32()); + notifyListeners(); + } + + void setThemeMode(ThemeMode mode) async { + _themeMode = mode; + final prefs = await SharedPreferences.getInstance(); + await prefs.setString('themeMode', mode.name); notifyListeners(); } @@ -56,4 +69,15 @@ class SettingsProvider with ChangeNotifier { } return "$rtmpUrl/$roomId"; } + + ThemeMode _themeModeFromString(String value) { + switch (value) { + case 'light': + return ThemeMode.light; + case 'dark': + return ThemeMode.dark; + default: + return ThemeMode.system; + } + } } diff --git a/frontend/lib/widgets/web_stream_player_stub.dart b/frontend/lib/widgets/web_stream_player_stub.dart index faca323..09d8d6c 100644 --- a/frontend/lib/widgets/web_stream_player_stub.dart +++ b/frontend/lib/widgets/web_stream_player_stub.dart @@ -2,8 +2,13 @@ import 'package:flutter/material.dart'; class WebStreamPlayer extends StatelessWidget { final String streamUrl; + final int? refreshToken; - const WebStreamPlayer({super.key, required this.streamUrl}); + const WebStreamPlayer({ + super.key, + required this.streamUrl, + this.refreshToken, + }); @override Widget build(BuildContext context) { diff --git a/frontend/lib/widgets/web_stream_player_web.dart b/frontend/lib/widgets/web_stream_player_web.dart index 6860ef9..fade0c6 100644 --- a/frontend/lib/widgets/web_stream_player_web.dart +++ b/frontend/lib/widgets/web_stream_player_web.dart @@ -5,8 +5,13 @@ import 'package:flutter/material.dart'; class WebStreamPlayer extends StatefulWidget { final String streamUrl; + final int? refreshToken; - const WebStreamPlayer({super.key, required this.streamUrl}); + const WebStreamPlayer({ + super.key, + required this.streamUrl, + this.refreshToken, + }); @override State createState() => _WebStreamPlayerState(); diff --git a/frontend/pubspec.yaml b/frontend/pubspec.yaml index 9f7294d..c731046 100644 --- a/frontend/pubspec.yaml +++ b/frontend/pubspec.yaml @@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # In Windows, build-name is used as the major, minor, and patch parts # of the product and file versions while build-number is used as the build suffix. -version: 1.0.0-beta3.5 +version: 1.0.0-beta3.7 environment: sdk: ^3.11.1