feat(frontend): add multi-language support (en, zh-Hans, zh-Hant, ja)
This commit is contained in:
@@ -2,6 +2,7 @@ import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../l10n/app_localizations.dart';
|
||||
import '../providers/auth_provider.dart';
|
||||
import '../providers/settings_provider.dart';
|
||||
import '../services/api_service.dart';
|
||||
@@ -22,6 +23,7 @@ class _HomePageState extends State<HomePage> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
bool isWide = MediaQuery.of(context).size.width > 600;
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
|
||||
final List<Widget> pages = [
|
||||
_ExploreView(onGoLive: () => setState(() => _selectedIndex = 1)),
|
||||
@@ -38,18 +40,18 @@ class _HomePageState extends State<HomePage> {
|
||||
onDestinationSelected: (int index) =>
|
||||
setState(() => _selectedIndex = index),
|
||||
labelType: NavigationRailLabelType.all,
|
||||
destinations: const [
|
||||
destinations: [
|
||||
NavigationRailDestination(
|
||||
icon: Icon(Icons.explore),
|
||||
label: Text('Explore'),
|
||||
icon: const Icon(Icons.explore),
|
||||
label: Text(l10n.explore),
|
||||
),
|
||||
NavigationRailDestination(
|
||||
icon: Icon(Icons.videocam),
|
||||
label: Text('Console'),
|
||||
icon: const Icon(Icons.videocam),
|
||||
label: Text(l10n.console),
|
||||
),
|
||||
NavigationRailDestination(
|
||||
icon: Icon(Icons.settings),
|
||||
label: Text('Settings'),
|
||||
icon: const Icon(Icons.settings),
|
||||
label: Text(l10n.settings),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -61,18 +63,18 @@ class _HomePageState extends State<HomePage> {
|
||||
selectedIndex: _selectedIndex,
|
||||
onDestinationSelected: (int index) =>
|
||||
setState(() => _selectedIndex = index),
|
||||
destinations: const [
|
||||
destinations: [
|
||||
NavigationDestination(
|
||||
icon: Icon(Icons.explore),
|
||||
label: 'Explore',
|
||||
icon: const Icon(Icons.explore),
|
||||
label: l10n.explore,
|
||||
),
|
||||
NavigationDestination(
|
||||
icon: Icon(Icons.videocam),
|
||||
label: 'Console',
|
||||
icon: const Icon(Icons.videocam),
|
||||
label: l10n.console,
|
||||
),
|
||||
NavigationDestination(
|
||||
icon: Icon(Icons.settings),
|
||||
label: 'Settings',
|
||||
icon: const Icon(Icons.settings),
|
||||
label: l10n.settings,
|
||||
),
|
||||
],
|
||||
)
|
||||
@@ -131,9 +133,10 @@ class _ExploreViewState extends State<_ExploreView> {
|
||||
}
|
||||
} catch (e) {
|
||||
if (!isAuto && mounted) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text("Failed to load rooms")));
|
||||
).showSnackBar(SnackBar(content: Text(l10n?.failedToLoadRooms ?? "Failed to load rooms")));
|
||||
}
|
||||
} finally {
|
||||
if (!isAuto && mounted) setState(() => _isLoading = false);
|
||||
@@ -141,20 +144,21 @@ class _ExploreViewState extends State<_ExploreView> {
|
||||
}
|
||||
|
||||
Future<void> _confirmLogout() async {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) {
|
||||
return AlertDialog(
|
||||
title: const Text('Confirm Logout'),
|
||||
content: const Text('Are you sure you want to log out now?'),
|
||||
title: Text(l10n.confirmLogout),
|
||||
content: Text(l10n.confirmLogoutDesc),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, false),
|
||||
child: const Text('Cancel'),
|
||||
child: Text(l10n.cancel),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.pop(context, true),
|
||||
child: const Text('Logout'),
|
||||
child: Text(l10n.logout),
|
||||
),
|
||||
],
|
||||
);
|
||||
@@ -169,44 +173,45 @@ class _ExploreViewState extends State<_ExploreView> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final settings = context.watch<SettingsProvider>();
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text("Explore", style: TextStyle(fontWeight: FontWeight.bold)),
|
||||
title: Text(l10n.explore, style: const TextStyle(fontWeight: FontWeight.bold)),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: Icon(Icons.refresh),
|
||||
icon: const Icon(Icons.refresh),
|
||||
onPressed: () => _refreshRooms(),
|
||||
),
|
||||
IconButton(icon: Icon(Icons.logout), onPressed: _confirmLogout),
|
||||
IconButton(icon: const Icon(Icons.logout), onPressed: _confirmLogout),
|
||||
],
|
||||
),
|
||||
floatingActionButton: FloatingActionButton.extended(
|
||||
onPressed: widget.onGoLive,
|
||||
label: Text("Go Live"),
|
||||
icon: Icon(Icons.videocam),
|
||||
label: Text(l10n.goLive),
|
||||
icon: const Icon(Icons.videocam),
|
||||
),
|
||||
body: RefreshIndicator(
|
||||
onRefresh: _refreshRooms,
|
||||
child: _isLoading && _activeRooms.isEmpty
|
||||
? Center(child: CircularProgressIndicator())
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: _activeRooms.isEmpty
|
||||
? ListView(
|
||||
children: [
|
||||
Padding(
|
||||
padding: EdgeInsets.only(top: 100),
|
||||
padding: const EdgeInsets.only(top: 100),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
const Icon(
|
||||
Icons.live_tv_outlined,
|
||||
size: 80,
|
||||
color: Colors.grey,
|
||||
),
|
||||
SizedBox(height: 16),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
"No active rooms. Be the first!",
|
||||
style: TextStyle(color: Colors.grey, fontSize: 16),
|
||||
l10n.noActiveRooms,
|
||||
style: const TextStyle(color: Colors.grey, fontSize: 16),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -214,8 +219,8 @@ class _ExploreViewState extends State<_ExploreView> {
|
||||
],
|
||||
)
|
||||
: GridView.builder(
|
||||
padding: EdgeInsets.all(12),
|
||||
gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent(
|
||||
padding: const EdgeInsets.all(12),
|
||||
gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
|
||||
maxCrossAxisExtent: 400,
|
||||
childAspectRatio: 1.2,
|
||||
crossAxisSpacing: 12,
|
||||
@@ -224,14 +229,14 @@ class _ExploreViewState extends State<_ExploreView> {
|
||||
itemCount: _activeRooms.length,
|
||||
itemBuilder: (context, index) {
|
||||
final room = _activeRooms[index];
|
||||
return _buildRoomCard(room, settings);
|
||||
return _buildRoomCard(room, settings, l10n);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildRoomCard(dynamic room, SettingsProvider settings) {
|
||||
Widget _buildRoomCard(dynamic room, SettingsProvider settings, AppLocalizations l10n) {
|
||||
final roomId = room['room_id'].toString();
|
||||
return Card(
|
||||
elevation: 4,
|
||||
@@ -294,12 +299,12 @@ class _ExploreViewState extends State<_ExploreView> {
|
||||
top: 8,
|
||||
left: 8,
|
||||
child: Container(
|
||||
padding: EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.red,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Row(
|
||||
child: const Row(
|
||||
children: [
|
||||
Icon(Icons.circle, size: 8, color: Colors.white),
|
||||
SizedBox(width: 4),
|
||||
@@ -331,7 +336,7 @@ class _ExploreViewState extends State<_ExploreView> {
|
||||
radius: 16,
|
||||
child: Text(room['user_id'].toString().substring(0, 1)),
|
||||
),
|
||||
SizedBox(width: 12),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
@@ -341,14 +346,14 @@ class _ExploreViewState extends State<_ExploreView> {
|
||||
room['title'],
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
"Host ID: ${room['user_id']}",
|
||||
style: TextStyle(fontSize: 12, color: Colors.grey),
|
||||
"${l10n.hostId}: ${room['user_id']}",
|
||||
style: const TextStyle(fontSize: 12, color: Colors.grey),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'dart:convert';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../l10n/app_localizations.dart';
|
||||
import '../providers/auth_provider.dart';
|
||||
import '../providers/settings_provider.dart';
|
||||
import '../services/api_service.dart';
|
||||
@@ -29,10 +30,11 @@ class _LoginPageState extends State<LoginPage> {
|
||||
}
|
||||
|
||||
void _handleLogin() async {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
if (_usernameController.text.isEmpty || _passwordController.text.isEmpty) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text("Please fill in all fields")));
|
||||
).showSnackBar(SnackBar(content: Text(l10n.fillAllFields)));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -53,7 +55,7 @@ class _LoginPageState extends State<LoginPage> {
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
final error = jsonDecode(response.body)['error'] ?? "Login Failed";
|
||||
final error = jsonDecode(response.body)['error'] ?? l10n.loginFailed;
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(error)));
|
||||
@@ -63,7 +65,7 @@ class _LoginPageState extends State<LoginPage> {
|
||||
return;
|
||||
}
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text("Network Error: Could not connect to server")),
|
||||
SnackBar(content: Text(l10n.networkError)),
|
||||
);
|
||||
} finally {
|
||||
if (mounted) setState(() => _isLoading = false);
|
||||
@@ -72,11 +74,13 @@ class _LoginPageState extends State<LoginPage> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: Icon(Icons.settings),
|
||||
icon: const Icon(Icons.settings),
|
||||
onPressed: () => Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(builder: (_) => const SettingsPage()),
|
||||
@@ -88,7 +92,7 @@ class _LoginPageState extends State<LoginPage> {
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 32.0),
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(maxWidth: 400),
|
||||
constraints: const BoxConstraints(maxWidth: 400),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
@@ -98,7 +102,7 @@ class _LoginPageState extends State<LoginPage> {
|
||||
size: 80,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
SizedBox(height: 16),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
"HIGHTUBE",
|
||||
style: TextStyle(
|
||||
@@ -108,11 +112,11 @@ class _LoginPageState extends State<LoginPage> {
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
const Text(
|
||||
"Open Source Live Platform",
|
||||
style: TextStyle(color: Colors.grey),
|
||||
),
|
||||
SizedBox(height: 48),
|
||||
const SizedBox(height: 48),
|
||||
|
||||
// Fields
|
||||
TextField(
|
||||
@@ -120,14 +124,14 @@ class _LoginPageState extends State<LoginPage> {
|
||||
textInputAction: TextInputAction.next,
|
||||
onSubmitted: (_) => _passwordFocusNode.requestFocus(),
|
||||
decoration: InputDecoration(
|
||||
labelText: "Username",
|
||||
prefixIcon: Icon(Icons.person),
|
||||
labelText: l10n.username,
|
||||
prefixIcon: const Icon(Icons.person),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
),
|
||||
),
|
||||
SizedBox(height: 16),
|
||||
const SizedBox(height: 16),
|
||||
TextField(
|
||||
controller: _passwordController,
|
||||
focusNode: _passwordFocusNode,
|
||||
@@ -139,14 +143,14 @@ class _LoginPageState extends State<LoginPage> {
|
||||
}
|
||||
},
|
||||
decoration: InputDecoration(
|
||||
labelText: "Password",
|
||||
prefixIcon: Icon(Icons.lock),
|
||||
labelText: l10n.password,
|
||||
prefixIcon: const Icon(Icons.lock),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
),
|
||||
),
|
||||
SizedBox(height: 32),
|
||||
const SizedBox(height: 32),
|
||||
|
||||
// Login Button
|
||||
SizedBox(
|
||||
@@ -160,14 +164,14 @@ class _LoginPageState extends State<LoginPage> {
|
||||
),
|
||||
),
|
||||
child: _isLoading
|
||||
? CircularProgressIndicator()
|
||||
? const CircularProgressIndicator()
|
||||
: Text(
|
||||
"LOGIN",
|
||||
style: TextStyle(fontWeight: FontWeight.bold),
|
||||
l10n.login,
|
||||
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
),
|
||||
SizedBox(height: 16),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Register Link
|
||||
TextButton(
|
||||
@@ -175,7 +179,7 @@ class _LoginPageState extends State<LoginPage> {
|
||||
context,
|
||||
MaterialPageRoute(builder: (_) => RegisterPage()),
|
||||
),
|
||||
child: Text("Don't have an account? Create one"),
|
||||
child: Text(l10n.dontHaveAccount),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'dart:convert';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import '../l10n/app_localizations.dart';
|
||||
import '../providers/auth_provider.dart';
|
||||
import '../providers/settings_provider.dart';
|
||||
import '../services/api_service.dart';
|
||||
@@ -42,9 +43,10 @@ class _MyStreamPageState extends State<MyStreamPage> {
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
final l10n = AppLocalizations.of(context);
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text("Failed to fetch room info")));
|
||||
).showSnackBar(SnackBar(content: Text(l10n?.failedToFetchRoomInfo ?? "Failed to fetch room info")));
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() => _isLoading = false);
|
||||
@@ -55,46 +57,47 @@ class _MyStreamPageState extends State<MyStreamPage> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final settings = context.watch<SettingsProvider>();
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: Text("My Stream Console")),
|
||||
appBar: AppBar(title: Text(l10n.myStreamConsole)),
|
||||
body: _isLoading
|
||||
? Center(child: CircularProgressIndicator())
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: _roomInfo == null
|
||||
? Center(child: Text("No room info found."))
|
||||
? Center(child: Text(l10n.noRoomInfo))
|
||||
: SingleChildScrollView(
|
||||
padding: EdgeInsets.all(20),
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_buildInfoCard(
|
||||
title: "Room Title",
|
||||
title: l10n.roomTitle,
|
||||
value: _roomInfo!['title'],
|
||||
icon: Icons.edit,
|
||||
onTap: () {
|
||||
// TODO: Implement title update API later
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text("Title editing coming soon!")),
|
||||
const SnackBar(content: Text("Title editing coming soon!")),
|
||||
);
|
||||
},
|
||||
),
|
||||
SizedBox(height: 20),
|
||||
const SizedBox(height: 20),
|
||||
_buildInfoCard(
|
||||
title: "RTMP Server URL",
|
||||
title: l10n.rtmpServerUrl,
|
||||
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"),
|
||||
content: Text(l10n.copiedToClipboard),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
SizedBox(height: 20),
|
||||
const SizedBox(height: 20),
|
||||
_buildInfoCard(
|
||||
title: "Stream Key (Keep Secret!)",
|
||||
title: l10n.streamKey,
|
||||
value: _roomInfo!['stream_key'],
|
||||
icon: Icons.copy,
|
||||
isSecret: true,
|
||||
@@ -104,18 +107,18 @@ class _MyStreamPageState extends State<MyStreamPage> {
|
||||
);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text("Stream Key copied to clipboard"),
|
||||
content: Text(l10n.copiedToClipboard),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
SizedBox(height: 24),
|
||||
const SizedBox(height: 24),
|
||||
AndroidQuickStreamPanel(
|
||||
rtmpBaseUrl: settings.rtmpUrl,
|
||||
streamKey: _roomInfo!['stream_key'],
|
||||
),
|
||||
SizedBox(height: 30),
|
||||
Center(
|
||||
const SizedBox(height: 30),
|
||||
const Center(
|
||||
child: Column(
|
||||
children: [
|
||||
Icon(Icons.info_outline, color: Colors.grey),
|
||||
@@ -143,10 +146,10 @@ class _MyStreamPageState extends State<MyStreamPage> {
|
||||
}) {
|
||||
return Card(
|
||||
child: ListTile(
|
||||
title: Text(title, style: TextStyle(fontSize: 12, color: Colors.grey)),
|
||||
title: Text(title, style: const TextStyle(fontSize: 12, color: Colors.grey)),
|
||||
subtitle: Text(
|
||||
isSecret ? "••••••••••••••••" : value,
|
||||
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16),
|
||||
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16),
|
||||
),
|
||||
trailing: IconButton(icon: Icon(icon), onPressed: onTap),
|
||||
),
|
||||
|
||||
@@ -7,6 +7,7 @@ import 'package:flutter/services.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:video_player/video_player.dart';
|
||||
|
||||
import '../l10n/app_localizations.dart';
|
||||
import '../providers/auth_provider.dart';
|
||||
import '../providers/settings_provider.dart';
|
||||
import '../services/api_service.dart';
|
||||
@@ -271,6 +272,7 @@ class _PlayerPageState extends State<PlayerPage> {
|
||||
|
||||
Future<void> _openVolumeSheet() async {
|
||||
_showControls();
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
await showModalBottomSheet<void>(
|
||||
context: context,
|
||||
builder: (context) {
|
||||
@@ -284,7 +286,7 @@ class _PlayerPageState extends State<PlayerPage> {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Volume',
|
||||
l10n.volume,
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
@@ -353,6 +355,7 @@ class _PlayerPageState extends State<PlayerPage> {
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
|
||||
final nextResolution = await showModalBottomSheet<String>(
|
||||
context: context,
|
||||
@@ -364,11 +367,11 @@ class _PlayerPageState extends State<PlayerPage> {
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
ListTile(
|
||||
title: Text('Playback Resolution'),
|
||||
title: Text(l10n.playbackResolution),
|
||||
subtitle: Text(
|
||||
available.length > 1
|
||||
? 'Select an available transcoded stream.'
|
||||
: 'Only the source stream is available right now.',
|
||||
? l10n.playbackOptionsDesc
|
||||
: l10n.sourceOnlyDesc,
|
||||
),
|
||||
),
|
||||
...options.map((option) {
|
||||
@@ -382,8 +385,8 @@ class _PlayerPageState extends State<PlayerPage> {
|
||||
),
|
||||
title: Text(option),
|
||||
subtitle: enabled
|
||||
? const Text('Available now')
|
||||
: const Text('Waiting for backend transcoding output'),
|
||||
? Text(l10n.availableNow)
|
||||
: Text(l10n.waitingForTranscoding),
|
||||
onTap: enabled ? () => Navigator.pop(context, option) : null,
|
||||
);
|
||||
}),
|
||||
@@ -590,6 +593,7 @@ class _PlayerPageState extends State<PlayerPage> {
|
||||
}
|
||||
|
||||
Widget _buildPlaybackControls() {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
return IgnorePointer(
|
||||
ignoring: !_controlsVisible,
|
||||
child: AnimatedOpacity(
|
||||
@@ -620,7 +624,7 @@ class _PlayerPageState extends State<PlayerPage> {
|
||||
children: [
|
||||
_buildControlButton(
|
||||
icon: Icons.refresh,
|
||||
label: "Refresh",
|
||||
label: l10n.refresh,
|
||||
onPressed: _refreshPlayer,
|
||||
),
|
||||
_buildControlButton(
|
||||
@@ -629,19 +633,19 @@ class _PlayerPageState extends State<PlayerPage> {
|
||||
: _volume < 0.5
|
||||
? Icons.volume_down
|
||||
: Icons.volume_up,
|
||||
label: "Volume",
|
||||
label: l10n.volume,
|
||||
onPressed: _openVolumeSheet,
|
||||
),
|
||||
_buildControlButton(
|
||||
icon: _showDanmaku ? Icons.subtitles : Icons.subtitles_off,
|
||||
label: _showDanmaku ? "Danmaku On" : "Danmaku Off",
|
||||
label: _showDanmaku ? l10n.danmakuOn : l10n.danmakuOff,
|
||||
onPressed: _toggleDanmaku,
|
||||
),
|
||||
_buildControlButton(
|
||||
icon: _isFullscreen
|
||||
? Icons.fullscreen_exit
|
||||
: Icons.fullscreen,
|
||||
label: _isFullscreen ? "Exit Fullscreen" : "Fullscreen",
|
||||
label: _isFullscreen ? l10n.exitFullscreen : l10n.fullscreen,
|
||||
onPressed: _toggleFullscreen,
|
||||
),
|
||||
_buildControlButton(
|
||||
@@ -679,23 +683,24 @@ class _PlayerPageState extends State<PlayerPage> {
|
||||
|
||||
// 抽离聊天区域组件
|
||||
Widget _buildChatSection() {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
return Column(
|
||||
children: [
|
||||
Container(
|
||||
padding: EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
color: Theme.of(context).colorScheme.surfaceContainerHighest,
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.chat_bubble_outline, size: 16),
|
||||
SizedBox(width: 8),
|
||||
Text("Live Chat", style: TextStyle(fontWeight: FontWeight.bold)),
|
||||
const Icon(Icons.chat_bubble_outline, size: 16),
|
||||
const SizedBox(width: 8),
|
||||
Text(l10n.liveChat, style: const TextStyle(fontWeight: FontWeight.bold)),
|
||||
],
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: ListView.builder(
|
||||
reverse: true,
|
||||
padding: EdgeInsets.all(8),
|
||||
padding: const EdgeInsets.all(8),
|
||||
itemCount: _messages.length,
|
||||
itemBuilder: (context, index) {
|
||||
final m = _messages[index];
|
||||
@@ -703,7 +708,7 @@ class _PlayerPageState extends State<PlayerPage> {
|
||||
},
|
||||
),
|
||||
),
|
||||
Divider(height: 1),
|
||||
const Divider(height: 1),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: Row(
|
||||
@@ -712,11 +717,11 @@ class _PlayerPageState extends State<PlayerPage> {
|
||||
child: TextField(
|
||||
controller: _msgController,
|
||||
decoration: InputDecoration(
|
||||
hintText: "Send a message...",
|
||||
hintText: l10n.sendMessage,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
contentPadding: EdgeInsets.symmetric(
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 8,
|
||||
),
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'dart:convert';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../l10n/app_localizations.dart';
|
||||
import '../providers/settings_provider.dart';
|
||||
import '../services/api_service.dart';
|
||||
|
||||
@@ -15,8 +16,9 @@ class _RegisterPageState extends State<RegisterPage> {
|
||||
bool _isLoading = false;
|
||||
|
||||
void _handleRegister() async {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
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(l10n.fillAllFields)));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -27,14 +29,14 @@ class _RegisterPageState extends State<RegisterPage> {
|
||||
try {
|
||||
final response = await api.register(_usernameController.text, _passwordController.text);
|
||||
if (response.statusCode == 201) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text("Account created! Please login.")));
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(l10n.accountCreated)));
|
||||
Navigator.pop(context);
|
||||
} else {
|
||||
final error = jsonDecode(response.body)['error'] ?? "Registration Failed";
|
||||
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(l10n.networkError)));
|
||||
} finally {
|
||||
if (mounted) setState(() => _isLoading = false);
|
||||
}
|
||||
@@ -42,42 +44,43 @@ class _RegisterPageState extends State<RegisterPage> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: Text("Create Account")),
|
||||
appBar: AppBar(title: Text(l10n.createAccount)),
|
||||
body: Center(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 32.0),
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(maxWidth: 400),
|
||||
constraints: const 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),
|
||||
const SizedBox(height: 24),
|
||||
Text(
|
||||
"Join Hightube",
|
||||
l10n.joinHightube,
|
||||
style: Theme.of(context).textTheme.headlineMedium?.copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
SizedBox(height: 48),
|
||||
const SizedBox(height: 48),
|
||||
TextField(
|
||||
controller: _usernameController,
|
||||
decoration: InputDecoration(
|
||||
labelText: "Desired Username",
|
||||
prefixIcon: Icon(Icons.person),
|
||||
labelText: l10n.desiredUsername,
|
||||
prefixIcon: const Icon(Icons.person),
|
||||
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)),
|
||||
),
|
||||
),
|
||||
SizedBox(height: 16),
|
||||
const SizedBox(height: 16),
|
||||
TextField(
|
||||
controller: _passwordController,
|
||||
obscureText: true,
|
||||
decoration: InputDecoration(
|
||||
labelText: "Password",
|
||||
prefixIcon: Icon(Icons.lock),
|
||||
labelText: l10n.password,
|
||||
prefixIcon: const Icon(Icons.lock),
|
||||
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)),
|
||||
),
|
||||
),
|
||||
SizedBox(height: 32),
|
||||
const SizedBox(height: 32),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
height: 50,
|
||||
@@ -86,13 +89,13 @@ class _RegisterPageState extends State<RegisterPage> {
|
||||
style: ElevatedButton.styleFrom(
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
),
|
||||
child: _isLoading ? CircularProgressIndicator() : Text("REGISTER", style: TextStyle(fontWeight: FontWeight.bold)),
|
||||
child: _isLoading ? const CircularProgressIndicator() : Text(l10n.register, style: const TextStyle(fontWeight: FontWeight.bold)),
|
||||
),
|
||||
),
|
||||
SizedBox(height: 16),
|
||||
const SizedBox(height: 16),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: Text("Already have an account? Login here"),
|
||||
child: Text(l10n.alreadyHaveAccount),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'dart:convert';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../l10n/app_localizations.dart';
|
||||
|
||||
import '../providers/auth_provider.dart';
|
||||
import '../providers/settings_provider.dart';
|
||||
@@ -88,21 +89,21 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _confirmLogout(AuthProvider auth) async {
|
||||
Future<void> _confirmLogout(AuthProvider auth, AppLocalizations l10n) async {
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) {
|
||||
return AlertDialog(
|
||||
title: const Text("Confirm Logout"),
|
||||
content: const Text("Are you sure you want to log out now?"),
|
||||
title: Text(l10n.confirmLogout),
|
||||
content: Text(l10n.confirmLogoutDesc),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, false),
|
||||
child: const Text("Cancel"),
|
||||
child: Text(l10n.cancel),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.pop(context, true),
|
||||
child: const Text("Logout"),
|
||||
child: Text(l10n.logout),
|
||||
),
|
||||
],
|
||||
);
|
||||
@@ -119,10 +120,11 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||
final auth = context.watch<AuthProvider>();
|
||||
final settings = context.watch<SettingsProvider>();
|
||||
final isAuthenticated = auth.isAuthenticated;
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text("Settings", style: TextStyle(fontWeight: FontWeight.bold)),
|
||||
title: Text(l10n.settings, style: TextStyle(fontWeight: FontWeight.bold)),
|
||||
centerTitle: true,
|
||||
),
|
||||
body: SingleChildScrollView(
|
||||
@@ -134,12 +136,58 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||
_buildProfileSection(auth),
|
||||
const SizedBox(height: 32),
|
||||
],
|
||||
_buildSectionTitle("Network Configuration"),
|
||||
|
||||
_buildSectionTitle(l10n.language),
|
||||
const SizedBox(height: 16),
|
||||
DropdownButtonFormField<Locale?>(
|
||||
initialValue: settings.locale == null
|
||||
? null
|
||||
: AppLocalizations.supportedLocales.cast<Locale?>().firstWhere(
|
||||
(l) => l?.languageCode == settings.locale?.languageCode &&
|
||||
l?.scriptCode == settings.locale?.scriptCode,
|
||||
orElse: () => null,
|
||||
),
|
||||
decoration: InputDecoration(
|
||||
labelText: l10n.selectLanguage,
|
||||
prefixIcon: const Icon(Icons.language),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
),
|
||||
items: [
|
||||
DropdownMenuItem(
|
||||
value: null,
|
||||
child: Text(l10n.system),
|
||||
),
|
||||
DropdownMenuItem(
|
||||
value: const Locale('en'),
|
||||
child: Text(l10n.english),
|
||||
),
|
||||
DropdownMenuItem(
|
||||
value: const Locale('zh'),
|
||||
child: Text(l10n.simplifiedChinese),
|
||||
),
|
||||
DropdownMenuItem(
|
||||
value: const Locale.fromSubtags(languageCode: 'zh', scriptCode: 'Hant'),
|
||||
child: Text(l10n.traditionalChinese),
|
||||
),
|
||||
DropdownMenuItem(
|
||||
value: const Locale('ja'),
|
||||
child: Text(l10n.japanese),
|
||||
),
|
||||
],
|
||||
onChanged: (Locale? newLocale) {
|
||||
settings.setLocale(newLocale);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
|
||||
_buildSectionTitle(l10n.networkConfiguration),
|
||||
const SizedBox(height: 16),
|
||||
TextField(
|
||||
controller: _urlController,
|
||||
decoration: InputDecoration(
|
||||
labelText: "Backend Server URL",
|
||||
labelText: l10n.backendServerUrl,
|
||||
hintText: "http://127.0.0.1:8080",
|
||||
prefixIcon: Icon(Icons.lan),
|
||||
border: OutlineInputBorder(
|
||||
@@ -157,13 +205,13 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||
);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text("Server URL Updated"),
|
||||
content: Text(l10n.serverUrlUpdated),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
),
|
||||
);
|
||||
},
|
||||
icon: Icon(Icons.save),
|
||||
label: Text("Save Network Settings"),
|
||||
label: Text(l10n.saveNetworkSettings),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Theme.of(
|
||||
context,
|
||||
@@ -179,28 +227,28 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
|
||||
_buildSectionTitle("Theme Customization"),
|
||||
_buildSectionTitle(l10n.themeCustomization),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
"Appearance Mode",
|
||||
l10n.appearanceMode,
|
||||
style: Theme.of(context).textTheme.labelLarge,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
SegmentedButton<ThemeMode>(
|
||||
segments: const [
|
||||
segments: [
|
||||
ButtonSegment<ThemeMode>(
|
||||
value: ThemeMode.system,
|
||||
label: Text("System"),
|
||||
label: Text(l10n.system),
|
||||
icon: Icon(Icons.brightness_auto),
|
||||
),
|
||||
ButtonSegment<ThemeMode>(
|
||||
value: ThemeMode.light,
|
||||
label: Text("Light"),
|
||||
label: Text(l10n.light),
|
||||
icon: Icon(Icons.light_mode),
|
||||
),
|
||||
ButtonSegment<ThemeMode>(
|
||||
value: ThemeMode.dark,
|
||||
label: Text("Dark"),
|
||||
label: Text(l10n.dark),
|
||||
icon: Icon(Icons.dark_mode),
|
||||
),
|
||||
],
|
||||
@@ -210,7 +258,7 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
Text("Accent Color", style: Theme.of(context).textTheme.labelLarge),
|
||||
Text(l10n.accentColor, style: Theme.of(context).textTheme.labelLarge),
|
||||
const SizedBox(height: 12),
|
||||
Wrap(
|
||||
spacing: 12,
|
||||
@@ -248,26 +296,26 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||
}).toList(),
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
_buildSectionTitle("Explore"),
|
||||
_buildSectionTitle(l10n.explore),
|
||||
const SizedBox(height: 8),
|
||||
SwitchListTile.adaptive(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
title: const Text("Live Preview Thumbnails"),
|
||||
subtitle: const Text(
|
||||
"Show cached snapshot covers for live rooms when available.",
|
||||
title: Text(l10n.livePreviewThumbnails),
|
||||
subtitle: Text(
|
||||
l10n.livePreviewThumbnailsDesc,
|
||||
),
|
||||
value: settings.livePreviewThumbnailsEnabled,
|
||||
onChanged: settings.setLivePreviewThumbnailsEnabled,
|
||||
),
|
||||
if (isAuthenticated) ...[
|
||||
const SizedBox(height: 32),
|
||||
_buildSectionTitle("Security"),
|
||||
_buildSectionTitle(l10n.security),
|
||||
const SizedBox(height: 16),
|
||||
TextField(
|
||||
controller: _oldPasswordController,
|
||||
obscureText: true,
|
||||
decoration: InputDecoration(
|
||||
labelText: "Old Password",
|
||||
labelText: l10n.oldPassword,
|
||||
prefixIcon: const Icon(Icons.lock_outline),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
@@ -279,7 +327,7 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||
controller: _newPasswordController,
|
||||
obscureText: true,
|
||||
decoration: InputDecoration(
|
||||
labelText: "New Password",
|
||||
labelText: l10n.newPassword,
|
||||
prefixIcon: const Icon(Icons.lock_reset),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
@@ -292,7 +340,7 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: _handleChangePassword,
|
||||
icon: const Icon(Icons.update),
|
||||
label: const Text("Change Password"),
|
||||
label: Text(l10n.changePassword),
|
||||
style: OutlinedButton.styleFrom(
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
@@ -304,9 +352,9 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: FilledButton.tonalIcon(
|
||||
onPressed: () => _confirmLogout(auth),
|
||||
onPressed: () => _confirmLogout(auth, l10n),
|
||||
icon: const Icon(Icons.logout),
|
||||
label: const Text("Logout"),
|
||||
label: Text(l10n.logout),
|
||||
style: FilledButton.styleFrom(
|
||||
foregroundColor: Colors.redAccent,
|
||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||
|
||||
Reference in New Issue
Block a user