diff --git a/frontend/lib/main.dart b/frontend/lib/main.dart index 0a651d6..170a973 100644 --- a/frontend/lib/main.dart +++ b/frontend/lib/main.dart @@ -1,13 +1,12 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; -import 'package:fvp/fvp.dart' as fvp; // 使用 as fvp 别名 +import 'package:fvp/fvp.dart' as fvp; import 'providers/auth_provider.dart'; import 'providers/settings_provider.dart'; import 'pages/home_page.dart'; import 'pages/login_page.dart'; void main() { - // 初始化播放器引擎 fvp.registerWith(); runApp( @@ -28,10 +27,22 @@ class HightubeApp extends StatelessWidget { return MaterialApp( title: 'Hightube', + // 设置深色主题为主,更符合视频类应用的审美 theme: ThemeData( - colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple), useMaterial3: true, + colorScheme: ColorScheme.fromSeed( + seedColor: Colors.deepPurple, + brightness: Brightness.light, + ), ), + darkTheme: ThemeData( + useMaterial3: true, + colorScheme: ColorScheme.fromSeed( + seedColor: Colors.deepPurple, + brightness: Brightness.dark, + ), + ), + themeMode: ThemeMode.system, // 跟随系统切换深浅色 home: auth.isAuthenticated ? HomePage() : LoginPage(), debugShowCheckedModeBanner: false, ); diff --git a/frontend/lib/pages/home_page.dart b/frontend/lib/pages/home_page.dart index 70467fb..bba8394 100644 --- a/frontend/lib/pages/home_page.dart +++ b/frontend/lib/pages/home_page.dart @@ -7,6 +7,7 @@ import '../providers/settings_provider.dart'; import '../services/api_service.dart'; import 'settings_page.dart'; import 'player_page.dart'; +import 'my_stream_page.dart'; class HomePage extends StatefulWidget { @override @@ -14,6 +15,66 @@ 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; + + return Scaffold( + body: Row( + children: [ + // 桌面端侧边导航 + if (isWide) + NavigationRail( + selectedIndex: _selectedIndex, + 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.settings), label: Text('Settings')), + ], + ), + // 主内容区 + Expanded(child: _pages[_selectedIndex]), + ], + ), + // 移动端底部导航 + bottomNavigationBar: !isWide + ? NavigationBar( + selectedIndex: _selectedIndex, + 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.settings), label: 'Settings'), + ], + ) + : null, + ); + } +} + +// 将原本的直播列表逻辑封装为一个内部 View +class _ExploreView extends StatefulWidget { + @override + _ExploreViewState createState() => _ExploreViewState(); +} + +class _ExploreViewState extends State<_ExploreView> { List _activeRooms = []; bool _isLoading = false; Timer? _refreshTimer; @@ -22,7 +83,6 @@ class _HomePageState extends State { void initState() { super.initState(); _refreshRooms(); - // 启动自动刷新定时器 (每 10 秒自动更新列表) _refreshTimer = Timer.periodic(Duration(seconds: 10), (timer) { if (mounted) _refreshRooms(isAuto: true); }); @@ -35,7 +95,7 @@ class _HomePageState extends State { } Future _refreshRooms({bool isAuto = false}) async { - if (!isAuto) setState(() => _isLoading = true); + if (!isAuto && mounted) setState(() => _isLoading = true); final settings = context.read(); final auth = context.read(); @@ -64,21 +124,12 @@ class _HomePageState extends State { return Scaffold( appBar: AppBar( - title: Text("Hightube Live"), + title: Text("Hightube Live", style: TextStyle(fontWeight: FontWeight.bold)), + centerTitle: true, actions: [ - IconButton( - icon: Icon(Icons.refresh), - tooltip: "Manual Refresh", - onPressed: () => _refreshRooms(), - ), - IconButton( - icon: Icon(Icons.settings), - tooltip: "Settings", - onPressed: () => Navigator.push(context, MaterialPageRoute(builder: (_) => SettingsPage())), - ), + IconButton(icon: Icon(Icons.refresh), onPressed: () => _refreshRooms()), IconButton( icon: Icon(Icons.logout), - tooltip: "Logout", onPressed: () => context.read().logout(), ), ], @@ -88,33 +139,107 @@ class _HomePageState extends State { child: _isLoading && _activeRooms.isEmpty ? Center(child: CircularProgressIndicator()) : _activeRooms.isEmpty - ? ListView(children: [Padding(padding: EdgeInsets.only(top: 50), child: Center(child: Text("No active rooms. Be the first!")))]) - : ListView.builder( + ? ListView(children: [ + Padding( + padding: EdgeInsets.only(top: 100), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.live_tv_outlined, size: 80, color: Colors.grey), + SizedBox(height: 16), + Text("No active rooms. Be the first!", style: TextStyle(color: Colors.grey, fontSize: 16)), + ], + ), + ) + ]) + : GridView.builder( + padding: EdgeInsets.all(12), + gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent( + maxCrossAxisExtent: 400, + childAspectRatio: 1.2, + crossAxisSpacing: 12, + mainAxisSpacing: 12, + ), itemCount: _activeRooms.length, itemBuilder: (context, index) { final room = _activeRooms[index]; - return ListTile( - leading: CircleAvatar(child: Icon(Icons.live_tv)), - title: Text(room['title']), - subtitle: Text("User ID: ${room['user_id']} (Streaming now)"), - trailing: Icon(Icons.play_circle_filled, color: Colors.blue), - onTap: () { - // 动态构建播放链接:rtmp://{host}:1935/live/{room_id} - final rtmpUrl = "${settings.rtmpUrl}/${room['room_id']}"; - Navigator.push( - context, - MaterialPageRoute( - builder: (_) => PlayerPage( - title: room['title'], - rtmpUrl: rtmpUrl, - ), - ), - ); - }, - ); + return _buildRoomCard(room, settings); }, ), ), ); } + + Widget _buildRoomCard(dynamic room, SettingsProvider settings) { + return Card( + elevation: 4, + clipBehavior: Clip.antiAlias, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + child: InkWell( + onTap: () { + final rtmpUrl = "${settings.rtmpUrl}/${room['room_id']}"; + Navigator.push( + context, + MaterialPageRoute( + builder: (_) => PlayerPage( + title: room['title'], + rtmpUrl: rtmpUrl, + ), + ), + ); + }, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Expanded( + flex: 3, + child: Stack( + fit: StackFit.expand, + children: [ + Container( + color: Theme.of(context).colorScheme.primaryContainer, + child: Center(child: Icon(Icons.live_tv, size: 50, color: Theme.of(context).colorScheme.primary)), + ), + Positioned( + top: 8, left: 8, + child: Container( + padding: EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration(color: Colors.red, borderRadius: BorderRadius.circular(4)), + child: Row(children: [ + Icon(Icons.circle, size: 8, color: Colors.white), + SizedBox(width: 4), + Text("LIVE", style: TextStyle(color: Colors.white, fontSize: 10, fontWeight: FontWeight.bold)), + ]), + ), + ), + ], + ), + ), + Expanded( + flex: 1, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + child: Row( + children: [ + CircleAvatar(radius: 16, child: Text(room['user_id'].toString().substring(0, 1))), + SizedBox(width: 12), + Expanded( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(room['title'], maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle(fontWeight: FontWeight.bold, fontSize: 14)), + Text("Host ID: ${room['user_id']}", style: TextStyle(fontSize: 12, color: Colors.grey)), + ], + ), + ), + ], + ), + ), + ), + ], + ), + ), + ); + } } diff --git a/frontend/lib/pages/my_stream_page.dart b/frontend/lib/pages/my_stream_page.dart new file mode 100644 index 0000000..205b77f --- /dev/null +++ b/frontend/lib/pages/my_stream_page.dart @@ -0,0 +1,125 @@ +import 'dart:convert'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:flutter/services.dart'; +import '../providers/auth_provider.dart'; +import '../providers/settings_provider.dart'; +import '../services/api_service.dart'; + +class MyStreamPage extends StatefulWidget { + @override + _MyStreamPageState createState() => _MyStreamPageState(); +} + +class _MyStreamPageState extends State { + Map? _roomInfo; + bool _isLoading = false; + + @override + void initState() { + super.initState(); + _fetchMyRoom(); + } + + Future _fetchMyRoom() async { + setState(() => _isLoading = true); + final settings = context.read(); + final auth = context.read(); + final api = ApiService(settings, auth.token); + + try { + final response = await api.getMyRoom(); + if (response.statusCode == 200) { + setState(() => _roomInfo = jsonDecode(response.body)); + } + } catch (e) { + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text("Failed to fetch room info"))); + } finally { + setState(() => _isLoading = false); + } + } + + @override + Widget build(BuildContext context) { + final settings = context.watch(); + + return Scaffold( + appBar: AppBar(title: Text("My Stream Console")), + body: _isLoading + ? Center(child: CircularProgressIndicator()) + : _roomInfo == null + ? Center(child: Text("No room info found.")) + : SingleChildScrollView( + padding: EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildInfoCard( + title: "Room Title", + value: _roomInfo!['title'], + icon: Icons.edit, + onTap: () { + // TODO: Implement title update API later + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text("Title editing coming soon!"))); + }, + ), + SizedBox(height: 20), + _buildInfoCard( + title: "RTMP Server URL", + value: settings.rtmpUrl, + icon: Icons.copy, + onTap: () { + Clipboard.setData(ClipboardData(text: settings.rtmpUrl)); + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text("Server URL copied to clipboard"))); + }, + ), + SizedBox(height: 20), + _buildInfoCard( + title: "Stream Key (Keep Secret!)", + value: _roomInfo!['stream_key'], + icon: Icons.copy, + isSecret: true, + onTap: () { + Clipboard.setData(ClipboardData(text: _roomInfo!['stream_key'])); + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text("Stream Key copied to clipboard"))); + }, + ), + SizedBox(height: 30), + Center( + child: Column( + children: [ + Icon(Icons.info_outline, color: Colors.grey), + SizedBox(height: 8), + Text( + "Use OBS or other tools to stream to this address.", + textAlign: TextAlign.center, + style: TextStyle(color: Colors.grey, fontSize: 12), + ), + ], + ), + ), + ], + ), + ), + ); + } + + Widget _buildInfoCard({ + required String title, + required String value, + required IconData icon, + bool isSecret = false, + VoidCallback? onTap, + }) { + return Card( + child: ListTile( + title: Text(title, style: TextStyle(fontSize: 12, color: Colors.grey)), + subtitle: Text( + isSecret ? "••••••••••••••••" : value, + style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16), + ), + trailing: IconButton(icon: Icon(icon), onPressed: onTap), + ), + ); + } +}