Phase 3.5: Finalized UI polish, added Console, and fixed stale stream status bug
This commit is contained in:
@@ -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.")
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}()
|
||||
|
||||
|
||||
@@ -17,49 +17,40 @@ class HomePage extends StatefulWidget {
|
||||
class _HomePageState extends State<HomePage> {
|
||||
int _selectedIndex = 0;
|
||||
|
||||
// 根据当前选择的索引返回对应的页面
|
||||
final List<Widget> _pages = [
|
||||
_ExploreView(),
|
||||
MyStreamPage(),
|
||||
SettingsPage(),
|
||||
];
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// 检查是否为桌面/宽屏环境
|
||||
bool isWide = MediaQuery.of(context).size.width > 600;
|
||||
|
||||
final List<Widget> _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<HomePage> {
|
||||
}
|
||||
}
|
||||
|
||||
// 将原本的直播列表逻辑封装为一个内部 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<void> _refreshRooms({bool isAuto = false}) async {
|
||||
if (!isAuto && mounted) setState(() => _isLoading = true);
|
||||
|
||||
final settings = context.read<SettingsProvider>();
|
||||
final auth = context.read<AuthProvider>();
|
||||
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<AuthProvider>().logout(),
|
||||
),
|
||||
IconButton(icon: Icon(Icons.logout), onPressed: () => context.read<AuthProvider>().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,
|
||||
|
||||
@@ -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<LoginPage> {
|
||||
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<SettingsProvider>();
|
||||
final auth = context.read<AuthProvider>();
|
||||
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<LoginPage> {
|
||||
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"),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -15,18 +15,19 @@ class _RegisterPageState extends State<RegisterPage> {
|
||||
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<SettingsProvider>();
|
||||
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<RegisterPage> {
|
||||
} 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"),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -8,38 +8,72 @@ class SettingsPage extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _SettingsPageState extends State<SettingsPage> {
|
||||
final _urlController = TextEditingController();
|
||||
late TextEditingController _urlController;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_urlController.text = context.read<SettingsProvider>().baseUrl;
|
||||
_urlController = TextEditingController(text: context.read<SettingsProvider>().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<SettingsProvider>().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<SettingsProvider>().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)"),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user