7 Commits

25 changed files with 1819 additions and 71 deletions

View File

@@ -45,6 +45,8 @@ go run cmd/server/main.go
- **API 服务**: `http://localhost:8080` - **API 服务**: `http://localhost:8080`
- **RTMP 服务**: `rtmp://localhost:1935` - **RTMP 服务**: `rtmp://localhost:1935`
最新版本服务端已经支持命令行参数修改指定端口,例如`-api-port 8081 -rtmp-port 1935`
### 3. 测试推流 ### 3. 测试推流
1. 调用 `/api/register` 注册账号。 1. 调用 `/api/register` 注册账号。
2. 调用 `/api/login` 获取 Token。 2. 调用 `/api/login` 获取 Token。
@@ -56,10 +58,15 @@ go run cmd/server/main.go
- [x] **Phase 1**: 基础 RTMP 推拉流功能实现。 - [x] **Phase 1**: 基础 RTMP 推拉流功能实现。
- [x] **Phase 2**: 数据库集成、用户鉴权与推流密钥校验。 - [x] **Phase 2**: 数据库集成、用户鉴权与推流密钥校验。
- [ ] **Phase 3**: Flutter 客户端基础架构与直播列表展示。 - [x] **Phase 3**: Flutter 客户端基础架构与直播列表展示。
- [ ] **Phase 4**: 实时评论系统 (WebSocket) 与弹幕功能。 - [x] **Phase 4**: 实时评论系统 (WebSocket) 与弹幕功能。
- [ ] **Phase 5**: 客户端原生推流支持与 UI/UX 优化。 - [x] **Phase 5**: 客户端原生推流支持与 UI/UX 优化。
## 测试说明
[Hightube项目网站](https://hightube.nudt.space)
我们提供[在线试用](https://stream.nudt.space)以及release里预构建的多种客户端和服务端发行试用时可在设置处将服务器地址设置为`https://stream.nudt.space`
## 📜 许可证 ## 📜 许可证
本项目采用 MIT 许可证开源。 本项目采用 MIT 许可证开源。

View File

@@ -18,7 +18,7 @@ const (
) )
type Message struct { type Message struct {
Type string `json:"type"` // "chat", "system", "danmaku" Type string `json:"type"` // "chat", "system", "danmaku", "stream_end"
Username string `json:"username"` Username string `json:"username"`
Content string `json:"content"` Content string `json:"content"`
RoomID string `json:"room_id"` RoomID string `json:"room_id"`
@@ -190,7 +190,15 @@ func (r *roomHub) handleBroadcast(message Message) {
delete(r.clients, client) delete(r.clients, client)
} }
} }
shouldDeleteIfIdle := message.Type == "stream_end"
if shouldDeleteIfIdle {
r.history = nil
}
r.mutex.Unlock() r.mutex.Unlock()
if shouldDeleteIfIdle {
r.manager.deleteRoomIfIdle(r)
}
} }
func (r *roomHub) handleClearHistory() { func (r *roomHub) handleClearHistory() {
@@ -223,6 +231,15 @@ func (h *Hub) BroadcastToRoom(msg Message) {
h.getOrCreateRoom(msg.RoomID).broadcast <- msg h.getOrCreateRoom(msg.RoomID).broadcast <- msg
} }
func (h *Hub) NotifyStreamEnded(roomID string) {
h.BroadcastToRoom(Message{
Type: "stream_end",
Username: "System",
Content: "The host has ended the live stream.",
RoomID: roomID,
})
}
func (c *Client) ReadPump() { func (c *Client) ReadPump() {
defer func() { defer func() {
c.Hub.UnregisterClient(c) c.Hub.UnregisterClient(c)

View File

@@ -0,0 +1,28 @@
//go:build linux
package monitor
import (
"os"
"syscall"
)
func getDiskSpaceGB() (float64, float64) {
wd, err := os.Getwd()
if err != nil {
return 0, 0
}
var stat syscall.Statfs_t
if err := syscall.Statfs(wd, &stat); err != nil {
return 0, 0
}
const gb = 1024.0 * 1024.0 * 1024.0
blockSize := uint64(stat.Bsize)
totalBytes := stat.Blocks * blockSize
freeBytes := stat.Bfree * blockSize
return float64(totalBytes) / gb, float64(freeBytes) / gb
}

View File

@@ -1,4 +1,4 @@
//go:build !windows //go:build !windows && !linux
package monitor package monitor

View File

@@ -172,7 +172,7 @@ func NewRTMPServer(rtmpPort string) *RTMPServer {
monitor.Warnf("Failed to mark room inactive room_id=%s: %v", roomID, err) monitor.Warnf("Failed to mark room inactive room_id=%s: %v", roomID, err)
} }
} }
chat.MainHub.ClearRoomHistory(roomID) chat.MainHub.NotifyStreamEnded(roomID)
monitor.Infof("Publishing ended for room_id=%s", roomID) monitor.Infof("Publishing ended for room_id=%s", roomID)
} else { } else {
monitor.Infof("Variant publishing ended for room_id=%s path=%s", roomID, channelPath) monitor.Infof("Variant publishing ended for room_id=%s path=%s", roomID, channelPath)

View File

@@ -50,6 +50,8 @@
"availableNow": "Available now", "availableNow": "Available now",
"waitingForTranscoding": "Waiting for backend transcoding output", "waitingForTranscoding": "Waiting for backend transcoding output",
"sendMessage": "Send a message...", "sendMessage": "Send a message...",
"liveStreamEnded": "The host has ended the live stream.",
"liveStreamEndedShort": "Live stream ended",
"liveChat": "Live Chat", "liveChat": "Live Chat",
"refresh": "Refresh", "refresh": "Refresh",
"volume": "Volume", "volume": "Volume",

View File

@@ -50,6 +50,8 @@
"availableNow": "利用可能", "availableNow": "利用可能",
"waitingForTranscoding": "バックエンドのトランスコード出力を待機中", "waitingForTranscoding": "バックエンドのトランスコード出力を待機中",
"sendMessage": "メッセージを送信...", "sendMessage": "メッセージを送信...",
"liveStreamEnded": "配信者が退出したため、ライブ配信は終了しました。",
"liveStreamEndedShort": "ライブ配信は終了しました",
"liveChat": "ライブチャット", "liveChat": "ライブチャット",
"refresh": "更新", "refresh": "更新",
"volume": "音量", "volume": "音量",

View File

@@ -401,6 +401,18 @@ abstract class AppLocalizations {
/// **'Send a message...'** /// **'Send a message...'**
String get sendMessage; String get sendMessage;
/// No description provided for @liveStreamEnded.
///
/// In en, this message translates to:
/// **'The host has ended the live stream.'**
String get liveStreamEnded;
/// No description provided for @liveStreamEndedShort.
///
/// In en, this message translates to:
/// **'Live stream ended'**
String get liveStreamEndedShort;
/// No description provided for @liveChat. /// No description provided for @liveChat.
/// ///
/// In en, this message translates to: /// In en, this message translates to:

View File

@@ -159,6 +159,12 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get sendMessage => 'Send a message...'; String get sendMessage => 'Send a message...';
@override
String get liveStreamEnded => 'The host has ended the live stream.';
@override
String get liveStreamEndedShort => 'Live stream ended';
@override @override
String get liveChat => 'Live Chat'; String get liveChat => 'Live Chat';

View File

@@ -159,6 +159,12 @@ class AppLocalizationsJa extends AppLocalizations {
@override @override
String get sendMessage => 'メッセージを送信...'; String get sendMessage => 'メッセージを送信...';
@override
String get liveStreamEnded => '配信者が退出したため、ライブ配信は終了しました。';
@override
String get liveStreamEndedShort => 'ライブ配信は終了しました';
@override @override
String get liveChat => 'ライブチャット'; String get liveChat => 'ライブチャット';

View File

@@ -158,6 +158,12 @@ class AppLocalizationsZh extends AppLocalizations {
@override @override
String get sendMessage => '发送消息...'; String get sendMessage => '发送消息...';
@override
String get liveStreamEnded => '主播已退出,直播已结束。';
@override
String get liveStreamEndedShort => '直播已结束';
@override @override
String get liveChat => '实时聊天'; String get liveChat => '实时聊天';
@@ -364,6 +370,12 @@ class AppLocalizationsZhHant extends AppLocalizationsZh {
@override @override
String get sendMessage => '發送訊息...'; String get sendMessage => '發送訊息...';
@override
String get liveStreamEnded => '主播已退出,直播已結束。';
@override
String get liveStreamEndedShort => '直播已結束';
@override @override
String get liveChat => '即時聊天'; String get liveChat => '即時聊天';

View File

@@ -50,6 +50,8 @@
"availableNow": "当前可用", "availableNow": "当前可用",
"waitingForTranscoding": "正在等待后端转码输出", "waitingForTranscoding": "正在等待后端转码输出",
"sendMessage": "发送消息...", "sendMessage": "发送消息...",
"liveStreamEnded": "主播已退出,直播已结束。",
"liveStreamEndedShort": "直播已结束",
"liveChat": "实时聊天", "liveChat": "实时聊天",
"refresh": "刷新", "refresh": "刷新",
"volume": "音量", "volume": "音量",

View File

@@ -50,6 +50,8 @@
"availableNow": "目前可用", "availableNow": "目前可用",
"waitingForTranscoding": "正在等待後端轉碼輸出", "waitingForTranscoding": "正在等待後端轉碼輸出",
"sendMessage": "發送訊息...", "sendMessage": "發送訊息...",
"liveStreamEnded": "主播已退出,直播已結束。",
"liveStreamEndedShort": "直播已結束",
"liveChat": "即時聊天", "liveChat": "即時聊天",
"refresh": "重新整理", "refresh": "重新整理",
"volume": "音量", "volume": "音量",

View File

@@ -43,6 +43,7 @@ class _PlayerPageState extends State<PlayerPage> {
bool _isRefreshing = false; bool _isRefreshing = false;
bool _isFullscreen = false; bool _isFullscreen = false;
bool _controlsVisible = true; bool _controlsVisible = true;
bool _streamEnded = false;
double _volume = kIsWeb ? 0.0 : 1.0; double _volume = kIsWeb ? 0.0 : 1.0;
int _playerVersion = 0; int _playerVersion = 0;
String _selectedResolution = 'Source'; String _selectedResolution = 'Source';
@@ -146,6 +147,10 @@ class _PlayerPageState extends State<PlayerPage> {
_chatService.messages.listen((msg) { _chatService.messages.listen((msg) {
if (mounted) { if (mounted) {
if (msg.type == "stream_end") {
_handleStreamEnded(msg.content);
return;
}
setState(() { setState(() {
_messages.insert(0, msg); _messages.insert(0, msg);
if (!msg.isHistory && (msg.type == "chat" || msg.type == "danmaku")) { if (!msg.isHistory && (msg.type == "chat" || msg.type == "danmaku")) {
@@ -158,6 +163,44 @@ class _PlayerPageState extends State<PlayerPage> {
}); });
} }
void _handleStreamEnded(String message) {
if (_streamEnded) {
return;
}
final l10n = AppLocalizations.of(context)!;
final streamEndedMessage = l10n.liveStreamEnded;
setState(() {
_streamEnded = true;
_isRefreshing = false;
_danmakus.clear();
_messages.insert(
0,
ChatMessage(
type: "system",
username: "System",
content: streamEndedMessage,
roomId: widget.roomId,
),
);
if (!kIsWeb) {
_isError = true;
_errorMessage = streamEndedMessage;
}
});
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(streamEndedMessage)));
Future<void>.delayed(const Duration(seconds: 2), () {
if (mounted && Navigator.canPop(context)) {
Navigator.pop(context);
}
});
}
void _addDanmaku(String text) { void _addDanmaku(String text) {
final key = UniqueKey(); final key = UniqueKey();
final lane = DateTime.now().millisecondsSinceEpoch % 8; final lane = DateTime.now().millisecondsSinceEpoch % 8;
@@ -180,7 +223,7 @@ class _PlayerPageState extends State<PlayerPage> {
} }
void _sendMsg() { void _sendMsg() {
if (_msgController.text.isNotEmpty) { if (!_streamEnded && _msgController.text.isNotEmpty) {
final auth = context.read<AuthProvider>(); final auth = context.read<AuthProvider>();
_chatService.sendMessage( _chatService.sendMessage(
_msgController.text, _msgController.text,
@@ -195,6 +238,9 @@ class _PlayerPageState extends State<PlayerPage> {
if (_isRefreshing) { if (_isRefreshing) {
return; return;
} }
if (_streamEnded) {
return;
}
await _loadPlaybackOptions(); await _loadPlaybackOptions();
@@ -625,7 +671,7 @@ class _PlayerPageState extends State<PlayerPage> {
_buildControlButton( _buildControlButton(
icon: Icons.refresh, icon: Icons.refresh,
label: l10n.refresh, label: l10n.refresh,
onPressed: _refreshPlayer, onPressed: _streamEnded ? null : _refreshPlayer,
), ),
_buildControlButton( _buildControlButton(
icon: _volume == 0 icon: _volume == 0
@@ -645,7 +691,9 @@ class _PlayerPageState extends State<PlayerPage> {
icon: _isFullscreen icon: _isFullscreen
? Icons.fullscreen_exit ? Icons.fullscreen_exit
: Icons.fullscreen, : Icons.fullscreen,
label: _isFullscreen ? l10n.exitFullscreen : l10n.fullscreen, label: _isFullscreen
? l10n.exitFullscreen
: l10n.fullscreen,
onPressed: _toggleFullscreen, onPressed: _toggleFullscreen,
), ),
_buildControlButton( _buildControlButton(
@@ -665,13 +713,15 @@ class _PlayerPageState extends State<PlayerPage> {
Widget _buildControlButton({ Widget _buildControlButton({
required IconData icon, required IconData icon,
required String label, required String label,
required FutureOr<void> Function() onPressed, required FutureOr<void> Function()? onPressed,
}) { }) {
return FilledButton.tonalIcon( return FilledButton.tonalIcon(
onPressed: () async { onPressed: onPressed == null
_showControls(); ? null
await onPressed(); : () async {
}, _showControls();
await onPressed();
},
icon: Icon(icon, size: 18), icon: Icon(icon, size: 18),
label: Text(label), label: Text(label),
style: FilledButton.styleFrom( style: FilledButton.styleFrom(
@@ -693,7 +743,10 @@ class _PlayerPageState extends State<PlayerPage> {
children: [ children: [
const Icon(Icons.chat_bubble_outline, size: 16), const Icon(Icons.chat_bubble_outline, size: 16),
const SizedBox(width: 8), const SizedBox(width: 8),
Text(l10n.liveChat, style: const TextStyle(fontWeight: FontWeight.bold)), Text(
l10n.liveChat,
style: const TextStyle(fontWeight: FontWeight.bold),
),
], ],
), ),
), ),
@@ -716,8 +769,11 @@ class _PlayerPageState extends State<PlayerPage> {
Expanded( Expanded(
child: TextField( child: TextField(
controller: _msgController, controller: _msgController,
enabled: !_streamEnded,
decoration: InputDecoration( decoration: InputDecoration(
hintText: l10n.sendMessage, hintText: _streamEnded
? l10n.liveStreamEndedShort
: l10n.sendMessage,
border: OutlineInputBorder( border: OutlineInputBorder(
borderRadius: BorderRadius.circular(20), borderRadius: BorderRadius.circular(20),
), ),
@@ -734,7 +790,7 @@ class _PlayerPageState extends State<PlayerPage> {
Icons.send, Icons.send,
color: Theme.of(context).colorScheme.primary, color: Theme.of(context).colorScheme.primary,
), ),
onPressed: _sendMsg, onPressed: _streamEnded ? null : _sendMsg,
), ),
], ],
), ),

View File

@@ -3,11 +3,16 @@ import 'package:flutter/foundation.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
class SettingsProvider with ChangeNotifier { class SettingsProvider with ChangeNotifier {
// Use 10.0.2.2 for Android emulator to access host's localhost // On web: use empty string so API calls use same origin (works behind any proxy)
static String get _defaultUrl => // On Android emulator: 10.0.2.2 maps to host localhost
(defaultTargetPlatform == TargetPlatform.android && !kIsWeb) // On other platforms: localhost
? "http://10.0.2.2:8080" static String get _defaultUrl {
: "http://localhost:8080"; if (kIsWeb) return "";
if (defaultTargetPlatform == TargetPlatform.android) {
return "http://10.0.2.2:8080";
}
return "http://localhost:8080";
}
String _baseUrl = _defaultUrl; String _baseUrl = _defaultUrl;
Color _themeColor = Colors.blue; Color _themeColor = Colors.blue;
@@ -38,7 +43,7 @@ class SettingsProvider with ChangeNotifier {
} }
_livePreviewThumbnailsEnabled = _livePreviewThumbnailsEnabled =
prefs.getBool('livePreviewThumbnailsEnabled') ?? false; prefs.getBool('livePreviewThumbnailsEnabled') ?? false;
final languageCode = prefs.getString('languageCode'); final languageCode = prefs.getString('languageCode');
final scriptCode = prefs.getString('scriptCode'); final scriptCode = prefs.getString('scriptCode');
final countryCode = prefs.getString('countryCode'); final countryCode = prefs.getString('countryCode');
@@ -49,7 +54,7 @@ class SettingsProvider with ChangeNotifier {
countryCode: countryCode, countryCode: countryCode,
); );
} }
notifyListeners(); notifyListeners();
} }
@@ -106,8 +111,18 @@ class SettingsProvider with ChangeNotifier {
// Also provide the RTMP URL based on the same hostname // Also provide the RTMP URL based on the same hostname
String get rtmpUrl { String get rtmpUrl {
final uri = Uri.parse(_baseUrl); final host = _baseUrl.isEmpty ? _effectiveHost : Uri.parse(_baseUrl).host;
return "rtmp://${uri.host}:1935/live"; return "rtmp://$host:1935/live";
}
// Fallback hostname when baseUrl is empty (web same-origin mode)
String get _effectiveHost {
if (kIsWeb) {
final host = Uri.base.host;
if (host.isNotEmpty) return host;
return 'localhost';
}
return 'localhost';
} }
String playbackUrl(String roomId, {String? quality}) { String playbackUrl(String roomId, {String? quality}) {

View File

@@ -45,8 +45,7 @@ class ChatService {
Stream<ChatMessage> get messages => _messageController.stream; Stream<ChatMessage> get messages => _messageController.stream;
void connect(String baseUrl, String roomId, String username) { void connect(String baseUrl, String roomId, String username) {
final wsUri = Uri.parse(baseUrl).replace( final wsUri = _webSocketUri(baseUrl).replace(
scheme: 'ws',
path: '/api/ws/room/$roomId', path: '/api/ws/room/$roomId',
queryParameters: {'username': username}, queryParameters: {'username': username},
); );
@@ -83,6 +82,21 @@ class ChatService {
} }
} }
Uri _webSocketUri(String baseUrl) {
if (baseUrl.isEmpty) {
if (kIsWeb) {
return Uri.base.replace(
scheme: Uri.base.scheme == 'https' ? 'wss' : 'ws',
);
}
return Uri.parse('http://localhost:8080');
}
final uri = Uri.parse(baseUrl);
final scheme = uri.scheme == 'https' ? 'wss' : 'ws';
return uri.replace(scheme: scheme);
}
void dispose() { void dispose() {
_channel?.sink.close(); _channel?.sink.close();
_messageController.close(); _messageController.close();

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.9 KiB

395
website/hightube/index.html Normal file

File diff suppressed because one or more lines are too long

804
website/hightube/styles.css Normal file
View File

@@ -0,0 +1,804 @@
:root {
color-scheme: light dark;
--primary: #0b57d0;
--on-primary: #ffffff;
--primary-container: #d7e3ff;
--on-primary-container: #001b3f;
--secondary: #565f71;
--tertiary: #705575;
--surface: #fbfcff;
--surface-rgb: 251 252 255;
--surface-container: #eef3fb;
--surface-container-high: #e5ebf5;
--outline: #727782;
--outline-rgb: 114 119 130;
--text: #191c20;
--muted: #42474f;
--success: #146c2e;
--warning: #7a5900;
--shadow: 0 24px 60px rgba(11, 87, 208, 0.16);
--topbar-bg: rgba(251, 252, 255, 0.68);
--topbar-border: rgba(114, 119, 130, 0.18);
--topbar-shadow: 0 12px 36px rgba(11, 87, 208, 0.08);
--grid-divider: rgba(114, 119, 130, 0.24);
--card-border: rgba(114, 119, 130, 0.24);
--device-border: rgba(114, 119, 130, 0.32);
--status-available-bg: rgba(20, 108, 46, 0.12);
--status-planned-bg: rgba(122, 89, 0, 0.12);
}
/* ---- Dark theme: forced ---- */
[data-theme="dark"] {
--primary: #a8c7ff;
--on-primary: #001b3f;
--primary-container: #003a7a;
--on-primary-container: #d7e3ff;
--secondary: #bcc7db;
--tertiary: #d7bde0;
--surface: #111318;
--surface-rgb: 17 19 24;
--surface-container: #1a1d25;
--surface-container-high: #21242d;
--outline: #8b909c;
--outline-rgb: 139 144 156;
--text: #e3e3e8;
--muted: #b0b3bd;
--success: #81c784;
--warning: #ffe08a;
--shadow: 0 24px 60px rgba(0, 0, 0, 0.4);
--topbar-bg: rgba(17, 19, 24, 0.72);
--topbar-border: rgba(139, 144, 156, 0.18);
--topbar-shadow: 0 12px 36px rgba(0, 0, 0, 0.28);
--grid-divider: rgba(139, 144, 156, 0.2);
--card-border: rgba(139, 144, 156, 0.2);
--device-border: rgba(139, 144, 156, 0.28);
--status-available-bg: rgba(129, 199, 132, 0.15);
--status-planned-bg: rgba(255, 224, 138, 0.15);
}
/* ---- Dark theme: auto (system preference, no manual override) ---- */
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--primary: #a8c7ff;
--on-primary: #001b3f;
--primary-container: #003a7a;
--on-primary-container: #d7e3ff;
--secondary: #bcc7db;
--tertiary: #d7bde0;
--surface: #111318;
--surface-rgb: 17 19 24;
--surface-container: #1a1d25;
--surface-container-high: #21242d;
--outline: #8b909c;
--outline-rgb: 139 144 156;
--text: #e3e3e8;
--muted: #b0b3bd;
--success: #81c784;
--warning: #ffe08a;
--shadow: 0 24px 60px rgba(0, 0, 0, 0.4);
--topbar-bg: rgba(17, 19, 24, 0.72);
--topbar-border: rgba(139, 144, 156, 0.18);
--topbar-shadow: 0 12px 36px rgba(0, 0, 0, 0.28);
--grid-divider: rgba(139, 144, 156, 0.2);
--card-border: rgba(139, 144, 156, 0.2);
--device-border: rgba(139, 144, 156, 0.28);
--status-available-bg: rgba(129, 199, 132, 0.15);
--status-planned-bg: rgba(255, 224, 138, 0.15);
}
}
* {
box-sizing: border-box;
}
html {
scroll-behavior: smooth;
}
body {
margin: 0;
min-width: 320px;
background: var(--surface);
color: var(--text);
font-family:
Inter, Roboto, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI",
sans-serif;
line-height: 1.6;
transition: background-color 300ms ease, color 300ms ease;
}
a {
color: inherit;
}
.topbar {
position: sticky;
top: 0;
z-index: 10;
display: flex;
align-items: center;
justify-content: space-between;
gap: 24px;
padding: 14px clamp(20px, 5vw, 72px);
background: var(--topbar-bg);
border-bottom: 1px solid var(--topbar-border);
box-shadow: var(--topbar-shadow);
-webkit-backdrop-filter: blur(22px) saturate(160%);
backdrop-filter: blur(22px) saturate(160%);
}
.brand {
display: inline-flex;
align-items: center;
gap: 10px;
color: var(--text);
font-weight: 800;
text-decoration: none;
}
.brand img {
width: 36px;
height: 36px;
border-radius: 10px;
}
.nav {
display: flex;
align-items: center;
gap: 6px;
}
.nav a {
min-height: 40px;
padding: 8px 14px;
border-radius: 20px;
color: var(--muted);
font-size: 0.94rem;
font-weight: 650;
text-decoration: none;
}
.nav a:hover {
background: var(--surface-container);
color: var(--primary);
}
/* 立即尝试 CTA button in nav */
.nav-cta {
background: var(--primary) !important;
color: var(--on-primary) !important;
margin-left: 8px;
font-weight: 800 !important;
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.2);
transition: transform 160ms ease, box-shadow 160ms ease, background 160ms ease;
}
.nav-cta:hover {
filter: brightness(0.88);
transform: translateY(-1px);
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.26);
}
/* Hamburger menu button — hidden on desktop */
.hamburger {
display: none;
flex-direction: column;
justify-content: center;
gap: 5px;
width: 40px;
height: 40px;
padding: 8px;
border: none;
border-radius: 10px;
background: transparent;
cursor: pointer;
z-index: 20;
}
.hamburger span {
display: block;
width: 100%;
height: 2.5px;
border-radius: 2px;
background: var(--text);
transition: transform 200ms ease, opacity 200ms ease;
}
.hamburger[aria-expanded="true"] span:nth-child(1) {
transform: translateY(7.5px) rotate(45deg);
}
.hamburger[aria-expanded="true"] span:nth-child(2) {
opacity: 0;
}
.hamburger[aria-expanded="true"] span:nth-child(3) {
transform: translateY(-7.5px) rotate(-45deg);
}
/* Right-side controls group (theme + hamburger) */
.topbar-actions {
display: flex;
align-items: center;
gap: 4px;
flex-shrink: 0;
}
/* Theme toggle button */
.theme-toggle {
display: flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
padding: 0;
border: none;
border-radius: 50%;
background: transparent;
color: var(--text);
font-size: 1.25rem;
cursor: pointer;
transition: background 200ms ease, transform 200ms ease;
flex-shrink: 0;
}
.theme-toggle:hover {
background: var(--surface-container);
transform: scale(1.08);
}
.theme-toggle:active {
transform: scale(0.94);
}
.theme-toggle svg {
width: 22px;
height: 22px;
fill: none;
stroke: currentColor;
stroke-width: 2;
stroke-linecap: round;
stroke-linejoin: round;
}
/* hide all icons by default, show based on data-state */
.theme-toggle .icon-sun,
.theme-toggle .icon-moon,
.theme-toggle .icon-auto {
display: none;
}
.theme-toggle[data-state="light"] .icon-sun {
display: block;
}
.theme-toggle[data-state="dark"] .icon-moon {
display: block;
}
.theme-toggle[data-state="auto"] .icon-auto {
display: block;
}
.hero {
display: grid;
grid-template-columns: minmax(0, 1.05fr) minmax(320px, 0.95fr);
gap: clamp(28px, 6vw, 84px);
align-items: center;
min-height: calc(100vh - 68px);
padding: clamp(48px, 7vw, 96px) clamp(20px, 5vw, 72px);
}
.hero-copy {
max-width: 680px;
}
.eyebrow {
margin: 0 0 12px;
color: var(--primary);
font-size: 0.85rem;
font-weight: 800;
letter-spacing: 0.08em;
text-transform: uppercase;
}
h1,
h2,
h3,
p {
overflow-wrap: anywhere;
}
h1 {
margin: 0;
color: var(--text);
font-size: clamp(3.5rem, 11vw, 8rem);
line-height: 0.9;
letter-spacing: 0;
}
h2 {
margin: 0;
font-size: clamp(2rem, 4vw, 3.5rem);
line-height: 1.05;
letter-spacing: 0;
}
h3 {
margin: 0;
font-size: 1.2rem;
line-height: 1.2;
letter-spacing: 0;
}
.lead {
max-width: 620px;
margin: 28px 0 0;
color: var(--muted);
font-size: clamp(1.1rem, 2vw, 1.35rem);
}
.actions {
display: flex;
flex-wrap: wrap;
gap: 12px;
margin-top: 34px;
}
.button,
.download-link {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 48px;
padding: 12px 22px;
border-radius: 24px;
font-weight: 800;
text-decoration: none;
transition:
transform 160ms ease,
box-shadow 160ms ease,
background 160ms ease;
}
.button:hover,
.download-link:hover {
transform: translateY(-1px);
}
.primary {
background: var(--primary);
color: var(--on-primary);
box-shadow: 0 10px 24px rgba(0, 0, 0, 0.18);
}
.secondary {
background: var(--primary-container);
color: var(--on-primary-container);
}
.hero-panel {
display: flex;
justify-content: center;
}
.device-window {
width: min(100%, 560px);
overflow: hidden;
border: 1px solid var(--device-border);
border-radius: 28px;
background: var(--surface-container);
box-shadow: var(--shadow);
}
.window-bar {
display: flex;
gap: 8px;
padding: 16px 18px;
border-bottom: 1px solid var(--card-border);
}
.window-bar span {
width: 12px;
height: 12px;
border-radius: 50%;
background: var(--outline);
}
.window-bar span:first-child {
background: #ba1a1a;
}
.window-bar span:nth-child(2) {
background: #a46700;
}
.window-bar span:nth-child(3) {
background: #146c2e;
}
.stream-preview {
position: relative;
display: grid;
min-height: 280px;
place-items: center;
background:
linear-gradient(135deg, rgba(11, 87, 208, 0.94), rgba(112, 85, 117, 0.9)),
radial-gradient(circle at 30% 30%, #d7e3ff, transparent 32%);
}
.live-badge {
position: absolute;
top: 18px;
left: 18px;
padding: 6px 12px;
border-radius: 16px;
background: #ba1a1a;
color: #ffffff;
font-size: 0.8rem;
font-weight: 900;
}
.play-symbol {
width: 86px;
height: 86px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.88);
clip-path: polygon(28% 18%, 28% 82%, 82% 50%);
}
.stats-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 1px;
background: var(--grid-divider);
}
.stats-grid div {
min-width: 0;
padding: 18px 14px;
background: var(--surface);
}
.stats-grid strong,
.stats-grid span {
display: block;
}
.stats-grid strong {
color: var(--primary);
font-size: 1.02rem;
}
.stats-grid span {
color: var(--muted);
font-size: 0.88rem;
}
.section {
padding: clamp(64px, 8vw, 112px) clamp(20px, 5vw, 72px);
}
.section-heading {
max-width: 820px;
margin-bottom: 32px;
}
.section-heading p:not(.eyebrow) {
max-width: 760px;
color: var(--muted);
font-size: 1.06rem;
}
.architecture-grid,
.download-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 18px;
}
.architecture-card,
.download-card {
min-width: 0;
padding: 24px;
border: 1px solid var(--card-border);
border-radius: 24px;
background: var(--surface-container);
}
.architecture-card .icon {
display: grid;
width: 48px;
height: 48px;
margin-bottom: 20px;
place-items: center;
border-radius: 16px;
background: var(--primary);
color: var(--on-primary);
}
.architecture-card .icon svg {
width: 26px;
height: 26px;
}
.architecture-card p,
.download-card p,
.feature-item p,
.source-section p {
color: var(--muted);
}
.feature-band {
background: var(--surface-container);
}
.feature-list {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 1px;
overflow: hidden;
border: 1px solid var(--card-border);
border-radius: 28px;
background: var(--grid-divider);
}
.feature-item {
min-width: 0;
padding: 28px;
background: var(--surface);
}
.feature-icon {
width: 36px;
height: 36px;
margin-bottom: 14px;
color: var(--primary);
}
.feature-icon svg {
width: 100%;
height: 100%;
display: block;
}
.download-grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.download-card {
display: flex;
min-height: 240px;
flex-direction: column;
align-items: flex-start;
}
.download-icon {
width: 32px;
height: 32px;
margin-bottom: 14px;
color: var(--muted);
}
.download-icon svg {
width: 100%;
height: 100%;
display: block;
}
.status {
margin-bottom: 18px;
padding: 5px 10px;
border-radius: 14px;
background: var(--status-available-bg);
color: var(--success);
font-size: 0.78rem;
font-weight: 850;
}
.status.muted {
background: var(--status-planned-bg);
color: var(--warning);
}
.download-link {
margin-top: auto;
background: var(--primary);
color: var(--on-primary);
}
.planned {
background: var(--surface-container-high);
}
.source-section {
display: flex;
align-items: center;
justify-content: space-between;
gap: 24px;
background: #001b3f;
color: #ffffff;
}
.source-section .eyebrow,
.source-section p {
color: #d7e3ff;
}
.source-section div {
max-width: 820px;
}
.footer {
display: grid;
grid-template-columns: 1fr auto 1fr;
align-items: center;
gap: 16px;
padding: 28px clamp(20px, 5vw, 72px);
background: #001533;
color: #d7e3ff;
font-size: 0.92rem;
}
.footer span:nth-child(2) {
text-align: center;
}
.footer span:last-child {
text-align: right;
}
@media (max-width: 900px) {
.topbar {
flex-direction: row;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
}
.hamburger {
display: flex;
}
.nav {
display: none;
width: 100%;
flex-direction: column;
gap: 2px;
padding: 8px 0 4px;
border-top: 1px solid var(--topbar-border);
margin-top: 10px;
}
.nav.open {
display: flex;
}
.nav a {
width: 100%;
padding: 12px 16px;
border-radius: 14px;
font-size: 1rem;
}
.nav-cta {
margin-left: 0 !important;
margin-top: 6px;
text-align: center;
}
.hero {
grid-template-columns: 1fr;
min-height: auto;
}
.architecture-grid,
.download-grid {
grid-template-columns: 1fr 1fr;
}
.stats-grid {
grid-template-columns: repeat(2, 1fr);
}
.source-section {
align-items: flex-start;
flex-direction: column;
}
}
@media (max-width: 640px) {
.topbar {
padding: 10px 16px;
}
.hero,
.section {
padding-right: 16px;
padding-left: 16px;
}
.hero {
padding-top: 32px;
padding-bottom: 32px;
gap: 24px;
}
h1 {
font-size: clamp(2.8rem, 10vw, 4rem);
}
h2 {
font-size: clamp(1.6rem, 5vw, 2.4rem);
}
.lead {
font-size: 1rem;
margin-top: 16px;
}
.hero-panel {
width: 100%;
}
.device-window {
width: 100%;
border-radius: 20px;
}
.stream-preview {
min-height: 180px;
}
.play-symbol {
width: 60px;
height: 60px;
}
.architecture-grid,
.download-grid,
.feature-list {
grid-template-columns: 1fr;
}
.architecture-card,
.download-card {
padding: 20px;
border-radius: 18px;
}
.feature-item {
padding: 20px;
}
.actions {
flex-direction: column;
}
.button,
.download-link {
width: 100%;
justify-content: center;
}
.nav a {
min-height: 48px;
padding: 14px 16px;
font-size: 1.05rem;
}
.nav-cta {
min-height: 50px;
font-size: 1.05rem;
}
.footer {
grid-template-columns: 1fr;
gap: 8px;
padding: 24px 16px;
}
.footer-hide-mobile {
display: none;
}
}

View File

@@ -22,7 +22,20 @@
<a href="#features">Features</a> <a href="#features">Features</a>
<a href="#downloads">Downloads</a> <a href="#downloads">Downloads</a>
<a href="#source">Source</a> <a href="#source">Source</a>
<a class="nav-cta" href="https://stream.nudt.space" target="_blank" rel="noopener">Try Now</a>
</nav> </nav>
<div class="topbar-actions">
<button class="theme-toggle" data-state="auto" aria-label="Theme: Auto" title="Theme: Auto — click to force Light">
<svg class="icon-sun" viewBox="0 0 24 24"><circle cx="12" cy="12" r="5"/><path d="M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42"/></svg>
<svg class="icon-moon" viewBox="0 0 24 24"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>
<svg class="icon-auto" viewBox="0 0 24 24"><rect x="2" y="3" width="20" height="14" rx="2"/><path d="M8 21h8M12 17v4"/></svg>
</button>
<button class="hamburger" aria-label="Toggle navigation" aria-expanded="false">
<span></span>
<span></span>
<span></span>
</button>
</div>
</header> </header>
<main id="home"> <main id="home">
@@ -159,9 +172,8 @@
<p class="eyebrow">Downloads</p> <p class="eyebrow">Downloads</p>
<h2>Get the current executable builds</h2> <h2>Get the current executable builds</h2>
<p> <p>
These files are hosted in the local website resources directory. All builds are distributed through the project release page. Apple
Windows server and client builds will be added later. Apple device device builds are not provided at this time.
builds are not provided at this time.
</p> </p>
</div> </div>
<div class="download-grid"> <div class="download-grid">
@@ -171,8 +183,8 @@
<p>Backend service for self-hosted streaming deployments.</p> <p>Backend service for self-hosted streaming deployments.</p>
<a <a
class="download-link" class="download-link"
href="resources/hightube-server/hightube-server_amd64_v1.0.0" href="https://git.nudt.space/Highground-Soft/Hightube/releases/download/v1.0.0/hightube-server_amd64_v1.0.0"
download rel="noopener"
> >
Download Linux server Download Linux server
</a> </a>
@@ -183,8 +195,8 @@
<p>Desktop Flutter client packaged as an AppImage.</p> <p>Desktop Flutter client packaged as an AppImage.</p>
<a <a
class="download-link" class="download-link"
href="resources/hightube-client/hightube-linux-amd64-v1.0.0.AppImage" href="https://git.nudt.space/Highground-Soft/Hightube/releases/download/v1.0.0/hightube-linux-amd64-v1.0.0.AppImage"
download rel="noopener"
> >
Download Linux AppImage Download Linux AppImage
</a> </a>
@@ -195,8 +207,8 @@
<p>ARM64 Android APK build for mobile viewing and interaction.</p> <p>ARM64 Android APK build for mobile viewing and interaction.</p>
<a <a
class="download-link" class="download-link"
href="resources/hightube-client/hightube-android-arm64-v8a-v1.0.0.apk" href="https://git.nudt.space/Highground-Soft/Hightube/releases/download/v1.0.0/hightube-android-arm64-v8a-v1.0.0.apk"
download rel="noopener"
> >
Download Android APK Download Android APK
</a> </a>
@@ -207,21 +219,35 @@
<p>Static web build archive for hosting the Flutter web frontend.</p> <p>Static web build archive for hosting the Flutter web frontend.</p>
<a <a
class="download-link" class="download-link"
href="resources/hightube-client/hightube-web-v1.0.0.tar.gz" href="https://git.nudt.space/Highground-Soft/Hightube/releases/download/v1.0.0/hightube-web-v1.0.0.tar.gz"
download rel="noopener"
> >
Download Web archive Download Web archive
</a> </a>
</article> </article>
<article class="download-card planned"> <article class="download-card available">
<span class="status muted">Coming later</span> <span class="status">Available</span>
<h3>Windows server</h3> <h3>Windows server</h3>
<p>Planned server executable for Windows environments.</p> <p>Server executable for Windows environments.</p>
<a
class="download-link"
href="https://git.nudt.space/Highground-Soft/Hightube/releases/download/v1.0.0/hightube-server_amd64_v1.0.0.exe"
rel="noopener"
>
Download Windows server
</a>
</article> </article>
<article class="download-card planned"> <article class="download-card available">
<span class="status muted">Coming later</span> <span class="status">Available</span>
<h3>Windows client</h3> <h3>Windows client</h3>
<p>Planned desktop client build for Windows users.</p> <p>Desktop client build for Windows users.</p>
<a
class="download-link"
href="https://git.nudt.space/Highground-Soft/Hightube/releases/download/v1.0.0/hightube-win_amd64_v1.0.0.zip"
rel="noopener"
>
Download Windows client
</a>
</article> </article>
</div> </div>
</section> </section>
@@ -246,10 +272,73 @@
</section> </section>
</main> </main>
<script>
(function () {
/* ---- hamburger menu ---- */
var btn = document.querySelector('.hamburger');
var nav = document.querySelector('.nav');
if (btn && nav) {
btn.addEventListener('click', function () {
var open = nav.classList.toggle('open');
btn.setAttribute('aria-expanded', open);
});
}
/* ---- theme toggle ---- */
var toggle = document.querySelector('.theme-toggle');
if (!toggle) return;
var STATES = ['auto', 'light', 'dark'];
var LABELS = {
auto: 'Theme: Auto — click to force Light',
light: 'Theme: Light — click to force Dark',
dark: 'Theme: Dark — click to return to Auto'
};
function getTheme() {
return localStorage.getItem('theme') || 'auto';
}
function applyTheme(state) {
if (state === 'light') {
document.documentElement.setAttribute('data-theme', 'light');
} else if (state === 'dark') {
document.documentElement.setAttribute('data-theme', 'dark');
} else {
document.documentElement.removeAttribute('data-theme');
}
toggle.setAttribute('data-state', state);
toggle.setAttribute('aria-label', 'Theme: ' + state.charAt(0).toUpperCase() + state.slice(1));
toggle.setAttribute('title', LABELS[state]);
localStorage.setItem('theme', state);
}
function nextTheme(current) {
var idx = STATES.indexOf(current);
return STATES[(idx + 1) % STATES.length];
}
// init
var current = getTheme();
applyTheme(current);
toggle.addEventListener('click', function () {
applyTheme(nextTheme(getTheme()));
});
// listen for system changes (only matters in auto mode)
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', function () {
if (getTheme() === 'auto') {
// force a repaint by re-applying auto
applyTheme('auto');
}
});
})();
</script>
<footer class="footer"> <footer class="footer">
<span>Hightube</span> <span class="footer-hide-mobile">Hightube</span>
<span>Built with ❤️ by HighGround-soft 2026</span> <span>Built with ❤️ by HighGround-soft 2026</span>
<span>Open Source Live Platform</span> <span class="footer-hide-mobile">Open Source Live Platform</span>
</footer> </footer>
</body> </body>
</html> </html>

View File

@@ -1,19 +1,90 @@
:root { :root {
color-scheme: light; color-scheme: light dark;
--primary: #0b57d0; --primary: #0b57d0;
--on-primary: #ffffff; --on-primary: #ffffff;
--primary-container: #d7e3ff; --primary-container: #d7e3ff;
--on-primary-container: #001b3f;
--secondary: #565f71; --secondary: #565f71;
--tertiary: #705575; --tertiary: #705575;
--surface: #fbfcff; --surface: #fbfcff;
--surface-rgb: 251 252 255;
--surface-container: #eef3fb; --surface-container: #eef3fb;
--surface-container-high: #e5ebf5; --surface-container-high: #e5ebf5;
--outline: #727782; --outline: #727782;
--outline-rgb: 114 119 130;
--text: #191c20; --text: #191c20;
--muted: #42474f; --muted: #42474f;
--success: #146c2e; --success: #146c2e;
--warning: #7a5900; --warning: #7a5900;
--shadow: 0 24px 60px rgba(11, 87, 208, 0.16); --shadow: 0 24px 60px rgba(11, 87, 208, 0.16);
--topbar-bg: rgba(251, 252, 255, 0.68);
--topbar-border: rgba(114, 119, 130, 0.18);
--topbar-shadow: 0 12px 36px rgba(11, 87, 208, 0.08);
--grid-divider: rgba(114, 119, 130, 0.24);
--card-border: rgba(114, 119, 130, 0.24);
--device-border: rgba(114, 119, 130, 0.32);
--status-available-bg: rgba(20, 108, 46, 0.12);
--status-planned-bg: rgba(122, 89, 0, 0.12);
}
/* ---- Dark theme: forced ---- */
[data-theme="dark"] {
--primary: #a8c7ff;
--on-primary: #001b3f;
--primary-container: #003a7a;
--on-primary-container: #d7e3ff;
--secondary: #bcc7db;
--tertiary: #d7bde0;
--surface: #111318;
--surface-rgb: 17 19 24;
--surface-container: #1a1d25;
--surface-container-high: #21242d;
--outline: #8b909c;
--outline-rgb: 139 144 156;
--text: #e3e3e8;
--muted: #b0b3bd;
--success: #81c784;
--warning: #ffe08a;
--shadow: 0 24px 60px rgba(0, 0, 0, 0.4);
--topbar-bg: rgba(17, 19, 24, 0.72);
--topbar-border: rgba(139, 144, 156, 0.18);
--topbar-shadow: 0 12px 36px rgba(0, 0, 0, 0.28);
--grid-divider: rgba(139, 144, 156, 0.2);
--card-border: rgba(139, 144, 156, 0.2);
--device-border: rgba(139, 144, 156, 0.28);
--status-available-bg: rgba(129, 199, 132, 0.15);
--status-planned-bg: rgba(255, 224, 138, 0.15);
}
/* ---- Dark theme: auto (system preference, no manual override) ---- */
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--primary: #a8c7ff;
--on-primary: #001b3f;
--primary-container: #003a7a;
--on-primary-container: #d7e3ff;
--secondary: #bcc7db;
--tertiary: #d7bde0;
--surface: #111318;
--surface-rgb: 17 19 24;
--surface-container: #1a1d25;
--surface-container-high: #21242d;
--outline: #8b909c;
--outline-rgb: 139 144 156;
--text: #e3e3e8;
--muted: #b0b3bd;
--success: #81c784;
--warning: #ffe08a;
--shadow: 0 24px 60px rgba(0, 0, 0, 0.4);
--topbar-bg: rgba(17, 19, 24, 0.72);
--topbar-border: rgba(139, 144, 156, 0.18);
--topbar-shadow: 0 12px 36px rgba(0, 0, 0, 0.28);
--grid-divider: rgba(139, 144, 156, 0.2);
--card-border: rgba(139, 144, 156, 0.2);
--device-border: rgba(139, 144, 156, 0.28);
--status-available-bg: rgba(129, 199, 132, 0.15);
--status-planned-bg: rgba(255, 224, 138, 0.15);
}
} }
* { * {
@@ -33,6 +104,7 @@ body {
Inter, Roboto, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Inter, Roboto, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI",
sans-serif; sans-serif;
line-height: 1.6; line-height: 1.6;
transition: background-color 300ms ease, color 300ms ease;
} }
a { a {
@@ -48,9 +120,9 @@ a {
justify-content: space-between; justify-content: space-between;
gap: 24px; gap: 24px;
padding: 14px clamp(20px, 5vw, 72px); padding: 14px clamp(20px, 5vw, 72px);
background: rgba(251, 252, 255, 0.68); background: var(--topbar-bg);
border-bottom: 1px solid rgba(114, 119, 130, 0.18); border-bottom: 1px solid var(--topbar-border);
box-shadow: 0 12px 36px rgba(11, 87, 208, 0.08); box-shadow: var(--topbar-shadow);
-webkit-backdrop-filter: blur(22px) saturate(160%); -webkit-backdrop-filter: blur(22px) saturate(160%);
backdrop-filter: blur(22px) saturate(160%); backdrop-filter: blur(22px) saturate(160%);
} }
@@ -91,6 +163,123 @@ a {
color: var(--primary); color: var(--primary);
} }
/* 立即尝试 CTA button in nav */
.nav-cta {
background: var(--primary) !important;
color: var(--on-primary) !important;
margin-left: 8px;
font-weight: 800 !important;
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.2);
transition: transform 160ms ease, box-shadow 160ms ease, background 160ms ease;
}
.nav-cta:hover {
filter: brightness(0.88);
transform: translateY(-1px);
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.26);
}
/* Hamburger menu button — hidden on desktop */
.hamburger {
display: none;
flex-direction: column;
justify-content: center;
gap: 5px;
width: 40px;
height: 40px;
padding: 8px;
border: none;
border-radius: 10px;
background: transparent;
cursor: pointer;
z-index: 20;
}
.hamburger span {
display: block;
width: 100%;
height: 2.5px;
border-radius: 2px;
background: var(--text);
transition: transform 200ms ease, opacity 200ms ease;
}
.hamburger[aria-expanded="true"] span:nth-child(1) {
transform: translateY(7.5px) rotate(45deg);
}
.hamburger[aria-expanded="true"] span:nth-child(2) {
opacity: 0;
}
.hamburger[aria-expanded="true"] span:nth-child(3) {
transform: translateY(-7.5px) rotate(-45deg);
}
/* Right-side controls group (theme + hamburger) */
.topbar-actions {
display: flex;
align-items: center;
gap: 4px;
flex-shrink: 0;
}
/* Theme toggle button */
.theme-toggle {
display: flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
padding: 0;
border: none;
border-radius: 50%;
background: transparent;
color: var(--text);
font-size: 1.25rem;
cursor: pointer;
transition: background 200ms ease, transform 200ms ease;
flex-shrink: 0;
}
.theme-toggle:hover {
background: var(--surface-container);
transform: scale(1.08);
}
.theme-toggle:active {
transform: scale(0.94);
}
.theme-toggle svg {
width: 22px;
height: 22px;
fill: none;
stroke: currentColor;
stroke-width: 2;
stroke-linecap: round;
stroke-linejoin: round;
}
/* hide all icons by default, show based on data-state */
.theme-toggle .icon-sun,
.theme-toggle .icon-moon,
.theme-toggle .icon-auto {
display: none;
}
.theme-toggle[data-state="light"] .icon-sun {
display: block;
}
.theme-toggle[data-state="dark"] .icon-moon {
display: block;
}
.theme-toggle[data-state="auto"] .icon-auto {
display: block;
}
.hero { .hero {
display: grid; display: grid;
grid-template-columns: minmax(0, 1.05fr) minmax(320px, 0.95fr); grid-template-columns: minmax(0, 1.05fr) minmax(320px, 0.95fr);
@@ -180,12 +369,12 @@ h3 {
.primary { .primary {
background: var(--primary); background: var(--primary);
color: var(--on-primary); color: var(--on-primary);
box-shadow: 0 10px 24px rgba(11, 87, 208, 0.22); box-shadow: 0 10px 24px rgba(0, 0, 0, 0.18);
} }
.secondary { .secondary {
background: var(--primary-container); background: var(--primary-container);
color: #001b3f; color: var(--on-primary-container);
} }
.hero-panel { .hero-panel {
@@ -196,7 +385,7 @@ h3 {
.device-window { .device-window {
width: min(100%, 560px); width: min(100%, 560px);
overflow: hidden; overflow: hidden;
border: 1px solid rgba(114, 119, 130, 0.32); border: 1px solid var(--device-border);
border-radius: 28px; border-radius: 28px;
background: var(--surface-container); background: var(--surface-container);
box-shadow: var(--shadow); box-shadow: var(--shadow);
@@ -206,7 +395,7 @@ h3 {
display: flex; display: flex;
gap: 8px; gap: 8px;
padding: 16px 18px; padding: 16px 18px;
border-bottom: 1px solid rgba(114, 119, 130, 0.24); border-bottom: 1px solid var(--card-border);
} }
.window-bar span { .window-bar span {
@@ -262,7 +451,7 @@ h3 {
display: grid; display: grid;
grid-template-columns: repeat(4, 1fr); grid-template-columns: repeat(4, 1fr);
gap: 1px; gap: 1px;
background: rgba(114, 119, 130, 0.24); background: var(--grid-divider);
} }
.stats-grid div { .stats-grid div {
@@ -312,7 +501,7 @@ h3 {
.download-card { .download-card {
min-width: 0; min-width: 0;
padding: 24px; padding: 24px;
border: 1px solid rgba(114, 119, 130, 0.24); border: 1px solid var(--card-border);
border-radius: 24px; border-radius: 24px;
background: var(--surface-container); background: var(--surface-container);
} }
@@ -345,9 +534,9 @@ h3 {
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 1px; gap: 1px;
overflow: hidden; overflow: hidden;
border: 1px solid rgba(114, 119, 130, 0.24); border: 1px solid var(--card-border);
border-radius: 28px; border-radius: 28px;
background: rgba(114, 119, 130, 0.24); background: var(--grid-divider);
} }
.feature-item { .feature-item {
@@ -371,14 +560,14 @@ h3 {
margin-bottom: 18px; margin-bottom: 18px;
padding: 5px 10px; padding: 5px 10px;
border-radius: 14px; border-radius: 14px;
background: rgba(20, 108, 46, 0.12); background: var(--status-available-bg);
color: var(--success); color: var(--success);
font-size: 0.78rem; font-size: 0.78rem;
font-weight: 850; font-weight: 850;
} }
.status.muted { .status.muted {
background: rgba(122, 89, 0, 0.12); background: var(--status-planned-bg);
color: var(--warning); color: var(--warning);
} }
@@ -431,14 +620,41 @@ h3 {
@media (max-width: 900px) { @media (max-width: 900px) {
.topbar { .topbar {
align-items: flex-start; flex-direction: row;
flex-direction: column; flex-wrap: wrap;
align-items: center;
justify-content: space-between;
}
.hamburger {
display: flex;
} }
.nav { .nav {
display: none;
width: 100%; width: 100%;
overflow-x: auto; flex-direction: column;
padding-bottom: 2px; gap: 2px;
padding: 8px 0 4px;
border-top: 1px solid var(--topbar-border);
margin-top: 10px;
}
.nav.open {
display: flex;
}
.nav a {
width: 100%;
padding: 12px 16px;
border-radius: 14px;
font-size: 1rem;
}
.nav-cta {
margin-left: 0 !important;
margin-top: 6px;
text-align: center;
} }
.hero { .hero {
@@ -462,34 +678,97 @@ h3 {
} }
@media (max-width: 640px) { @media (max-width: 640px) {
.topbar {
padding: 10px 16px;
}
.hero, .hero,
.section { .section {
padding-right: 16px; padding-right: 16px;
padding-left: 16px; padding-left: 16px;
} }
.hero {
padding-top: 32px;
padding-bottom: 32px;
gap: 24px;
}
h1 {
font-size: clamp(2.8rem, 10vw, 4rem);
}
h2 {
font-size: clamp(1.6rem, 5vw, 2.4rem);
}
.lead {
font-size: 1rem;
margin-top: 16px;
}
.hero-panel {
width: 100%;
}
.device-window {
width: 100%;
border-radius: 20px;
}
.stream-preview {
min-height: 180px;
}
.play-symbol {
width: 60px;
height: 60px;
}
.architecture-grid, .architecture-grid,
.download-grid, .download-grid,
.feature-list { .feature-list {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.actions, .architecture-card,
.download-card {
padding: 20px;
border-radius: 18px;
}
.feature-item {
padding: 20px;
}
.actions {
flex-direction: column;
}
.button, .button,
.download-link { .download-link {
width: 100%; width: 100%;
justify-content: center;
} }
.stream-preview { .nav a {
min-height: 220px; min-height: 48px;
padding: 14px 16px;
font-size: 1.05rem;
}
.nav-cta {
min-height: 50px;
font-size: 1.05rem;
} }
.footer { .footer {
grid-template-columns: 1fr; grid-template-columns: 1fr;
gap: 8px;
padding: 24px 16px;
} }
.footer span, .footer-hide-mobile {
.footer span:last-child { display: none;
text-align: left;
} }
} }