diff --git a/backend/internal/db/db.go b/backend/internal/db/db.go index 17c69b9..a2b211b 100644 --- a/backend/internal/db/db.go +++ b/backend/internal/db/db.go @@ -41,5 +41,8 @@ func InitDB() { log.Fatalf("Failed to migrate database: %v", err) } + // Phase 3.5 Fix: Reset all rooms to inactive on startup using explicit map to ensure false is updated + DB.Model(&model.Room{}).Where("1 = 1").Updates(map[string]interface{}{"is_active": false}) + log.Println("Database initialized successfully.") } diff --git a/backend/internal/stream/server.go b/backend/internal/stream/server.go index 4e0a2b7..e594036 100644 --- a/backend/internal/stream/server.go +++ b/backend/internal/stream/server.go @@ -72,8 +72,8 @@ func NewRTMPServer() *RTMPServer { s.channels[roomLivePath] = q s.mutex.Unlock() - // Mark room as active in DB - db.DB.Model(&room).Update("is_active", true) + // Mark room as active in DB (using map to ensure true/false is correctly updated) + db.DB.Model(&room).Updates(map[string]interface{}{"is_active": true}) // 3. Cleanup on end defer func() { @@ -81,7 +81,8 @@ func NewRTMPServer() *RTMPServer { delete(s.channels, roomLivePath) s.mutex.Unlock() q.Close() - db.DB.Model(&room).Update("is_active", false) // Mark room as inactive + // Explicitly set is_active to false using map + db.DB.Model(&room).Updates(map[string]interface{}{"is_active": false}) fmt.Printf("[INFO] Publishing ended for Room ID: %d\n", room.ID) }() diff --git a/frontend/lib/pages/home_page.dart b/frontend/lib/pages/home_page.dart index bba8394..96e2a97 100644 --- a/frontend/lib/pages/home_page.dart +++ b/frontend/lib/pages/home_page.dart @@ -17,49 +17,40 @@ class HomePage extends StatefulWidget { class _HomePageState extends State { int _selectedIndex = 0; - // 根据当前选择的索引返回对应的页面 - final List _pages = [ - _ExploreView(), - MyStreamPage(), - SettingsPage(), - ]; - @override Widget build(BuildContext context) { - // 检查是否为桌面/宽屏环境 bool isWide = MediaQuery.of(context).size.width > 600; + final List _pages = [ + _ExploreView(onGoLive: () => setState(() => _selectedIndex = 1)), + MyStreamPage(), + SettingsPage(), + ]; + return Scaffold( body: Row( children: [ - // 桌面端侧边导航 if (isWide) NavigationRail( selectedIndex: _selectedIndex, - onDestinationSelected: (int index) { - setState(() => _selectedIndex = index); - }, + onDestinationSelected: (int index) => setState(() => _selectedIndex = index), labelType: NavigationRailLabelType.all, destinations: const [ NavigationRailDestination(icon: Icon(Icons.explore), label: Text('Explore')), - NavigationRailDestination(icon: Icon(Icons.videocam), label: Text('Stream')), + NavigationRailDestination(icon: Icon(Icons.videocam), label: Text('Console')), NavigationRailDestination(icon: Icon(Icons.settings), label: Text('Settings')), ], ), - // 主内容区 Expanded(child: _pages[_selectedIndex]), ], ), - // 移动端底部导航 bottomNavigationBar: !isWide ? NavigationBar( selectedIndex: _selectedIndex, - onDestinationSelected: (int index) { - setState(() => _selectedIndex = index); - }, + onDestinationSelected: (int index) => setState(() => _selectedIndex = index), destinations: const [ NavigationDestination(icon: Icon(Icons.explore), label: 'Explore'), - NavigationDestination(icon: Icon(Icons.videocam), label: 'Stream'), + NavigationDestination(icon: Icon(Icons.videocam), label: 'Console'), NavigationDestination(icon: Icon(Icons.settings), label: 'Settings'), ], ) @@ -68,8 +59,10 @@ class _HomePageState extends State { } } -// 将原本的直播列表逻辑封装为一个内部 View class _ExploreView extends StatefulWidget { + final VoidCallback onGoLive; + const _ExploreView({required this.onGoLive}); + @override _ExploreViewState createState() => _ExploreViewState(); } @@ -96,7 +89,6 @@ class _ExploreViewState extends State<_ExploreView> { Future _refreshRooms({bool isAuto = false}) async { if (!isAuto && mounted) setState(() => _isLoading = true); - final settings = context.read(); final auth = context.read(); final api = ApiService(settings, auth.token); @@ -105,14 +97,10 @@ class _ExploreViewState extends State<_ExploreView> { final response = await api.getActiveRooms(); if (response.statusCode == 200) { final data = jsonDecode(response.body); - if (mounted) { - setState(() => _activeRooms = data['active_rooms'] ?? []); - } + if (mounted) setState(() => _activeRooms = data['active_rooms'] ?? []); } } catch (e) { - if (!isAuto && mounted) { - ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text("Failed to load rooms"))); - } + if (!isAuto && mounted) ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text("Failed to load rooms"))); } finally { if (!isAuto && mounted) setState(() => _isLoading = false); } @@ -124,16 +112,17 @@ class _ExploreViewState extends State<_ExploreView> { return Scaffold( appBar: AppBar( - title: Text("Hightube Live", style: TextStyle(fontWeight: FontWeight.bold)), - centerTitle: true, + title: Text("Explore", style: TextStyle(fontWeight: FontWeight.bold)), actions: [ IconButton(icon: Icon(Icons.refresh), onPressed: () => _refreshRooms()), - IconButton( - icon: Icon(Icons.logout), - onPressed: () => context.read().logout(), - ), + IconButton(icon: Icon(Icons.logout), onPressed: () => context.read().logout()), ], ), + floatingActionButton: FloatingActionButton.extended( + onPressed: widget.onGoLive, + label: Text("Go Live"), + icon: Icon(Icons.videocam), + ), body: RefreshIndicator( onRefresh: _refreshRooms, child: _isLoading && _activeRooms.isEmpty @@ -178,15 +167,7 @@ class _ExploreViewState extends State<_ExploreView> { child: InkWell( onTap: () { final rtmpUrl = "${settings.rtmpUrl}/${room['room_id']}"; - Navigator.push( - context, - MaterialPageRoute( - builder: (_) => PlayerPage( - title: room['title'], - rtmpUrl: rtmpUrl, - ), - ), - ); + Navigator.push(context, MaterialPageRoute(builder: (_) => PlayerPage(title: room['title'], rtmpUrl: rtmpUrl))); }, child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, diff --git a/frontend/lib/pages/login_page.dart b/frontend/lib/pages/login_page.dart index 7b3381a..c83f62c 100644 --- a/frontend/lib/pages/login_page.dart +++ b/frontend/lib/pages/login_page.dart @@ -5,6 +5,7 @@ import '../providers/auth_provider.dart'; import '../providers/settings_provider.dart'; import '../services/api_service.dart'; import 'register_page.dart'; +import 'settings_page.dart'; class LoginPage extends StatefulWidget { @override @@ -17,17 +18,18 @@ class _LoginPageState extends State { bool _isLoading = false; void _handleLogin() async { + if (_usernameController.text.isEmpty || _passwordController.text.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text("Please fill in all fields"))); + return; + } + setState(() => _isLoading = true); final settings = context.read(); final auth = context.read(); 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']); @@ -36,29 +38,89 @@ class _LoginPageState extends State { ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(error))); } } catch (e) { - ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text("Network Error"))); + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text("Network Error: Could not connect to server"))); } finally { - setState(() => _isLoading = false); + if (mounted) setState(() => _isLoading = false); } } @override Widget build(BuildContext context) { return Scaffold( - appBar: AppBar(title: Text("Login")), - body: Padding( - padding: const EdgeInsets.all(16.0), - child: Column( - children: [ - TextField(controller: _usernameController, decoration: InputDecoration(labelText: "Username")), - TextField(controller: _passwordController, decoration: InputDecoration(labelText: "Password"), obscureText: true), - SizedBox(height: 20), - _isLoading ? CircularProgressIndicator() : ElevatedButton(onPressed: _handleLogin, child: Text("Login")), - TextButton( - onPressed: () => Navigator.push(context, MaterialPageRoute(builder: (_) => RegisterPage())), - child: Text("Don't have an account? Register"), + appBar: AppBar( + actions: [ + IconButton( + icon: Icon(Icons.settings), + onPressed: () => Navigator.push(context, MaterialPageRoute(builder: (_) => SettingsPage())), + ), + ], + ), + body: Center( + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 32.0), + child: ConstrainedBox( + constraints: BoxConstraints(maxWidth: 400), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + // Logo & Name + Icon(Icons.flutter_dash, size: 80, color: Theme.of(context).colorScheme.primary), + SizedBox(height: 16), + Text( + "HIGHTUBE", + style: TextStyle( + fontSize: 32, + fontWeight: FontWeight.bold, + letterSpacing: 4, + color: Theme.of(context).colorScheme.primary, + ), + ), + 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)), + ), + ), + SizedBox(height: 16), + TextField( + controller: _passwordController, + obscureText: true, + decoration: InputDecoration( + labelText: "Password", + prefixIcon: Icon(Icons.lock), + border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)), + ), + ), + SizedBox(height: 32), + + // Login Button + SizedBox( + width: double.infinity, + height: 50, + child: ElevatedButton( + onPressed: _isLoading ? null : _handleLogin, + style: ElevatedButton.styleFrom( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + ), + child: _isLoading ? CircularProgressIndicator() : Text("LOGIN", style: TextStyle(fontWeight: FontWeight.bold)), + ), + ), + SizedBox(height: 16), + + // Register Link + TextButton( + onPressed: () => Navigator.push(context, MaterialPageRoute(builder: (_) => RegisterPage())), + child: Text("Don't have an account? Create one"), + ), + ], ), - ], + ), ), ), ); diff --git a/frontend/lib/pages/register_page.dart b/frontend/lib/pages/register_page.dart index 4d2cedf..2b2902a 100644 --- a/frontend/lib/pages/register_page.dart +++ b/frontend/lib/pages/register_page.dart @@ -15,18 +15,19 @@ class _RegisterPageState extends State { bool _isLoading = false; void _handleRegister() async { + if (_usernameController.text.isEmpty || _passwordController.text.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text("Please fill in all fields"))); + return; + } + setState(() => _isLoading = true); final settings = context.read(); final api = ApiService(settings, null); try { - final response = await api.register( - _usernameController.text, - _passwordController.text, - ); - + final response = await api.register(_usernameController.text, _passwordController.text); if (response.statusCode == 201) { - ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text("Registered! Please login."))); + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text("Account created! Please login."))); Navigator.pop(context); } else { final error = jsonDecode(response.body)['error'] ?? "Registration Failed"; @@ -35,23 +36,67 @@ class _RegisterPageState extends State { } catch (e) { ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text("Network Error"))); } finally { - setState(() => _isLoading = false); + if (mounted) setState(() => _isLoading = false); } } @override Widget build(BuildContext context) { return Scaffold( - appBar: AppBar(title: Text("Register")), - body: Padding( - padding: const EdgeInsets.all(16.0), - child: Column( - children: [ - TextField(controller: _usernameController, decoration: InputDecoration(labelText: "Username")), - TextField(controller: _passwordController, decoration: InputDecoration(labelText: "Password"), obscureText: true), - SizedBox(height: 20), - _isLoading ? CircularProgressIndicator() : ElevatedButton(onPressed: _handleRegister, child: Text("Register")), - ], + appBar: AppBar(title: Text("Create Account")), + body: Center( + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 32.0), + child: ConstrainedBox( + constraints: BoxConstraints(maxWidth: 400), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.person_add_outlined, size: 64, color: Theme.of(context).colorScheme.primary), + SizedBox(height: 24), + Text( + "Join Hightube", + style: Theme.of(context).textTheme.headlineMedium?.copyWith(fontWeight: FontWeight.bold), + ), + SizedBox(height: 48), + TextField( + controller: _usernameController, + decoration: InputDecoration( + labelText: "Desired Username", + prefixIcon: Icon(Icons.person), + border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)), + ), + ), + SizedBox(height: 16), + TextField( + controller: _passwordController, + obscureText: true, + decoration: InputDecoration( + labelText: "Password", + prefixIcon: Icon(Icons.lock), + border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)), + ), + ), + SizedBox(height: 32), + SizedBox( + width: double.infinity, + height: 50, + child: ElevatedButton( + onPressed: _isLoading ? null : _handleRegister, + style: ElevatedButton.styleFrom( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + ), + child: _isLoading ? CircularProgressIndicator() : Text("REGISTER", style: TextStyle(fontWeight: FontWeight.bold)), + ), + ), + SizedBox(height: 16), + TextButton( + onPressed: () => Navigator.pop(context), + child: Text("Already have an account? Login here"), + ), + ], + ), + ), ), ), ); diff --git a/frontend/lib/pages/settings_page.dart b/frontend/lib/pages/settings_page.dart index 84bf404..1ff9c62 100644 --- a/frontend/lib/pages/settings_page.dart +++ b/frontend/lib/pages/settings_page.dart @@ -8,38 +8,72 @@ class SettingsPage extends StatefulWidget { } class _SettingsPageState extends State { - final _urlController = TextEditingController(); + late TextEditingController _urlController; @override void initState() { super.initState(); - _urlController.text = context.read().baseUrl; + _urlController = TextEditingController(text: context.read().baseUrl); } @override Widget build(BuildContext context) { return Scaffold( - appBar: AppBar(title: Text("Server Settings")), - body: Padding( - padding: const EdgeInsets.all(16.0), + appBar: AppBar(title: Text("Settings", style: TextStyle(fontWeight: FontWeight.bold))), + body: SingleChildScrollView( + padding: const EdgeInsets.all(24.0), child: Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ + Text( + "Network Configuration", + style: Theme.of(context).textTheme.titleMedium?.copyWith( + color: Theme.of(context).colorScheme.primary, + fontWeight: FontWeight.bold, + ), + ), + SizedBox(height: 16), TextField( controller: _urlController, decoration: InputDecoration( - labelText: "Backend URL (e.g., http://127.0.0.1:8080)", + labelText: "Backend Server URL", + hintText: "http://127.0.0.1:8080", + prefixIcon: Icon(Icons.lan), + border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)), + helperText: "Restarting stream may be required after change", ), ), - SizedBox(height: 20), - ElevatedButton( - onPressed: () { - context.read().setBaseUrl(_urlController.text); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text("Server URL Updated")), - ); - }, - child: Text("Save Settings"), + SizedBox(height: 24), + SizedBox( + width: double.infinity, + height: 50, + child: ElevatedButton.icon( + onPressed: () { + 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 Configuration"), + style: ElevatedButton.styleFrom( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + ), + ), ), + SizedBox(height: 40), + Divider(), + SizedBox(height: 20), + Text( + "About Hightube", + style: TextStyle(fontWeight: FontWeight.bold, color: Colors.grey), + ), + SizedBox(height: 10), + Text("Version: 1.0.0-MVP"), + Text("Status: Phase 3.5 (UI Refinement)"), ], ), ),