feat(frontend): add multi-language support (en, zh-Hans, zh-Hant, ja)

This commit is contained in:
2026-05-25 11:49:53 +08:00
parent 1539e495e6
commit 261b1ab169
20 changed files with 1955 additions and 139 deletions

3
frontend/l10n.yaml Normal file
View File

@@ -0,0 +1,3 @@
arb-dir: lib/l10n
template-arb-file: app_en.arb
output-localization-file: app_localizations.dart

View File

@@ -0,0 +1,70 @@
{
"@@locale": "en",
"settings": "Settings",
"networkConfiguration": "Network Configuration",
"backendServerUrl": "Backend Server URL",
"saveNetworkSettings": "Save Network Settings",
"serverUrlUpdated": "Server URL Updated",
"themeCustomization": "Theme Customization",
"appearanceMode": "Appearance Mode",
"system": "System",
"light": "Light",
"dark": "Dark",
"accentColor": "Accent Color",
"explore": "Explore",
"livePreviewThumbnails": "Live Preview Thumbnails",
"livePreviewThumbnailsDesc": "Show cached snapshot covers for live rooms when available.",
"security": "Security",
"oldPassword": "Old Password",
"newPassword": "New Password",
"changePassword": "Change Password",
"logout": "Logout",
"confirmLogout": "Confirm Logout",
"confirmLogoutDesc": "Are you sure you want to log out now?",
"cancel": "Cancel",
"language": "Language",
"selectLanguage": "Select Language",
"english": "English",
"simplifiedChinese": "简体中文",
"traditionalChinese": "繁體中文",
"japanese": "日本語",
"console": "Console",
"failedToLoadRooms": "Failed to load rooms",
"goLive": "Go Live",
"noActiveRooms": "No active rooms. Be the first!",
"hostId": "Host ID",
"username": "Username",
"password": "Password",
"fillAllFields": "Please fill in all fields",
"networkError": "Network Error: Could not connect to server",
"loginFailed": "Login Failed",
"login": "LOGIN",
"dontHaveAccount": "Don't have an account? Create one",
"createAccount": "Create Account",
"joinHightube": "Join Hightube",
"desiredUsername": "Desired Username",
"register": "REGISTER",
"alreadyHaveAccount": "Already have an account? Login here",
"accountCreated": "Account created! Please login.",
"playbackResolution": "Playback Resolution",
"availableNow": "Available now",
"waitingForTranscoding": "Waiting for backend transcoding output",
"sendMessage": "Send a message...",
"liveChat": "Live Chat",
"refresh": "Refresh",
"volume": "Volume",
"danmakuOn": "Danmaku On",
"danmakuOff": "Danmaku Off",
"fullscreen": "Fullscreen",
"exitFullscreen": "Exit Fullscreen",
"resolution": "Resolution",
"playbackOptionsDesc": "Select an available transcoded stream.",
"sourceOnlyDesc": "Only the source stream is available right now.",
"myStreamConsole": "My Stream Console",
"noRoomInfo": "No room info found.",
"roomTitle": "Room Title",
"rtmpServerUrl": "RTMP Server URL",
"streamKey": "Stream Key (Keep Secret!)",
"copiedToClipboard": "Copied to clipboard",
"failedToFetchRoomInfo": "Failed to fetch room info"
}

View File

@@ -0,0 +1,70 @@
{
"@@locale": "ja",
"settings": "設定",
"networkConfiguration": "ネットワーク設定",
"backendServerUrl": "バックエンドサーバーURL",
"saveNetworkSettings": "ネットワーク設定を保存",
"serverUrlUpdated": "サーバーURLが更新されました",
"themeCustomization": "テーマのカスタマイズ",
"appearanceMode": "外観モード",
"system": "システム",
"light": "ライト",
"dark": "ダーク",
"accentColor": "アクセントカラー",
"explore": "探索",
"livePreviewThumbnails": "ライブプレビューサムネイル",
"livePreviewThumbnailsDesc": "利用可能な場合、ライブルームのキャッシュされたスナップショットカバーを表示します。",
"security": "セキュリティ",
"oldPassword": "現在のパスワード",
"newPassword": "新しいパスワード",
"changePassword": "パスワードを変更",
"logout": "ログアウト",
"confirmLogout": "ログアウトの確認",
"confirmLogoutDesc": "今すぐログアウトしてもよろしいですか?",
"cancel": "キャンセル",
"language": "言語",
"selectLanguage": "言語を選択",
"english": "English",
"simplifiedChinese": "简体中文",
"traditionalChinese": "繁體中文",
"japanese": "日本語",
"console": "コンソール",
"failedToLoadRooms": "ルームの読み込みに失敗しました",
"goLive": "ライブ配信を開始",
"noActiveRooms": "配信中のルームはありません。最初の配信者になりましょう!",
"hostId": "配信者 ID",
"username": "ユーザー名",
"password": "パスワード",
"fillAllFields": "すべての項目を入力してください",
"networkError": "ネットワークエラー:サーバーに接続できませんでした",
"loginFailed": "ログインに失敗しました",
"login": "ログイン",
"dontHaveAccount": "アカウントをお持ちでないですか?新規登録",
"createAccount": "アカウント作成",
"joinHightube": "Hightube に参加",
"desiredUsername": "ユーザー名",
"register": "登録",
"alreadyHaveAccount": "既にアカウントをお持ちですか?ログイン",
"accountCreated": "アカウントが作成されました!ログインしてください。",
"playbackResolution": "再生解像度",
"availableNow": "利用可能",
"waitingForTranscoding": "バックエンドのトランスコード出力を待機中",
"sendMessage": "メッセージを送信...",
"liveChat": "ライブチャット",
"refresh": "更新",
"volume": "音量",
"danmakuOn": "弾幕オン",
"danmakuOff": "弾幕オフ",
"fullscreen": "全画面",
"exitFullscreen": "全画面終了",
"resolution": "解像度",
"playbackOptionsDesc": "利用可能なトランスコード済みストリームを選択します。",
"sourceOnlyDesc": "現在、ソースストリームのみが利用可能です。",
"myStreamConsole": "配信コンソール",
"noRoomInfo": "ルーム情報が見つかりません。",
"roomTitle": "ルームタイトル",
"rtmpServerUrl": "RTMP サーバー URL",
"streamKey": "ストリームキー (秘密にしてください!)",
"copiedToClipboard": "クリップボードにコピーしました",
"failedToFetchRoomInfo": "ルーム情報の取得に失敗しました"
}

View File

@@ -0,0 +1,553 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:intl/intl.dart' as intl;
import 'app_localizations_en.dart';
import 'app_localizations_ja.dart';
import 'app_localizations_zh.dart';
// ignore_for_file: type=lint
/// Callers can lookup localized strings with an instance of AppLocalizations
/// returned by `AppLocalizations.of(context)`.
///
/// Applications need to include `AppLocalizations.delegate()` in their app's
/// `localizationDelegates` list, and the locales they support in the app's
/// `supportedLocales` list. For example:
///
/// ```dart
/// import 'l10n/app_localizations.dart';
///
/// return MaterialApp(
/// localizationsDelegates: AppLocalizations.localizationsDelegates,
/// supportedLocales: AppLocalizations.supportedLocales,
/// home: MyApplicationHome(),
/// );
/// ```
///
/// ## Update pubspec.yaml
///
/// Please make sure to update your pubspec.yaml to include the following
/// packages:
///
/// ```yaml
/// dependencies:
/// # Internationalization support.
/// flutter_localizations:
/// sdk: flutter
/// intl: any # Use the pinned version from flutter_localizations
///
/// # Rest of dependencies
/// ```
///
/// ## iOS Applications
///
/// iOS applications define key application metadata, including supported
/// locales, in an Info.plist file that is built into the application bundle.
/// To configure the locales supported by your app, youll need to edit this
/// file.
///
/// First, open your projects ios/Runner.xcworkspace Xcode workspace file.
/// Then, in the Project Navigator, open the Info.plist file under the Runner
/// projects Runner folder.
///
/// Next, select the Information Property List item, select Add Item from the
/// Editor menu, then select Localizations from the pop-up menu.
///
/// Select and expand the newly-created Localizations item then, for each
/// locale your application supports, add a new item and select the locale
/// you wish to add from the pop-up menu in the Value field. This list should
/// be consistent with the languages listed in the AppLocalizations.supportedLocales
/// property.
abstract class AppLocalizations {
AppLocalizations(String locale)
: localeName = intl.Intl.canonicalizedLocale(locale.toString());
final String localeName;
static AppLocalizations? of(BuildContext context) {
return Localizations.of<AppLocalizations>(context, AppLocalizations);
}
static const LocalizationsDelegate<AppLocalizations> delegate =
_AppLocalizationsDelegate();
/// A list of this localizations delegate along with the default localizations
/// delegates.
///
/// Returns a list of localizations delegates containing this delegate along with
/// GlobalMaterialLocalizations.delegate, GlobalCupertinoLocalizations.delegate,
/// and GlobalWidgetsLocalizations.delegate.
///
/// Additional delegates can be added by appending to this list in
/// MaterialApp. This list does not have to be used at all if a custom list
/// of delegates is preferred or required.
static const List<LocalizationsDelegate<dynamic>> localizationsDelegates =
<LocalizationsDelegate<dynamic>>[
delegate,
GlobalMaterialLocalizations.delegate,
GlobalCupertinoLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
];
/// A list of this localizations delegate's supported locales.
static const List<Locale> supportedLocales = <Locale>[
Locale('en'),
Locale('ja'),
Locale('zh'),
Locale.fromSubtags(languageCode: 'zh', scriptCode: 'Hant'),
];
/// No description provided for @settings.
///
/// In en, this message translates to:
/// **'Settings'**
String get settings;
/// No description provided for @networkConfiguration.
///
/// In en, this message translates to:
/// **'Network Configuration'**
String get networkConfiguration;
/// No description provided for @backendServerUrl.
///
/// In en, this message translates to:
/// **'Backend Server URL'**
String get backendServerUrl;
/// No description provided for @saveNetworkSettings.
///
/// In en, this message translates to:
/// **'Save Network Settings'**
String get saveNetworkSettings;
/// No description provided for @serverUrlUpdated.
///
/// In en, this message translates to:
/// **'Server URL Updated'**
String get serverUrlUpdated;
/// No description provided for @themeCustomization.
///
/// In en, this message translates to:
/// **'Theme Customization'**
String get themeCustomization;
/// No description provided for @appearanceMode.
///
/// In en, this message translates to:
/// **'Appearance Mode'**
String get appearanceMode;
/// No description provided for @system.
///
/// In en, this message translates to:
/// **'System'**
String get system;
/// No description provided for @light.
///
/// In en, this message translates to:
/// **'Light'**
String get light;
/// No description provided for @dark.
///
/// In en, this message translates to:
/// **'Dark'**
String get dark;
/// No description provided for @accentColor.
///
/// In en, this message translates to:
/// **'Accent Color'**
String get accentColor;
/// No description provided for @explore.
///
/// In en, this message translates to:
/// **'Explore'**
String get explore;
/// No description provided for @livePreviewThumbnails.
///
/// In en, this message translates to:
/// **'Live Preview Thumbnails'**
String get livePreviewThumbnails;
/// No description provided for @livePreviewThumbnailsDesc.
///
/// In en, this message translates to:
/// **'Show cached snapshot covers for live rooms when available.'**
String get livePreviewThumbnailsDesc;
/// No description provided for @security.
///
/// In en, this message translates to:
/// **'Security'**
String get security;
/// No description provided for @oldPassword.
///
/// In en, this message translates to:
/// **'Old Password'**
String get oldPassword;
/// No description provided for @newPassword.
///
/// In en, this message translates to:
/// **'New Password'**
String get newPassword;
/// No description provided for @changePassword.
///
/// In en, this message translates to:
/// **'Change Password'**
String get changePassword;
/// No description provided for @logout.
///
/// In en, this message translates to:
/// **'Logout'**
String get logout;
/// No description provided for @confirmLogout.
///
/// In en, this message translates to:
/// **'Confirm Logout'**
String get confirmLogout;
/// No description provided for @confirmLogoutDesc.
///
/// In en, this message translates to:
/// **'Are you sure you want to log out now?'**
String get confirmLogoutDesc;
/// No description provided for @cancel.
///
/// In en, this message translates to:
/// **'Cancel'**
String get cancel;
/// No description provided for @language.
///
/// In en, this message translates to:
/// **'Language'**
String get language;
/// No description provided for @selectLanguage.
///
/// In en, this message translates to:
/// **'Select Language'**
String get selectLanguage;
/// No description provided for @english.
///
/// In en, this message translates to:
/// **'English'**
String get english;
/// No description provided for @simplifiedChinese.
///
/// In en, this message translates to:
/// **'简体中文'**
String get simplifiedChinese;
/// No description provided for @traditionalChinese.
///
/// In en, this message translates to:
/// **'繁體中文'**
String get traditionalChinese;
/// No description provided for @japanese.
///
/// In en, this message translates to:
/// **'日本語'**
String get japanese;
/// No description provided for @console.
///
/// In en, this message translates to:
/// **'Console'**
String get console;
/// No description provided for @failedToLoadRooms.
///
/// In en, this message translates to:
/// **'Failed to load rooms'**
String get failedToLoadRooms;
/// No description provided for @goLive.
///
/// In en, this message translates to:
/// **'Go Live'**
String get goLive;
/// No description provided for @noActiveRooms.
///
/// In en, this message translates to:
/// **'No active rooms. Be the first!'**
String get noActiveRooms;
/// No description provided for @hostId.
///
/// In en, this message translates to:
/// **'Host ID'**
String get hostId;
/// No description provided for @username.
///
/// In en, this message translates to:
/// **'Username'**
String get username;
/// No description provided for @password.
///
/// In en, this message translates to:
/// **'Password'**
String get password;
/// No description provided for @fillAllFields.
///
/// In en, this message translates to:
/// **'Please fill in all fields'**
String get fillAllFields;
/// No description provided for @networkError.
///
/// In en, this message translates to:
/// **'Network Error: Could not connect to server'**
String get networkError;
/// No description provided for @loginFailed.
///
/// In en, this message translates to:
/// **'Login Failed'**
String get loginFailed;
/// No description provided for @login.
///
/// In en, this message translates to:
/// **'LOGIN'**
String get login;
/// No description provided for @dontHaveAccount.
///
/// In en, this message translates to:
/// **'Don\'t have an account? Create one'**
String get dontHaveAccount;
/// No description provided for @createAccount.
///
/// In en, this message translates to:
/// **'Create Account'**
String get createAccount;
/// No description provided for @joinHightube.
///
/// In en, this message translates to:
/// **'Join Hightube'**
String get joinHightube;
/// No description provided for @desiredUsername.
///
/// In en, this message translates to:
/// **'Desired Username'**
String get desiredUsername;
/// No description provided for @register.
///
/// In en, this message translates to:
/// **'REGISTER'**
String get register;
/// No description provided for @alreadyHaveAccount.
///
/// In en, this message translates to:
/// **'Already have an account? Login here'**
String get alreadyHaveAccount;
/// No description provided for @accountCreated.
///
/// In en, this message translates to:
/// **'Account created! Please login.'**
String get accountCreated;
/// No description provided for @playbackResolution.
///
/// In en, this message translates to:
/// **'Playback Resolution'**
String get playbackResolution;
/// No description provided for @availableNow.
///
/// In en, this message translates to:
/// **'Available now'**
String get availableNow;
/// No description provided for @waitingForTranscoding.
///
/// In en, this message translates to:
/// **'Waiting for backend transcoding output'**
String get waitingForTranscoding;
/// No description provided for @sendMessage.
///
/// In en, this message translates to:
/// **'Send a message...'**
String get sendMessage;
/// No description provided for @liveChat.
///
/// In en, this message translates to:
/// **'Live Chat'**
String get liveChat;
/// No description provided for @refresh.
///
/// In en, this message translates to:
/// **'Refresh'**
String get refresh;
/// No description provided for @volume.
///
/// In en, this message translates to:
/// **'Volume'**
String get volume;
/// No description provided for @danmakuOn.
///
/// In en, this message translates to:
/// **'Danmaku On'**
String get danmakuOn;
/// No description provided for @danmakuOff.
///
/// In en, this message translates to:
/// **'Danmaku Off'**
String get danmakuOff;
/// No description provided for @fullscreen.
///
/// In en, this message translates to:
/// **'Fullscreen'**
String get fullscreen;
/// No description provided for @exitFullscreen.
///
/// In en, this message translates to:
/// **'Exit Fullscreen'**
String get exitFullscreen;
/// No description provided for @resolution.
///
/// In en, this message translates to:
/// **'Resolution'**
String get resolution;
/// No description provided for @playbackOptionsDesc.
///
/// In en, this message translates to:
/// **'Select an available transcoded stream.'**
String get playbackOptionsDesc;
/// No description provided for @sourceOnlyDesc.
///
/// In en, this message translates to:
/// **'Only the source stream is available right now.'**
String get sourceOnlyDesc;
/// No description provided for @myStreamConsole.
///
/// In en, this message translates to:
/// **'My Stream Console'**
String get myStreamConsole;
/// No description provided for @noRoomInfo.
///
/// In en, this message translates to:
/// **'No room info found.'**
String get noRoomInfo;
/// No description provided for @roomTitle.
///
/// In en, this message translates to:
/// **'Room Title'**
String get roomTitle;
/// No description provided for @rtmpServerUrl.
///
/// In en, this message translates to:
/// **'RTMP Server URL'**
String get rtmpServerUrl;
/// No description provided for @streamKey.
///
/// In en, this message translates to:
/// **'Stream Key (Keep Secret!)'**
String get streamKey;
/// No description provided for @copiedToClipboard.
///
/// In en, this message translates to:
/// **'Copied to clipboard'**
String get copiedToClipboard;
/// No description provided for @failedToFetchRoomInfo.
///
/// In en, this message translates to:
/// **'Failed to fetch room info'**
String get failedToFetchRoomInfo;
}
class _AppLocalizationsDelegate
extends LocalizationsDelegate<AppLocalizations> {
const _AppLocalizationsDelegate();
@override
Future<AppLocalizations> load(Locale locale) {
return SynchronousFuture<AppLocalizations>(lookupAppLocalizations(locale));
}
@override
bool isSupported(Locale locale) =>
<String>['en', 'ja', 'zh'].contains(locale.languageCode);
@override
bool shouldReload(_AppLocalizationsDelegate old) => false;
}
AppLocalizations lookupAppLocalizations(Locale locale) {
// Lookup logic when language+script codes are specified.
switch (locale.languageCode) {
case 'zh':
{
switch (locale.scriptCode) {
case 'Hant':
return AppLocalizationsZhHant();
}
break;
}
}
// Lookup logic when only language code is specified.
switch (locale.languageCode) {
case 'en':
return AppLocalizationsEn();
case 'ja':
return AppLocalizationsJa();
case 'zh':
return AppLocalizationsZh();
}
throw FlutterError(
'AppLocalizations.delegate failed to load unsupported locale "$locale". This is likely '
'an issue with the localizations generation tool. Please file an issue '
'on GitHub with a reproducible sample app and the gen-l10n configuration '
'that was used.',
);
}

View File

@@ -0,0 +1,212 @@
// ignore: unused_import
import 'package:intl/intl.dart' as intl;
import 'app_localizations.dart';
// ignore_for_file: type=lint
/// The translations for English (`en`).
class AppLocalizationsEn extends AppLocalizations {
AppLocalizationsEn([String locale = 'en']) : super(locale);
@override
String get settings => 'Settings';
@override
String get networkConfiguration => 'Network Configuration';
@override
String get backendServerUrl => 'Backend Server URL';
@override
String get saveNetworkSettings => 'Save Network Settings';
@override
String get serverUrlUpdated => 'Server URL Updated';
@override
String get themeCustomization => 'Theme Customization';
@override
String get appearanceMode => 'Appearance Mode';
@override
String get system => 'System';
@override
String get light => 'Light';
@override
String get dark => 'Dark';
@override
String get accentColor => 'Accent Color';
@override
String get explore => 'Explore';
@override
String get livePreviewThumbnails => 'Live Preview Thumbnails';
@override
String get livePreviewThumbnailsDesc =>
'Show cached snapshot covers for live rooms when available.';
@override
String get security => 'Security';
@override
String get oldPassword => 'Old Password';
@override
String get newPassword => 'New Password';
@override
String get changePassword => 'Change Password';
@override
String get logout => 'Logout';
@override
String get confirmLogout => 'Confirm Logout';
@override
String get confirmLogoutDesc => 'Are you sure you want to log out now?';
@override
String get cancel => 'Cancel';
@override
String get language => 'Language';
@override
String get selectLanguage => 'Select Language';
@override
String get english => 'English';
@override
String get simplifiedChinese => '简体中文';
@override
String get traditionalChinese => '繁體中文';
@override
String get japanese => '日本語';
@override
String get console => 'Console';
@override
String get failedToLoadRooms => 'Failed to load rooms';
@override
String get goLive => 'Go Live';
@override
String get noActiveRooms => 'No active rooms. Be the first!';
@override
String get hostId => 'Host ID';
@override
String get username => 'Username';
@override
String get password => 'Password';
@override
String get fillAllFields => 'Please fill in all fields';
@override
String get networkError => 'Network Error: Could not connect to server';
@override
String get loginFailed => 'Login Failed';
@override
String get login => 'LOGIN';
@override
String get dontHaveAccount => 'Don\'t have an account? Create one';
@override
String get createAccount => 'Create Account';
@override
String get joinHightube => 'Join Hightube';
@override
String get desiredUsername => 'Desired Username';
@override
String get register => 'REGISTER';
@override
String get alreadyHaveAccount => 'Already have an account? Login here';
@override
String get accountCreated => 'Account created! Please login.';
@override
String get playbackResolution => 'Playback Resolution';
@override
String get availableNow => 'Available now';
@override
String get waitingForTranscoding => 'Waiting for backend transcoding output';
@override
String get sendMessage => 'Send a message...';
@override
String get liveChat => 'Live Chat';
@override
String get refresh => 'Refresh';
@override
String get volume => 'Volume';
@override
String get danmakuOn => 'Danmaku On';
@override
String get danmakuOff => 'Danmaku Off';
@override
String get fullscreen => 'Fullscreen';
@override
String get exitFullscreen => 'Exit Fullscreen';
@override
String get resolution => 'Resolution';
@override
String get playbackOptionsDesc => 'Select an available transcoded stream.';
@override
String get sourceOnlyDesc => 'Only the source stream is available right now.';
@override
String get myStreamConsole => 'My Stream Console';
@override
String get noRoomInfo => 'No room info found.';
@override
String get roomTitle => 'Room Title';
@override
String get rtmpServerUrl => 'RTMP Server URL';
@override
String get streamKey => 'Stream Key (Keep Secret!)';
@override
String get copiedToClipboard => 'Copied to clipboard';
@override
String get failedToFetchRoomInfo => 'Failed to fetch room info';
}

View File

@@ -0,0 +1,212 @@
// ignore: unused_import
import 'package:intl/intl.dart' as intl;
import 'app_localizations.dart';
// ignore_for_file: type=lint
/// The translations for Japanese (`ja`).
class AppLocalizationsJa extends AppLocalizations {
AppLocalizationsJa([String locale = 'ja']) : super(locale);
@override
String get settings => '設定';
@override
String get networkConfiguration => 'ネットワーク設定';
@override
String get backendServerUrl => 'バックエンドサーバーURL';
@override
String get saveNetworkSettings => 'ネットワーク設定を保存';
@override
String get serverUrlUpdated => 'サーバーURLが更新されました';
@override
String get themeCustomization => 'テーマのカスタマイズ';
@override
String get appearanceMode => '外観モード';
@override
String get system => 'システム';
@override
String get light => 'ライト';
@override
String get dark => 'ダーク';
@override
String get accentColor => 'アクセントカラー';
@override
String get explore => '探索';
@override
String get livePreviewThumbnails => 'ライブプレビューサムネイル';
@override
String get livePreviewThumbnailsDesc =>
'利用可能な場合、ライブルームのキャッシュされたスナップショットカバーを表示します。';
@override
String get security => 'セキュリティ';
@override
String get oldPassword => '現在のパスワード';
@override
String get newPassword => '新しいパスワード';
@override
String get changePassword => 'パスワードを変更';
@override
String get logout => 'ログアウト';
@override
String get confirmLogout => 'ログアウトの確認';
@override
String get confirmLogoutDesc => '今すぐログアウトしてもよろしいですか?';
@override
String get cancel => 'キャンセル';
@override
String get language => '言語';
@override
String get selectLanguage => '言語を選択';
@override
String get english => 'English';
@override
String get simplifiedChinese => '简体中文';
@override
String get traditionalChinese => '繁體中文';
@override
String get japanese => '日本語';
@override
String get console => 'コンソール';
@override
String get failedToLoadRooms => 'ルームの読み込みに失敗しました';
@override
String get goLive => 'ライブ配信を開始';
@override
String get noActiveRooms => '配信中のルームはありません。最初の配信者になりましょう!';
@override
String get hostId => '配信者 ID';
@override
String get username => 'ユーザー名';
@override
String get password => 'パスワード';
@override
String get fillAllFields => 'すべての項目を入力してください';
@override
String get networkError => 'ネットワークエラー:サーバーに接続できませんでした';
@override
String get loginFailed => 'ログインに失敗しました';
@override
String get login => 'ログイン';
@override
String get dontHaveAccount => 'アカウントをお持ちでないですか?新規登録';
@override
String get createAccount => 'アカウント作成';
@override
String get joinHightube => 'Hightube に参加';
@override
String get desiredUsername => 'ユーザー名';
@override
String get register => '登録';
@override
String get alreadyHaveAccount => '既にアカウントをお持ちですか?ログイン';
@override
String get accountCreated => 'アカウントが作成されました!ログインしてください。';
@override
String get playbackResolution => '再生解像度';
@override
String get availableNow => '利用可能';
@override
String get waitingForTranscoding => 'バックエンドのトランスコード出力を待機中';
@override
String get sendMessage => 'メッセージを送信...';
@override
String get liveChat => 'ライブチャット';
@override
String get refresh => '更新';
@override
String get volume => '音量';
@override
String get danmakuOn => '弾幕オン';
@override
String get danmakuOff => '弾幕オフ';
@override
String get fullscreen => '全画面';
@override
String get exitFullscreen => '全画面終了';
@override
String get resolution => '解像度';
@override
String get playbackOptionsDesc => '利用可能なトランスコード済みストリームを選択します。';
@override
String get sourceOnlyDesc => '現在、ソースストリームのみが利用可能です。';
@override
String get myStreamConsole => '配信コンソール';
@override
String get noRoomInfo => 'ルーム情報が見つかりません。';
@override
String get roomTitle => 'ルームタイトル';
@override
String get rtmpServerUrl => 'RTMP サーバー URL';
@override
String get streamKey => 'ストリームキー (秘密にしてください!)';
@override
String get copiedToClipboard => 'クリップボードにコピーしました';
@override
String get failedToFetchRoomInfo => 'ルーム情報の取得に失敗しました';
}

View File

@@ -0,0 +1,417 @@
// ignore: unused_import
import 'package:intl/intl.dart' as intl;
import 'app_localizations.dart';
// ignore_for_file: type=lint
/// The translations for Chinese (`zh`).
class AppLocalizationsZh extends AppLocalizations {
AppLocalizationsZh([String locale = 'zh']) : super(locale);
@override
String get settings => '设置';
@override
String get networkConfiguration => '网络配置';
@override
String get backendServerUrl => '后端服务器地址';
@override
String get saveNetworkSettings => '保存网络设置';
@override
String get serverUrlUpdated => '服务器地址已更新';
@override
String get themeCustomization => '主题自定义';
@override
String get appearanceMode => '外观模式';
@override
String get system => '系统';
@override
String get light => '浅色';
@override
String get dark => '深色';
@override
String get accentColor => '强调色';
@override
String get explore => '探索';
@override
String get livePreviewThumbnails => '直播预览图';
@override
String get livePreviewThumbnailsDesc => '在可用时显示直播房间的缓存快照封面。';
@override
String get security => '安全';
@override
String get oldPassword => '旧密码';
@override
String get newPassword => '新密码';
@override
String get changePassword => '修改密码';
@override
String get logout => '退出登录';
@override
String get confirmLogout => '确认退出';
@override
String get confirmLogoutDesc => '您确定现在要退出登录吗?';
@override
String get cancel => '取消';
@override
String get language => '语言';
@override
String get selectLanguage => '选择语言';
@override
String get english => 'English';
@override
String get simplifiedChinese => '简体中文';
@override
String get traditionalChinese => '繁體中文';
@override
String get japanese => '日本語';
@override
String get console => '控制台';
@override
String get failedToLoadRooms => '加载房间失败';
@override
String get goLive => '开始直播';
@override
String get noActiveRooms => '暂无直播房间。快来开播吧!';
@override
String get hostId => '主播 ID';
@override
String get username => '用户名';
@override
String get password => '密码';
@override
String get fillAllFields => '请填写所有字段';
@override
String get networkError => '网络错误:无法连接到服务器';
@override
String get loginFailed => '登录失败';
@override
String get login => '登录';
@override
String get dontHaveAccount => '没有账号?立即注册';
@override
String get createAccount => '创建账号';
@override
String get joinHightube => '加入 Hightube';
@override
String get desiredUsername => '用户名';
@override
String get register => '注册';
@override
String get alreadyHaveAccount => '已有账号?立即登录';
@override
String get accountCreated => '账号创建成功!请登录。';
@override
String get playbackResolution => '播放分辨率';
@override
String get availableNow => '当前可用';
@override
String get waitingForTranscoding => '正在等待后端转码输出';
@override
String get sendMessage => '发送消息...';
@override
String get liveChat => '实时聊天';
@override
String get refresh => '刷新';
@override
String get volume => '音量';
@override
String get danmakuOn => '弹幕开启';
@override
String get danmakuOff => '弹幕关闭';
@override
String get fullscreen => '全屏';
@override
String get exitFullscreen => '退出全屏';
@override
String get resolution => '分辨率';
@override
String get playbackOptionsDesc => '选择可用的转码流。';
@override
String get sourceOnlyDesc => '目前仅源流可用。';
@override
String get myStreamConsole => '我的直播控制台';
@override
String get noRoomInfo => '未找到房间信息。';
@override
String get roomTitle => '房间标题';
@override
String get rtmpServerUrl => 'RTMP 服务器地址';
@override
String get streamKey => '推流码 (请务必保密!)';
@override
String get copiedToClipboard => '已复制到剪贴板';
@override
String get failedToFetchRoomInfo => '获取房间信息失败';
}
/// The translations for Chinese, using the Han script (`zh_Hant`).
class AppLocalizationsZhHant extends AppLocalizationsZh {
AppLocalizationsZhHant() : super('zh_Hant');
@override
String get settings => '設定';
@override
String get networkConfiguration => '網路設定';
@override
String get backendServerUrl => '後端伺服器地址';
@override
String get saveNetworkSettings => '儲存網路設定';
@override
String get serverUrlUpdated => '伺服器地址已更新';
@override
String get themeCustomization => '主題自訂';
@override
String get appearanceMode => '外觀模式';
@override
String get system => '系統';
@override
String get light => '淺色';
@override
String get dark => '深色';
@override
String get accentColor => '強調色';
@override
String get explore => '探索';
@override
String get livePreviewThumbnails => '直播預覽圖';
@override
String get livePreviewThumbnailsDesc => '在可用時顯示直播房間的快取快照封面。';
@override
String get security => '安全';
@override
String get oldPassword => '舊密碼';
@override
String get newPassword => '新密碼';
@override
String get changePassword => '修改密碼';
@override
String get logout => '登出';
@override
String get confirmLogout => '確認登出';
@override
String get confirmLogoutDesc => '您確定現在要登出嗎?';
@override
String get cancel => '取消';
@override
String get language => '語言';
@override
String get selectLanguage => '選擇語言';
@override
String get english => 'English';
@override
String get simplifiedChinese => '简体中文';
@override
String get traditionalChinese => '繁體中文';
@override
String get japanese => '日本語';
@override
String get console => '控制台';
@override
String get failedToLoadRooms => '載入房間失敗';
@override
String get goLive => '開始直播';
@override
String get noActiveRooms => '暫無直播房間。快來開播吧!';
@override
String get hostId => '主播 ID';
@override
String get username => '用戶名';
@override
String get password => '密碼';
@override
String get fillAllFields => '請填寫所有欄位';
@override
String get networkError => '網路錯誤:無法連接到伺服器';
@override
String get loginFailed => '登錄失敗';
@override
String get login => '登錄';
@override
String get dontHaveAccount => '沒有帳號?立即註冊';
@override
String get createAccount => '建立帳號';
@override
String get joinHightube => '加入 Hightube';
@override
String get desiredUsername => '用戶名';
@override
String get register => '註冊';
@override
String get alreadyHaveAccount => '已有帳號?立即登錄';
@override
String get accountCreated => '帳號建立成功!請登錄。';
@override
String get playbackResolution => '播放解析度';
@override
String get availableNow => '目前可用';
@override
String get waitingForTranscoding => '正在等待後端轉碼輸出';
@override
String get sendMessage => '發送訊息...';
@override
String get liveChat => '即時聊天';
@override
String get refresh => '重新整理';
@override
String get volume => '音量';
@override
String get danmakuOn => '彈幕開啟';
@override
String get danmakuOff => '彈幕關閉';
@override
String get fullscreen => '全屏';
@override
String get exitFullscreen => '退出全屏';
@override
String get resolution => '解析度';
@override
String get playbackOptionsDesc => '選擇可用的轉碼流。';
@override
String get sourceOnlyDesc => '目前僅源流可用。';
@override
String get myStreamConsole => '我的直播控制台';
@override
String get noRoomInfo => '未找到房間資訊。';
@override
String get roomTitle => '房間標題';
@override
String get rtmpServerUrl => 'RTMP 伺服器地址';
@override
String get streamKey => '推流碼 (請務必保密!)';
@override
String get copiedToClipboard => '已複製到剪貼板';
@override
String get failedToFetchRoomInfo => '獲取房間資訊失敗';
}

View File

@@ -0,0 +1,70 @@
{
"@@locale": "zh",
"settings": "设置",
"networkConfiguration": "网络配置",
"backendServerUrl": "后端服务器地址",
"saveNetworkSettings": "保存网络设置",
"serverUrlUpdated": "服务器地址已更新",
"themeCustomization": "主题自定义",
"appearanceMode": "外观模式",
"system": "系统",
"light": "浅色",
"dark": "深色",
"accentColor": "强调色",
"explore": "探索",
"livePreviewThumbnails": "直播预览图",
"livePreviewThumbnailsDesc": "在可用时显示直播房间的缓存快照封面。",
"security": "安全",
"oldPassword": "旧密码",
"newPassword": "新密码",
"changePassword": "修改密码",
"logout": "退出登录",
"confirmLogout": "确认退出",
"confirmLogoutDesc": "您确定现在要退出登录吗?",
"cancel": "取消",
"language": "语言",
"selectLanguage": "选择语言",
"english": "English",
"simplifiedChinese": "简体中文",
"traditionalChinese": "繁體中文",
"japanese": "日本語",
"console": "控制台",
"failedToLoadRooms": "加载房间失败",
"goLive": "开始直播",
"noActiveRooms": "暂无直播房间。快来开播吧!",
"hostId": "主播 ID",
"username": "用户名",
"password": "密码",
"fillAllFields": "请填写所有字段",
"networkError": "网络错误:无法连接到服务器",
"loginFailed": "登录失败",
"login": "登录",
"dontHaveAccount": "没有账号?立即注册",
"createAccount": "创建账号",
"joinHightube": "加入 Hightube",
"desiredUsername": "用户名",
"register": "注册",
"alreadyHaveAccount": "已有账号?立即登录",
"accountCreated": "账号创建成功!请登录。",
"playbackResolution": "播放分辨率",
"availableNow": "当前可用",
"waitingForTranscoding": "正在等待后端转码输出",
"sendMessage": "发送消息...",
"liveChat": "实时聊天",
"refresh": "刷新",
"volume": "音量",
"danmakuOn": "弹幕开启",
"danmakuOff": "弹幕关闭",
"fullscreen": "全屏",
"exitFullscreen": "退出全屏",
"resolution": "分辨率",
"playbackOptionsDesc": "选择可用的转码流。",
"sourceOnlyDesc": "目前仅源流可用。",
"myStreamConsole": "我的直播控制台",
"noRoomInfo": "未找到房间信息。",
"roomTitle": "房间标题",
"rtmpServerUrl": "RTMP 服务器地址",
"streamKey": "推流码 (请务必保密!)",
"copiedToClipboard": "已复制到剪贴板",
"failedToFetchRoomInfo": "获取房间信息失败"
}

View File

@@ -0,0 +1,70 @@
{
"@@locale": "zh_Hant",
"settings": "設定",
"networkConfiguration": "網路設定",
"backendServerUrl": "後端伺服器地址",
"saveNetworkSettings": "儲存網路設定",
"serverUrlUpdated": "伺服器地址已更新",
"themeCustomization": "主題自訂",
"appearanceMode": "外觀模式",
"system": "系統",
"light": "淺色",
"dark": "深色",
"accentColor": "強調色",
"explore": "探索",
"livePreviewThumbnails": "直播預覽圖",
"livePreviewThumbnailsDesc": "在可用時顯示直播房間的快取快照封面。",
"security": "安全",
"oldPassword": "舊密碼",
"newPassword": "新密碼",
"changePassword": "修改密碼",
"logout": "登出",
"confirmLogout": "確認登出",
"confirmLogoutDesc": "您確定現在要登出嗎?",
"cancel": "取消",
"language": "語言",
"selectLanguage": "選擇語言",
"english": "English",
"simplifiedChinese": "简体中文",
"traditionalChinese": "繁體中文",
"japanese": "日本語",
"console": "控制台",
"failedToLoadRooms": "載入房間失敗",
"goLive": "開始直播",
"noActiveRooms": "暫無直播房間。快來開播吧!",
"hostId": "主播 ID",
"username": "用戶名",
"password": "密碼",
"fillAllFields": "請填寫所有欄位",
"networkError": "網路錯誤:無法連接到伺服器",
"loginFailed": "登錄失敗",
"login": "登錄",
"dontHaveAccount": "沒有帳號?立即註冊",
"createAccount": "建立帳號",
"joinHightube": "加入 Hightube",
"desiredUsername": "用戶名",
"register": "註冊",
"alreadyHaveAccount": "已有帳號?立即登錄",
"accountCreated": "帳號建立成功!請登錄。",
"playbackResolution": "播放解析度",
"availableNow": "目前可用",
"waitingForTranscoding": "正在等待後端轉碼輸出",
"sendMessage": "發送訊息...",
"liveChat": "即時聊天",
"refresh": "重新整理",
"volume": "音量",
"danmakuOn": "彈幕開啟",
"danmakuOff": "彈幕關閉",
"fullscreen": "全屏",
"exitFullscreen": "退出全屏",
"resolution": "解析度",
"playbackOptionsDesc": "選擇可用的轉碼流。",
"sourceOnlyDesc": "目前僅源流可用。",
"myStreamConsole": "我的直播控制台",
"noRoomInfo": "未找到房間資訊。",
"roomTitle": "房間標題",
"rtmpServerUrl": "RTMP 伺服器地址",
"streamKey": "推流碼 (請務必保密!)",
"copiedToClipboard": "已複製到剪貼板",
"failedToFetchRoomInfo": "獲取房間資訊失敗"
}

View File

@@ -1,10 +1,12 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:fvp/fvp.dart' as fvp; import 'package:fvp/fvp.dart' as fvp;
import 'providers/auth_provider.dart'; import 'providers/auth_provider.dart';
import 'providers/settings_provider.dart'; import 'providers/settings_provider.dart';
import 'pages/home_page.dart'; import 'pages/home_page.dart';
import 'pages/login_page.dart'; import 'pages/login_page.dart';
import 'l10n/app_localizations.dart';
void main() { void main() {
fvp.registerWith(); fvp.registerWith();
@@ -30,6 +32,14 @@ class HightubeApp extends StatelessWidget {
return MaterialApp( return MaterialApp(
title: 'Hightube', title: 'Hightube',
localizationsDelegates: const [
AppLocalizations.delegate,
GlobalMaterialLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
GlobalCupertinoLocalizations.delegate,
],
supportedLocales: AppLocalizations.supportedLocales,
locale: settings.locale,
theme: ThemeData( theme: ThemeData(
useMaterial3: true, useMaterial3: true,
colorScheme: ColorScheme.fromSeed( colorScheme: ColorScheme.fromSeed(

View File

@@ -2,6 +2,7 @@ import 'dart:async';
import 'dart:convert'; import 'dart:convert';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../l10n/app_localizations.dart';
import '../providers/auth_provider.dart'; import '../providers/auth_provider.dart';
import '../providers/settings_provider.dart'; import '../providers/settings_provider.dart';
import '../services/api_service.dart'; import '../services/api_service.dart';
@@ -22,6 +23,7 @@ class _HomePageState extends State<HomePage> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
bool isWide = MediaQuery.of(context).size.width > 600; bool isWide = MediaQuery.of(context).size.width > 600;
final l10n = AppLocalizations.of(context)!;
final List<Widget> pages = [ final List<Widget> pages = [
_ExploreView(onGoLive: () => setState(() => _selectedIndex = 1)), _ExploreView(onGoLive: () => setState(() => _selectedIndex = 1)),
@@ -38,18 +40,18 @@ class _HomePageState extends State<HomePage> {
onDestinationSelected: (int index) => onDestinationSelected: (int index) =>
setState(() => _selectedIndex = index), setState(() => _selectedIndex = index),
labelType: NavigationRailLabelType.all, labelType: NavigationRailLabelType.all,
destinations: const [ destinations: [
NavigationRailDestination( NavigationRailDestination(
icon: Icon(Icons.explore), icon: const Icon(Icons.explore),
label: Text('Explore'), label: Text(l10n.explore),
), ),
NavigationRailDestination( NavigationRailDestination(
icon: Icon(Icons.videocam), icon: const Icon(Icons.videocam),
label: Text('Console'), label: Text(l10n.console),
), ),
NavigationRailDestination( NavigationRailDestination(
icon: Icon(Icons.settings), icon: const Icon(Icons.settings),
label: Text('Settings'), label: Text(l10n.settings),
), ),
], ],
), ),
@@ -61,18 +63,18 @@ class _HomePageState extends State<HomePage> {
selectedIndex: _selectedIndex, selectedIndex: _selectedIndex,
onDestinationSelected: (int index) => onDestinationSelected: (int index) =>
setState(() => _selectedIndex = index), setState(() => _selectedIndex = index),
destinations: const [ destinations: [
NavigationDestination( NavigationDestination(
icon: Icon(Icons.explore), icon: const Icon(Icons.explore),
label: 'Explore', label: l10n.explore,
), ),
NavigationDestination( NavigationDestination(
icon: Icon(Icons.videocam), icon: const Icon(Icons.videocam),
label: 'Console', label: l10n.console,
), ),
NavigationDestination( NavigationDestination(
icon: Icon(Icons.settings), icon: const Icon(Icons.settings),
label: 'Settings', label: l10n.settings,
), ),
], ],
) )
@@ -131,9 +133,10 @@ class _ExploreViewState extends State<_ExploreView> {
} }
} catch (e) { } catch (e) {
if (!isAuto && mounted) { if (!isAuto && mounted) {
final l10n = AppLocalizations.of(context);
ScaffoldMessenger.of( ScaffoldMessenger.of(
context, context,
).showSnackBar(SnackBar(content: Text("Failed to load rooms"))); ).showSnackBar(SnackBar(content: Text(l10n?.failedToLoadRooms ?? "Failed to load rooms")));
} }
} finally { } finally {
if (!isAuto && mounted) setState(() => _isLoading = false); if (!isAuto && mounted) setState(() => _isLoading = false);
@@ -141,20 +144,21 @@ class _ExploreViewState extends State<_ExploreView> {
} }
Future<void> _confirmLogout() async { Future<void> _confirmLogout() async {
final l10n = AppLocalizations.of(context)!;
final confirmed = await showDialog<bool>( final confirmed = await showDialog<bool>(
context: context, context: context,
builder: (context) { builder: (context) {
return AlertDialog( return AlertDialog(
title: const Text('Confirm Logout'), title: Text(l10n.confirmLogout),
content: const Text('Are you sure you want to log out now?'), content: Text(l10n.confirmLogoutDesc),
actions: [ actions: [
TextButton( TextButton(
onPressed: () => Navigator.pop(context, false), onPressed: () => Navigator.pop(context, false),
child: const Text('Cancel'), child: Text(l10n.cancel),
), ),
FilledButton( FilledButton(
onPressed: () => Navigator.pop(context, true), onPressed: () => Navigator.pop(context, true),
child: const Text('Logout'), child: Text(l10n.logout),
), ),
], ],
); );
@@ -169,44 +173,45 @@ class _ExploreViewState extends State<_ExploreView> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final settings = context.watch<SettingsProvider>(); final settings = context.watch<SettingsProvider>();
final l10n = AppLocalizations.of(context)!;
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
title: Text("Explore", style: TextStyle(fontWeight: FontWeight.bold)), title: Text(l10n.explore, style: const TextStyle(fontWeight: FontWeight.bold)),
actions: [ actions: [
IconButton( IconButton(
icon: Icon(Icons.refresh), icon: const Icon(Icons.refresh),
onPressed: () => _refreshRooms(), onPressed: () => _refreshRooms(),
), ),
IconButton(icon: Icon(Icons.logout), onPressed: _confirmLogout), IconButton(icon: const Icon(Icons.logout), onPressed: _confirmLogout),
], ],
), ),
floatingActionButton: FloatingActionButton.extended( floatingActionButton: FloatingActionButton.extended(
onPressed: widget.onGoLive, onPressed: widget.onGoLive,
label: Text("Go Live"), label: Text(l10n.goLive),
icon: Icon(Icons.videocam), icon: const Icon(Icons.videocam),
), ),
body: RefreshIndicator( body: RefreshIndicator(
onRefresh: _refreshRooms, onRefresh: _refreshRooms,
child: _isLoading && _activeRooms.isEmpty child: _isLoading && _activeRooms.isEmpty
? Center(child: CircularProgressIndicator()) ? const Center(child: CircularProgressIndicator())
: _activeRooms.isEmpty : _activeRooms.isEmpty
? ListView( ? ListView(
children: [ children: [
Padding( Padding(
padding: EdgeInsets.only(top: 100), padding: const EdgeInsets.only(top: 100),
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
Icon( const Icon(
Icons.live_tv_outlined, Icons.live_tv_outlined,
size: 80, size: 80,
color: Colors.grey, color: Colors.grey,
), ),
SizedBox(height: 16), const SizedBox(height: 16),
Text( Text(
"No active rooms. Be the first!", l10n.noActiveRooms,
style: TextStyle(color: Colors.grey, fontSize: 16), style: const TextStyle(color: Colors.grey, fontSize: 16),
), ),
], ],
), ),
@@ -214,8 +219,8 @@ class _ExploreViewState extends State<_ExploreView> {
], ],
) )
: GridView.builder( : GridView.builder(
padding: EdgeInsets.all(12), padding: const EdgeInsets.all(12),
gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent( gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: 400, maxCrossAxisExtent: 400,
childAspectRatio: 1.2, childAspectRatio: 1.2,
crossAxisSpacing: 12, crossAxisSpacing: 12,
@@ -224,14 +229,14 @@ class _ExploreViewState extends State<_ExploreView> {
itemCount: _activeRooms.length, itemCount: _activeRooms.length,
itemBuilder: (context, index) { itemBuilder: (context, index) {
final room = _activeRooms[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(); final roomId = room['room_id'].toString();
return Card( return Card(
elevation: 4, elevation: 4,
@@ -294,12 +299,12 @@ class _ExploreViewState extends State<_ExploreView> {
top: 8, top: 8,
left: 8, left: 8,
child: Container( child: Container(
padding: EdgeInsets.symmetric(horizontal: 8, vertical: 4), padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.red, color: Colors.red,
borderRadius: BorderRadius.circular(4), borderRadius: BorderRadius.circular(4),
), ),
child: Row( child: const Row(
children: [ children: [
Icon(Icons.circle, size: 8, color: Colors.white), Icon(Icons.circle, size: 8, color: Colors.white),
SizedBox(width: 4), SizedBox(width: 4),
@@ -331,7 +336,7 @@ class _ExploreViewState extends State<_ExploreView> {
radius: 16, radius: 16,
child: Text(room['user_id'].toString().substring(0, 1)), child: Text(room['user_id'].toString().substring(0, 1)),
), ),
SizedBox(width: 12), const SizedBox(width: 12),
Expanded( Expanded(
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
@@ -341,14 +346,14 @@ class _ExploreViewState extends State<_ExploreView> {
room['title'], room['title'],
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
style: TextStyle( style: const TextStyle(
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
fontSize: 14, fontSize: 14,
), ),
), ),
Text( Text(
"Host ID: ${room['user_id']}", "${l10n.hostId}: ${room['user_id']}",
style: TextStyle(fontSize: 12, color: Colors.grey), style: const TextStyle(fontSize: 12, color: Colors.grey),
), ),
], ],
), ),

View File

@@ -1,6 +1,7 @@
import 'dart:convert'; import 'dart:convert';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../l10n/app_localizations.dart';
import '../providers/auth_provider.dart'; import '../providers/auth_provider.dart';
import '../providers/settings_provider.dart'; import '../providers/settings_provider.dart';
import '../services/api_service.dart'; import '../services/api_service.dart';
@@ -29,10 +30,11 @@ class _LoginPageState extends State<LoginPage> {
} }
void _handleLogin() async { void _handleLogin() async {
final l10n = AppLocalizations.of(context)!;
if (_usernameController.text.isEmpty || _passwordController.text.isEmpty) { if (_usernameController.text.isEmpty || _passwordController.text.isEmpty) {
ScaffoldMessenger.of( ScaffoldMessenger.of(
context, context,
).showSnackBar(SnackBar(content: Text("Please fill in all fields"))); ).showSnackBar(SnackBar(content: Text(l10n.fillAllFields)));
return; return;
} }
@@ -53,7 +55,7 @@ class _LoginPageState extends State<LoginPage> {
if (!mounted) { if (!mounted) {
return; return;
} }
final error = jsonDecode(response.body)['error'] ?? "Login Failed"; final error = jsonDecode(response.body)['error'] ?? l10n.loginFailed;
ScaffoldMessenger.of( ScaffoldMessenger.of(
context, context,
).showSnackBar(SnackBar(content: Text(error))); ).showSnackBar(SnackBar(content: Text(error)));
@@ -63,7 +65,7 @@ class _LoginPageState extends State<LoginPage> {
return; return;
} }
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text("Network Error: Could not connect to server")), SnackBar(content: Text(l10n.networkError)),
); );
} finally { } finally {
if (mounted) setState(() => _isLoading = false); if (mounted) setState(() => _isLoading = false);
@@ -72,11 +74,13 @@ class _LoginPageState extends State<LoginPage> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
actions: [ actions: [
IconButton( IconButton(
icon: Icon(Icons.settings), icon: const Icon(Icons.settings),
onPressed: () => Navigator.push( onPressed: () => Navigator.push(
context, context,
MaterialPageRoute(builder: (_) => const SettingsPage()), MaterialPageRoute(builder: (_) => const SettingsPage()),
@@ -88,7 +92,7 @@ class _LoginPageState extends State<LoginPage> {
child: SingleChildScrollView( child: SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 32.0), padding: const EdgeInsets.symmetric(horizontal: 32.0),
child: ConstrainedBox( child: ConstrainedBox(
constraints: BoxConstraints(maxWidth: 400), constraints: const BoxConstraints(maxWidth: 400),
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
@@ -98,7 +102,7 @@ class _LoginPageState extends State<LoginPage> {
size: 80, size: 80,
color: Theme.of(context).colorScheme.primary, color: Theme.of(context).colorScheme.primary,
), ),
SizedBox(height: 16), const SizedBox(height: 16),
Text( Text(
"HIGHTUBE", "HIGHTUBE",
style: TextStyle( style: TextStyle(
@@ -108,11 +112,11 @@ class _LoginPageState extends State<LoginPage> {
color: Theme.of(context).colorScheme.primary, color: Theme.of(context).colorScheme.primary,
), ),
), ),
Text( const Text(
"Open Source Live Platform", "Open Source Live Platform",
style: TextStyle(color: Colors.grey), style: TextStyle(color: Colors.grey),
), ),
SizedBox(height: 48), const SizedBox(height: 48),
// Fields // Fields
TextField( TextField(
@@ -120,14 +124,14 @@ class _LoginPageState extends State<LoginPage> {
textInputAction: TextInputAction.next, textInputAction: TextInputAction.next,
onSubmitted: (_) => _passwordFocusNode.requestFocus(), onSubmitted: (_) => _passwordFocusNode.requestFocus(),
decoration: InputDecoration( decoration: InputDecoration(
labelText: "Username", labelText: l10n.username,
prefixIcon: Icon(Icons.person), prefixIcon: const Icon(Icons.person),
border: OutlineInputBorder( border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
), ),
), ),
), ),
SizedBox(height: 16), const SizedBox(height: 16),
TextField( TextField(
controller: _passwordController, controller: _passwordController,
focusNode: _passwordFocusNode, focusNode: _passwordFocusNode,
@@ -139,14 +143,14 @@ class _LoginPageState extends State<LoginPage> {
} }
}, },
decoration: InputDecoration( decoration: InputDecoration(
labelText: "Password", labelText: l10n.password,
prefixIcon: Icon(Icons.lock), prefixIcon: const Icon(Icons.lock),
border: OutlineInputBorder( border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
), ),
), ),
), ),
SizedBox(height: 32), const SizedBox(height: 32),
// Login Button // Login Button
SizedBox( SizedBox(
@@ -160,14 +164,14 @@ class _LoginPageState extends State<LoginPage> {
), ),
), ),
child: _isLoading child: _isLoading
? CircularProgressIndicator() ? const CircularProgressIndicator()
: Text( : Text(
"LOGIN", l10n.login,
style: TextStyle(fontWeight: FontWeight.bold), style: const TextStyle(fontWeight: FontWeight.bold),
), ),
), ),
), ),
SizedBox(height: 16), const SizedBox(height: 16),
// Register Link // Register Link
TextButton( TextButton(
@@ -175,7 +179,7 @@ class _LoginPageState extends State<LoginPage> {
context, context,
MaterialPageRoute(builder: (_) => RegisterPage()), MaterialPageRoute(builder: (_) => RegisterPage()),
), ),
child: Text("Don't have an account? Create one"), child: Text(l10n.dontHaveAccount),
), ),
], ],
), ),

View File

@@ -2,6 +2,7 @@ import 'dart:convert';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import '../l10n/app_localizations.dart';
import '../providers/auth_provider.dart'; import '../providers/auth_provider.dart';
import '../providers/settings_provider.dart'; import '../providers/settings_provider.dart';
import '../services/api_service.dart'; import '../services/api_service.dart';
@@ -42,9 +43,10 @@ class _MyStreamPageState extends State<MyStreamPage> {
if (!mounted) { if (!mounted) {
return; return;
} }
final l10n = AppLocalizations.of(context);
ScaffoldMessenger.of( ScaffoldMessenger.of(
context, context,
).showSnackBar(SnackBar(content: Text("Failed to fetch room info"))); ).showSnackBar(SnackBar(content: Text(l10n?.failedToFetchRoomInfo ?? "Failed to fetch room info")));
} finally { } finally {
if (mounted) { if (mounted) {
setState(() => _isLoading = false); setState(() => _isLoading = false);
@@ -55,46 +57,47 @@ class _MyStreamPageState extends State<MyStreamPage> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final settings = context.watch<SettingsProvider>(); final settings = context.watch<SettingsProvider>();
final l10n = AppLocalizations.of(context)!;
return Scaffold( return Scaffold(
appBar: AppBar(title: Text("My Stream Console")), appBar: AppBar(title: Text(l10n.myStreamConsole)),
body: _isLoading body: _isLoading
? Center(child: CircularProgressIndicator()) ? const Center(child: CircularProgressIndicator())
: _roomInfo == null : _roomInfo == null
? Center(child: Text("No room info found.")) ? Center(child: Text(l10n.noRoomInfo))
: SingleChildScrollView( : SingleChildScrollView(
padding: EdgeInsets.all(20), padding: const EdgeInsets.all(20),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
_buildInfoCard( _buildInfoCard(
title: "Room Title", title: l10n.roomTitle,
value: _roomInfo!['title'], value: _roomInfo!['title'],
icon: Icons.edit, icon: Icons.edit,
onTap: () { onTap: () {
// TODO: Implement title update API later // TODO: Implement title update API later
ScaffoldMessenger.of(context).showSnackBar( 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( _buildInfoCard(
title: "RTMP Server URL", title: l10n.rtmpServerUrl,
value: settings.rtmpUrl, value: settings.rtmpUrl,
icon: Icons.copy, icon: Icons.copy,
onTap: () { onTap: () {
Clipboard.setData(ClipboardData(text: settings.rtmpUrl)); Clipboard.setData(ClipboardData(text: settings.rtmpUrl));
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( SnackBar(
content: Text("Server URL copied to clipboard"), content: Text(l10n.copiedToClipboard),
), ),
); );
}, },
), ),
SizedBox(height: 20), const SizedBox(height: 20),
_buildInfoCard( _buildInfoCard(
title: "Stream Key (Keep Secret!)", title: l10n.streamKey,
value: _roomInfo!['stream_key'], value: _roomInfo!['stream_key'],
icon: Icons.copy, icon: Icons.copy,
isSecret: true, isSecret: true,
@@ -104,18 +107,18 @@ class _MyStreamPageState extends State<MyStreamPage> {
); );
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( SnackBar(
content: Text("Stream Key copied to clipboard"), content: Text(l10n.copiedToClipboard),
), ),
); );
}, },
), ),
SizedBox(height: 24), const SizedBox(height: 24),
AndroidQuickStreamPanel( AndroidQuickStreamPanel(
rtmpBaseUrl: settings.rtmpUrl, rtmpBaseUrl: settings.rtmpUrl,
streamKey: _roomInfo!['stream_key'], streamKey: _roomInfo!['stream_key'],
), ),
SizedBox(height: 30), const SizedBox(height: 30),
Center( const Center(
child: Column( child: Column(
children: [ children: [
Icon(Icons.info_outline, color: Colors.grey), Icon(Icons.info_outline, color: Colors.grey),
@@ -143,10 +146,10 @@ class _MyStreamPageState extends State<MyStreamPage> {
}) { }) {
return Card( return Card(
child: ListTile( 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( subtitle: Text(
isSecret ? "••••••••••••••••" : value, isSecret ? "••••••••••••••••" : value,
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16), style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16),
), ),
trailing: IconButton(icon: Icon(icon), onPressed: onTap), trailing: IconButton(icon: Icon(icon), onPressed: onTap),
), ),

View File

@@ -7,6 +7,7 @@ import 'package:flutter/services.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:video_player/video_player.dart'; import 'package:video_player/video_player.dart';
import '../l10n/app_localizations.dart';
import '../providers/auth_provider.dart'; import '../providers/auth_provider.dart';
import '../providers/settings_provider.dart'; import '../providers/settings_provider.dart';
import '../services/api_service.dart'; import '../services/api_service.dart';
@@ -271,6 +272,7 @@ class _PlayerPageState extends State<PlayerPage> {
Future<void> _openVolumeSheet() async { Future<void> _openVolumeSheet() async {
_showControls(); _showControls();
final l10n = AppLocalizations.of(context)!;
await showModalBottomSheet<void>( await showModalBottomSheet<void>(
context: context, context: context,
builder: (context) { builder: (context) {
@@ -284,7 +286,7 @@ class _PlayerPageState extends State<PlayerPage> {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text( Text(
'Volume', l10n.volume,
style: Theme.of(context).textTheme.titleMedium, style: Theme.of(context).textTheme.titleMedium,
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
@@ -353,6 +355,7 @@ class _PlayerPageState extends State<PlayerPage> {
if (!mounted) { if (!mounted) {
return; return;
} }
final l10n = AppLocalizations.of(context)!;
final nextResolution = await showModalBottomSheet<String>( final nextResolution = await showModalBottomSheet<String>(
context: context, context: context,
@@ -364,11 +367,11 @@ class _PlayerPageState extends State<PlayerPage> {
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
ListTile( ListTile(
title: Text('Playback Resolution'), title: Text(l10n.playbackResolution),
subtitle: Text( subtitle: Text(
available.length > 1 available.length > 1
? 'Select an available transcoded stream.' ? l10n.playbackOptionsDesc
: 'Only the source stream is available right now.', : l10n.sourceOnlyDesc,
), ),
), ),
...options.map((option) { ...options.map((option) {
@@ -382,8 +385,8 @@ class _PlayerPageState extends State<PlayerPage> {
), ),
title: Text(option), title: Text(option),
subtitle: enabled subtitle: enabled
? const Text('Available now') ? Text(l10n.availableNow)
: const Text('Waiting for backend transcoding output'), : Text(l10n.waitingForTranscoding),
onTap: enabled ? () => Navigator.pop(context, option) : null, onTap: enabled ? () => Navigator.pop(context, option) : null,
); );
}), }),
@@ -590,6 +593,7 @@ class _PlayerPageState extends State<PlayerPage> {
} }
Widget _buildPlaybackControls() { Widget _buildPlaybackControls() {
final l10n = AppLocalizations.of(context)!;
return IgnorePointer( return IgnorePointer(
ignoring: !_controlsVisible, ignoring: !_controlsVisible,
child: AnimatedOpacity( child: AnimatedOpacity(
@@ -620,7 +624,7 @@ class _PlayerPageState extends State<PlayerPage> {
children: [ children: [
_buildControlButton( _buildControlButton(
icon: Icons.refresh, icon: Icons.refresh,
label: "Refresh", label: l10n.refresh,
onPressed: _refreshPlayer, onPressed: _refreshPlayer,
), ),
_buildControlButton( _buildControlButton(
@@ -629,19 +633,19 @@ class _PlayerPageState extends State<PlayerPage> {
: _volume < 0.5 : _volume < 0.5
? Icons.volume_down ? Icons.volume_down
: Icons.volume_up, : Icons.volume_up,
label: "Volume", label: l10n.volume,
onPressed: _openVolumeSheet, onPressed: _openVolumeSheet,
), ),
_buildControlButton( _buildControlButton(
icon: _showDanmaku ? Icons.subtitles : Icons.subtitles_off, icon: _showDanmaku ? Icons.subtitles : Icons.subtitles_off,
label: _showDanmaku ? "Danmaku On" : "Danmaku Off", label: _showDanmaku ? l10n.danmakuOn : l10n.danmakuOff,
onPressed: _toggleDanmaku, onPressed: _toggleDanmaku,
), ),
_buildControlButton( _buildControlButton(
icon: _isFullscreen icon: _isFullscreen
? Icons.fullscreen_exit ? Icons.fullscreen_exit
: Icons.fullscreen, : Icons.fullscreen,
label: _isFullscreen ? "Exit Fullscreen" : "Fullscreen", label: _isFullscreen ? l10n.exitFullscreen : l10n.fullscreen,
onPressed: _toggleFullscreen, onPressed: _toggleFullscreen,
), ),
_buildControlButton( _buildControlButton(
@@ -679,23 +683,24 @@ class _PlayerPageState extends State<PlayerPage> {
// 抽离聊天区域组件 // 抽离聊天区域组件
Widget _buildChatSection() { Widget _buildChatSection() {
final l10n = AppLocalizations.of(context)!;
return Column( return Column(
children: [ children: [
Container( Container(
padding: EdgeInsets.symmetric(horizontal: 16, vertical: 12), padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
color: Theme.of(context).colorScheme.surfaceContainerHighest, color: Theme.of(context).colorScheme.surfaceContainerHighest,
child: Row( child: Row(
children: [ children: [
Icon(Icons.chat_bubble_outline, size: 16), const Icon(Icons.chat_bubble_outline, size: 16),
SizedBox(width: 8), const SizedBox(width: 8),
Text("Live Chat", style: TextStyle(fontWeight: FontWeight.bold)), Text(l10n.liveChat, style: const TextStyle(fontWeight: FontWeight.bold)),
], ],
), ),
), ),
Expanded( Expanded(
child: ListView.builder( child: ListView.builder(
reverse: true, reverse: true,
padding: EdgeInsets.all(8), padding: const EdgeInsets.all(8),
itemCount: _messages.length, itemCount: _messages.length,
itemBuilder: (context, index) { itemBuilder: (context, index) {
final m = _messages[index]; final m = _messages[index];
@@ -703,7 +708,7 @@ class _PlayerPageState extends State<PlayerPage> {
}, },
), ),
), ),
Divider(height: 1), const Divider(height: 1),
Padding( Padding(
padding: const EdgeInsets.all(8.0), padding: const EdgeInsets.all(8.0),
child: Row( child: Row(
@@ -712,11 +717,11 @@ class _PlayerPageState extends State<PlayerPage> {
child: TextField( child: TextField(
controller: _msgController, controller: _msgController,
decoration: InputDecoration( decoration: InputDecoration(
hintText: "Send a message...", hintText: l10n.sendMessage,
border: OutlineInputBorder( border: OutlineInputBorder(
borderRadius: BorderRadius.circular(20), borderRadius: BorderRadius.circular(20),
), ),
contentPadding: EdgeInsets.symmetric( contentPadding: const EdgeInsets.symmetric(
horizontal: 16, horizontal: 16,
vertical: 8, vertical: 8,
), ),

View File

@@ -1,6 +1,7 @@
import 'dart:convert'; import 'dart:convert';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../l10n/app_localizations.dart';
import '../providers/settings_provider.dart'; import '../providers/settings_provider.dart';
import '../services/api_service.dart'; import '../services/api_service.dart';
@@ -15,8 +16,9 @@ class _RegisterPageState extends State<RegisterPage> {
bool _isLoading = false; bool _isLoading = false;
void _handleRegister() async { void _handleRegister() async {
final l10n = AppLocalizations.of(context)!;
if (_usernameController.text.isEmpty || _passwordController.text.isEmpty) { 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; return;
} }
@@ -27,14 +29,14 @@ class _RegisterPageState extends State<RegisterPage> {
try { try {
final response = await api.register(_usernameController.text, _passwordController.text); final response = await api.register(_usernameController.text, _passwordController.text);
if (response.statusCode == 201) { 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); Navigator.pop(context);
} else { } else {
final error = jsonDecode(response.body)['error'] ?? "Registration Failed"; final error = jsonDecode(response.body)['error'] ?? "Registration Failed";
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(error))); ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(error)));
} }
} catch (e) { } catch (e) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text("Network Error"))); ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(l10n.networkError)));
} finally { } finally {
if (mounted) setState(() => _isLoading = false); if (mounted) setState(() => _isLoading = false);
} }
@@ -42,42 +44,43 @@ class _RegisterPageState extends State<RegisterPage> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return Scaffold( return Scaffold(
appBar: AppBar(title: Text("Create Account")), appBar: AppBar(title: Text(l10n.createAccount)),
body: Center( body: Center(
child: SingleChildScrollView( child: SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 32.0), padding: const EdgeInsets.symmetric(horizontal: 32.0),
child: ConstrainedBox( child: ConstrainedBox(
constraints: BoxConstraints(maxWidth: 400), constraints: const BoxConstraints(maxWidth: 400),
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
Icon(Icons.person_add_outlined, size: 64, color: Theme.of(context).colorScheme.primary), Icon(Icons.person_add_outlined, size: 64, color: Theme.of(context).colorScheme.primary),
SizedBox(height: 24), const SizedBox(height: 24),
Text( Text(
"Join Hightube", l10n.joinHightube,
style: Theme.of(context).textTheme.headlineMedium?.copyWith(fontWeight: FontWeight.bold), style: Theme.of(context).textTheme.headlineMedium?.copyWith(fontWeight: FontWeight.bold),
), ),
SizedBox(height: 48), const SizedBox(height: 48),
TextField( TextField(
controller: _usernameController, controller: _usernameController,
decoration: InputDecoration( decoration: InputDecoration(
labelText: "Desired Username", labelText: l10n.desiredUsername,
prefixIcon: Icon(Icons.person), prefixIcon: const Icon(Icons.person),
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)), border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)),
), ),
), ),
SizedBox(height: 16), const SizedBox(height: 16),
TextField( TextField(
controller: _passwordController, controller: _passwordController,
obscureText: true, obscureText: true,
decoration: InputDecoration( decoration: InputDecoration(
labelText: "Password", labelText: l10n.password,
prefixIcon: Icon(Icons.lock), prefixIcon: const Icon(Icons.lock),
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)), border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)),
), ),
), ),
SizedBox(height: 32), const SizedBox(height: 32),
SizedBox( SizedBox(
width: double.infinity, width: double.infinity,
height: 50, height: 50,
@@ -86,13 +89,13 @@ class _RegisterPageState extends State<RegisterPage> {
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), 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( TextButton(
onPressed: () => Navigator.pop(context), onPressed: () => Navigator.pop(context),
child: Text("Already have an account? Login here"), child: Text(l10n.alreadyHaveAccount),
), ),
], ],
), ),

View File

@@ -2,6 +2,7 @@ import 'dart:convert';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../l10n/app_localizations.dart';
import '../providers/auth_provider.dart'; import '../providers/auth_provider.dart';
import '../providers/settings_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>( final confirmed = await showDialog<bool>(
context: context, context: context,
builder: (context) { builder: (context) {
return AlertDialog( return AlertDialog(
title: const Text("Confirm Logout"), title: Text(l10n.confirmLogout),
content: const Text("Are you sure you want to log out now?"), content: Text(l10n.confirmLogoutDesc),
actions: [ actions: [
TextButton( TextButton(
onPressed: () => Navigator.pop(context, false), onPressed: () => Navigator.pop(context, false),
child: const Text("Cancel"), child: Text(l10n.cancel),
), ),
FilledButton( FilledButton(
onPressed: () => Navigator.pop(context, true), 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 auth = context.watch<AuthProvider>();
final settings = context.watch<SettingsProvider>(); final settings = context.watch<SettingsProvider>();
final isAuthenticated = auth.isAuthenticated; final isAuthenticated = auth.isAuthenticated;
final l10n = AppLocalizations.of(context)!;
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
title: Text("Settings", style: TextStyle(fontWeight: FontWeight.bold)), title: Text(l10n.settings, style: TextStyle(fontWeight: FontWeight.bold)),
centerTitle: true, centerTitle: true,
), ),
body: SingleChildScrollView( body: SingleChildScrollView(
@@ -134,12 +136,58 @@ class _SettingsPageState extends State<SettingsPage> {
_buildProfileSection(auth), _buildProfileSection(auth),
const SizedBox(height: 32), 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), const SizedBox(height: 16),
TextField( TextField(
controller: _urlController, controller: _urlController,
decoration: InputDecoration( decoration: InputDecoration(
labelText: "Backend Server URL", labelText: l10n.backendServerUrl,
hintText: "http://127.0.0.1:8080", hintText: "http://127.0.0.1:8080",
prefixIcon: Icon(Icons.lan), prefixIcon: Icon(Icons.lan),
border: OutlineInputBorder( border: OutlineInputBorder(
@@ -157,13 +205,13 @@ class _SettingsPageState extends State<SettingsPage> {
); );
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( SnackBar(
content: Text("Server URL Updated"), content: Text(l10n.serverUrlUpdated),
behavior: SnackBarBehavior.floating, behavior: SnackBarBehavior.floating,
), ),
); );
}, },
icon: Icon(Icons.save), icon: Icon(Icons.save),
label: Text("Save Network Settings"), label: Text(l10n.saveNetworkSettings),
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(
backgroundColor: Theme.of( backgroundColor: Theme.of(
context, context,
@@ -179,28 +227,28 @@ class _SettingsPageState extends State<SettingsPage> {
), ),
const SizedBox(height: 32), const SizedBox(height: 32),
_buildSectionTitle("Theme Customization"), _buildSectionTitle(l10n.themeCustomization),
const SizedBox(height: 16), const SizedBox(height: 16),
Text( Text(
"Appearance Mode", l10n.appearanceMode,
style: Theme.of(context).textTheme.labelLarge, style: Theme.of(context).textTheme.labelLarge,
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
SegmentedButton<ThemeMode>( SegmentedButton<ThemeMode>(
segments: const [ segments: [
ButtonSegment<ThemeMode>( ButtonSegment<ThemeMode>(
value: ThemeMode.system, value: ThemeMode.system,
label: Text("System"), label: Text(l10n.system),
icon: Icon(Icons.brightness_auto), icon: Icon(Icons.brightness_auto),
), ),
ButtonSegment<ThemeMode>( ButtonSegment<ThemeMode>(
value: ThemeMode.light, value: ThemeMode.light,
label: Text("Light"), label: Text(l10n.light),
icon: Icon(Icons.light_mode), icon: Icon(Icons.light_mode),
), ),
ButtonSegment<ThemeMode>( ButtonSegment<ThemeMode>(
value: ThemeMode.dark, value: ThemeMode.dark,
label: Text("Dark"), label: Text(l10n.dark),
icon: Icon(Icons.dark_mode), icon: Icon(Icons.dark_mode),
), ),
], ],
@@ -210,7 +258,7 @@ class _SettingsPageState extends State<SettingsPage> {
}, },
), ),
const SizedBox(height: 20), 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), const SizedBox(height: 12),
Wrap( Wrap(
spacing: 12, spacing: 12,
@@ -248,26 +296,26 @@ class _SettingsPageState extends State<SettingsPage> {
}).toList(), }).toList(),
), ),
const SizedBox(height: 32), const SizedBox(height: 32),
_buildSectionTitle("Explore"), _buildSectionTitle(l10n.explore),
const SizedBox(height: 8), const SizedBox(height: 8),
SwitchListTile.adaptive( SwitchListTile.adaptive(
contentPadding: EdgeInsets.zero, contentPadding: EdgeInsets.zero,
title: const Text("Live Preview Thumbnails"), title: Text(l10n.livePreviewThumbnails),
subtitle: const Text( subtitle: Text(
"Show cached snapshot covers for live rooms when available.", l10n.livePreviewThumbnailsDesc,
), ),
value: settings.livePreviewThumbnailsEnabled, value: settings.livePreviewThumbnailsEnabled,
onChanged: settings.setLivePreviewThumbnailsEnabled, onChanged: settings.setLivePreviewThumbnailsEnabled,
), ),
if (isAuthenticated) ...[ if (isAuthenticated) ...[
const SizedBox(height: 32), const SizedBox(height: 32),
_buildSectionTitle("Security"), _buildSectionTitle(l10n.security),
const SizedBox(height: 16), const SizedBox(height: 16),
TextField( TextField(
controller: _oldPasswordController, controller: _oldPasswordController,
obscureText: true, obscureText: true,
decoration: InputDecoration( decoration: InputDecoration(
labelText: "Old Password", labelText: l10n.oldPassword,
prefixIcon: const Icon(Icons.lock_outline), prefixIcon: const Icon(Icons.lock_outline),
border: OutlineInputBorder( border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
@@ -279,7 +327,7 @@ class _SettingsPageState extends State<SettingsPage> {
controller: _newPasswordController, controller: _newPasswordController,
obscureText: true, obscureText: true,
decoration: InputDecoration( decoration: InputDecoration(
labelText: "New Password", labelText: l10n.newPassword,
prefixIcon: const Icon(Icons.lock_reset), prefixIcon: const Icon(Icons.lock_reset),
border: OutlineInputBorder( border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
@@ -292,7 +340,7 @@ class _SettingsPageState extends State<SettingsPage> {
child: OutlinedButton.icon( child: OutlinedButton.icon(
onPressed: _handleChangePassword, onPressed: _handleChangePassword,
icon: const Icon(Icons.update), icon: const Icon(Icons.update),
label: const Text("Change Password"), label: Text(l10n.changePassword),
style: OutlinedButton.styleFrom( style: OutlinedButton.styleFrom(
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
@@ -304,9 +352,9 @@ class _SettingsPageState extends State<SettingsPage> {
SizedBox( SizedBox(
width: double.infinity, width: double.infinity,
child: FilledButton.tonalIcon( child: FilledButton.tonalIcon(
onPressed: () => _confirmLogout(auth), onPressed: () => _confirmLogout(auth, l10n),
icon: const Icon(Icons.logout), icon: const Icon(Icons.logout),
label: const Text("Logout"), label: Text(l10n.logout),
style: FilledButton.styleFrom( style: FilledButton.styleFrom(
foregroundColor: Colors.redAccent, foregroundColor: Colors.redAccent,
padding: const EdgeInsets.symmetric(vertical: 14), padding: const EdgeInsets.symmetric(vertical: 14),

View File

@@ -13,11 +13,13 @@ class SettingsProvider with ChangeNotifier {
Color _themeColor = Colors.blue; Color _themeColor = Colors.blue;
ThemeMode _themeMode = ThemeMode.system; ThemeMode _themeMode = ThemeMode.system;
bool _livePreviewThumbnailsEnabled = false; bool _livePreviewThumbnailsEnabled = false;
Locale? _locale;
String get baseUrl => _baseUrl; String get baseUrl => _baseUrl;
Color get themeColor => _themeColor; Color get themeColor => _themeColor;
ThemeMode get themeMode => _themeMode; ThemeMode get themeMode => _themeMode;
bool get livePreviewThumbnailsEnabled => _livePreviewThumbnailsEnabled; bool get livePreviewThumbnailsEnabled => _livePreviewThumbnailsEnabled;
Locale? get locale => _locale;
SettingsProvider() { SettingsProvider() {
_loadSettings(); _loadSettings();
@@ -36,6 +38,41 @@ class SettingsProvider with ChangeNotifier {
} }
_livePreviewThumbnailsEnabled = _livePreviewThumbnailsEnabled =
prefs.getBool('livePreviewThumbnailsEnabled') ?? false; prefs.getBool('livePreviewThumbnailsEnabled') ?? false;
final languageCode = prefs.getString('languageCode');
final scriptCode = prefs.getString('scriptCode');
final countryCode = prefs.getString('countryCode');
if (languageCode != null) {
_locale = Locale.fromSubtags(
languageCode: languageCode,
scriptCode: scriptCode,
countryCode: countryCode,
);
}
notifyListeners();
}
void setLocale(Locale? newLocale) async {
_locale = newLocale;
final prefs = await SharedPreferences.getInstance();
if (newLocale == null) {
await prefs.remove('languageCode');
await prefs.remove('scriptCode');
await prefs.remove('countryCode');
} else {
await prefs.setString('languageCode', newLocale.languageCode);
if (newLocale.scriptCode != null) {
await prefs.setString('scriptCode', newLocale.scriptCode!);
} else {
await prefs.remove('scriptCode');
}
if (newLocale.countryCode != null) {
await prefs.setString('countryCode', newLocale.countryCode!);
} else {
await prefs.remove('countryCode');
}
}
notifyListeners(); notifyListeners();
} }

View File

@@ -58,6 +58,13 @@ class _AndroidQuickStreamPanelState extends State<AndroidQuickStreamPanel> {
} }
Future<void> _initialize() async { Future<void> _initialize() async {
if (kIsWeb || defaultTargetPlatform != TargetPlatform.android) {
setState(() {
_isPreparing = false;
_statusMessage = 'Quick stream is only supported on Android.';
});
return;
}
if (!mounted) { if (!mounted) {
return; return;
} }

View File

@@ -150,6 +150,11 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "6.0.0" version: "6.0.0"
flutter_localizations:
dependency: "direct main"
description: flutter
source: sdk
version: "0.0.0"
flutter_test: flutter_test:
dependency: "direct dev" dependency: "direct dev"
description: flutter description: flutter
@@ -216,6 +221,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "4.8.0" version: "4.8.0"
intl:
dependency: "direct main"
description:
name: intl
sha256: "3df61194eb431efc39c4ceba583b95633a403f46c9fd341e550ce0bfa50e9aa5"
url: "https://pub.dev"
source: hosted
version: "0.20.2"
json_annotation: json_annotation:
dependency: transitive dependency: transitive
description: description:

View File

@@ -42,6 +42,9 @@ dependencies:
web_socket_channel: ^3.0.3 web_socket_channel: ^3.0.3
permission_handler: ^12.0.1 permission_handler: ^12.0.1
rtmp_streaming: ^1.0.5 rtmp_streaming: ^1.0.5
intl: ^0.20.2
flutter_localizations:
sdk: flutter
dev_dependencies: dev_dependencies:
flutter_test: flutter_test:
@@ -75,6 +78,7 @@ flutter_launcher_icons:
# The following section is specific to Flutter packages. # The following section is specific to Flutter packages.
flutter: flutter:
generate: true
# The following line ensures that the Material Icons font is # The following line ensures that the Material Icons font is
# included with your application, so that you can use the icons in # included with your application, so that you can use the icons in