10 Commits
v1.0.0 ... main

24 changed files with 828 additions and 83 deletions

1
.gitignore vendored
View File

@@ -4,6 +4,7 @@
.vscode/
docs/
.codex
build/
# --- Backend (Go) ---
backend/hightube.db

View File

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

View File

@@ -18,7 +18,7 @@ const (
)
type Message struct {
Type string `json:"type"` // "chat", "system", "danmaku"
Type string `json:"type"` // "chat", "system", "danmaku", "stream_end"
Username string `json:"username"`
Content string `json:"content"`
RoomID string `json:"room_id"`
@@ -190,7 +190,15 @@ func (r *roomHub) handleBroadcast(message Message) {
delete(r.clients, client)
}
}
shouldDeleteIfIdle := message.Type == "stream_end"
if shouldDeleteIfIdle {
r.history = nil
}
r.mutex.Unlock()
if shouldDeleteIfIdle {
r.manager.deleteRoomIfIdle(r)
}
}
func (r *roomHub) handleClearHistory() {
@@ -223,6 +231,15 @@ func (h *Hub) BroadcastToRoom(msg Message) {
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() {
defer func() {
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

View File

@@ -172,7 +172,7 @@ func NewRTMPServer(rtmpPort string) *RTMPServer {
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)
} else {
monitor.Infof("Variant publishing ended for room_id=%s path=%s", roomID, channelPath)

View File

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

View File

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

View File

@@ -401,6 +401,18 @@ abstract class AppLocalizations {
/// **'Send a message...'**
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.
///
/// In en, this message translates to:

View File

@@ -159,6 +159,12 @@ class AppLocalizationsEn extends AppLocalizations {
@override
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
String get liveChat => 'Live Chat';

View File

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

View File

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

View File

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

View File

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

View File

@@ -43,6 +43,7 @@ class _PlayerPageState extends State<PlayerPage> {
bool _isRefreshing = false;
bool _isFullscreen = false;
bool _controlsVisible = true;
bool _streamEnded = false;
double _volume = kIsWeb ? 0.0 : 1.0;
int _playerVersion = 0;
String _selectedResolution = 'Source';
@@ -146,6 +147,10 @@ class _PlayerPageState extends State<PlayerPage> {
_chatService.messages.listen((msg) {
if (mounted) {
if (msg.type == "stream_end") {
_handleStreamEnded(msg.content);
return;
}
setState(() {
_messages.insert(0, msg);
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) {
final key = UniqueKey();
final lane = DateTime.now().millisecondsSinceEpoch % 8;
@@ -180,7 +223,7 @@ class _PlayerPageState extends State<PlayerPage> {
}
void _sendMsg() {
if (_msgController.text.isNotEmpty) {
if (!_streamEnded && _msgController.text.isNotEmpty) {
final auth = context.read<AuthProvider>();
_chatService.sendMessage(
_msgController.text,
@@ -195,6 +238,9 @@ class _PlayerPageState extends State<PlayerPage> {
if (_isRefreshing) {
return;
}
if (_streamEnded) {
return;
}
await _loadPlaybackOptions();
@@ -625,7 +671,7 @@ class _PlayerPageState extends State<PlayerPage> {
_buildControlButton(
icon: Icons.refresh,
label: l10n.refresh,
onPressed: _refreshPlayer,
onPressed: _streamEnded ? null : _refreshPlayer,
),
_buildControlButton(
icon: _volume == 0
@@ -645,7 +691,9 @@ class _PlayerPageState extends State<PlayerPage> {
icon: _isFullscreen
? Icons.fullscreen_exit
: Icons.fullscreen,
label: _isFullscreen ? l10n.exitFullscreen : l10n.fullscreen,
label: _isFullscreen
? l10n.exitFullscreen
: l10n.fullscreen,
onPressed: _toggleFullscreen,
),
_buildControlButton(
@@ -665,10 +713,12 @@ class _PlayerPageState extends State<PlayerPage> {
Widget _buildControlButton({
required IconData icon,
required String label,
required FutureOr<void> Function() onPressed,
required FutureOr<void> Function()? onPressed,
}) {
return FilledButton.tonalIcon(
onPressed: () async {
onPressed: onPressed == null
? null
: () async {
_showControls();
await onPressed();
},
@@ -693,7 +743,10 @@ class _PlayerPageState extends State<PlayerPage> {
children: [
const Icon(Icons.chat_bubble_outline, size: 16),
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(
child: TextField(
controller: _msgController,
enabled: !_streamEnded,
decoration: InputDecoration(
hintText: l10n.sendMessage,
hintText: _streamEnded
? l10n.liveStreamEndedShort
: l10n.sendMessage,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(20),
),
@@ -734,7 +790,7 @@ class _PlayerPageState extends State<PlayerPage> {
Icons.send,
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';
class SettingsProvider with ChangeNotifier {
// Use 10.0.2.2 for Android emulator to access host's localhost
static String get _defaultUrl =>
(defaultTargetPlatform == TargetPlatform.android && !kIsWeb)
? "http://10.0.2.2:8080"
: "http://localhost:8080";
// On web: use empty string so API calls use same origin (works behind any proxy)
// On Android emulator: 10.0.2.2 maps to host localhost
// On other platforms: localhost
static String get _defaultUrl {
if (kIsWeb) return "";
if (defaultTargetPlatform == TargetPlatform.android) {
return "http://10.0.2.2:8080";
}
return "http://localhost:8080";
}
String _baseUrl = _defaultUrl;
Color _themeColor = Colors.blue;
@@ -106,8 +111,18 @@ class SettingsProvider with ChangeNotifier {
// Also provide the RTMP URL based on the same hostname
String get rtmpUrl {
final uri = Uri.parse(_baseUrl);
return "rtmp://${uri.host}:1935/live";
final host = _baseUrl.isEmpty ? _effectiveHost : Uri.parse(_baseUrl).host;
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}) {

View File

@@ -45,8 +45,7 @@ class ChatService {
Stream<ChatMessage> get messages => _messageController.stream;
void connect(String baseUrl, String roomId, String username) {
final wsUri = Uri.parse(baseUrl).replace(
scheme: 'ws',
final wsUri = _webSocketUri(baseUrl).replace(
path: '/api/ws/room/$roomId',
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() {
_channel?.sink.close();
_messageController.close();

114
scripts/package_linux_appimage.sh Executable file
View File

@@ -0,0 +1,114 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd -- "${SCRIPT_DIR}/.." && pwd)"
FRONTEND_DIR="${REPO_ROOT}/frontend"
DEFAULT_BUNDLE_DIR="${FRONTEND_DIR}/build/linux/x64/release/bundle"
APP_NAME="hightube"
APP_DISPLAY_NAME="Hightube"
BUNDLE_DIR="${BUNDLE_DIR:-${DEFAULT_BUNDLE_DIR}}"
APPDIR="${APPDIR:-${REPO_ROOT}/build/appimage/${APP_NAME}.AppDir}"
OUTPUT="${OUTPUT:-${REPO_ROOT}/hightube-linux_amd64.AppImage}"
ICON_SOURCE="${ICON_SOURCE:-${FRONTEND_DIR}/assets/icon/app_icon.png}"
APPIMAGETOOL="${APPIMAGETOOL:-appimagetool}"
RUN_FLUTTER_BUILD=0
usage() {
cat <<EOF
Usage: $(basename "$0") [--build]
Package the Flutter Linux release bundle into:
${OUTPUT}
Options:
--build Run "flutter build linux --release" before packaging.
Environment overrides:
BUNDLE_DIR Flutter Linux release bundle directory.
OUTPUT Output AppImage path.
APPDIR Temporary AppDir path.
ICON_SOURCE PNG icon path.
APPIMAGETOOL appimagetool executable path.
EOF
}
while [[ $# -gt 0 ]]; do
case "$1" in
--build)
RUN_FLUTTER_BUILD=1
shift
;;
-h|--help)
usage
exit 0
;;
*)
echo "Unknown argument: $1" >&2
usage >&2
exit 2
;;
esac
done
if [[ "${RUN_FLUTTER_BUILD}" -eq 1 ]]; then
(cd "${FRONTEND_DIR}" && flutter build linux --release)
fi
if [[ ! -x "${BUNDLE_DIR}/${APP_NAME}" ]]; then
echo "Linux release bundle not found at: ${BUNDLE_DIR}" >&2
echo "Run: cd frontend && flutter build linux --release" >&2
exit 1
fi
if [[ ! -f "${ICON_SOURCE}" ]]; then
echo "Icon file not found at: ${ICON_SOURCE}" >&2
exit 1
fi
if ! command -v "${APPIMAGETOOL}" >/dev/null 2>&1; then
echo "appimagetool not found." >&2
echo "Install appimagetool or set APPIMAGETOOL=/path/to/appimagetool" >&2
exit 1
fi
rm -rf "${APPDIR}"
mkdir -p \
"${APPDIR}/usr/bin" \
"${APPDIR}/usr/share/applications" \
"${APPDIR}/usr/share/icons/hicolor/512x512/apps"
cp -a "${BUNDLE_DIR}/." "${APPDIR}/usr/bin/"
cp "${ICON_SOURCE}" "${APPDIR}/${APP_NAME}.png"
cp "${ICON_SOURCE}" "${APPDIR}/usr/share/icons/hicolor/512x512/apps/${APP_NAME}.png"
cat > "${APPDIR}/${APP_NAME}.desktop" <<EOF
[Desktop Entry]
Type=Application
Name=${APP_DISPLAY_NAME}
Comment=Open source live streaming platform
Exec=${APP_NAME}
Icon=${APP_NAME}
Terminal=false
Categories=AudioVideo;Player;
EOF
cp "${APPDIR}/${APP_NAME}.desktop" "${APPDIR}/usr/share/applications/${APP_NAME}.desktop"
cat > "${APPDIR}/AppRun" <<'EOF'
#!/usr/bin/env bash
set -euo pipefail
HERE="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
cd "${HERE}/usr/bin"
exec "./hightube" "$@"
EOF
chmod +x "${APPDIR}/AppRun" "${APPDIR}/usr/bin/${APP_NAME}"
rm -f "${OUTPUT}"
APPIMAGE_EXTRACT_AND_RUN=1 ARCH=x86_64 "${APPIMAGETOOL}" "${APPDIR}" "${OUTPUT}"
chmod +x "${OUTPUT}"
echo "Created AppImage: ${OUTPUT}"

File diff suppressed because one or more lines are too long

View File

@@ -1,19 +1,90 @@
:root {
color-scheme: light;
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);
}
}
* {
@@ -33,6 +104,7 @@ body {
Inter, Roboto, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI",
sans-serif;
line-height: 1.6;
transition: background-color 300ms ease, color 300ms ease;
}
a {
@@ -48,9 +120,9 @@ a {
justify-content: space-between;
gap: 24px;
padding: 14px clamp(20px, 5vw, 72px);
background: rgba(251, 252, 255, 0.68);
border-bottom: 1px solid rgba(114, 119, 130, 0.18);
box-shadow: 0 12px 36px rgba(11, 87, 208, 0.08);
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%);
}
@@ -91,6 +163,123 @@ a {
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);
@@ -180,12 +369,12 @@ h3 {
.primary {
background: var(--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 {
background: var(--primary-container);
color: #001b3f;
color: var(--on-primary-container);
}
.hero-panel {
@@ -196,7 +385,7 @@ h3 {
.device-window {
width: min(100%, 560px);
overflow: hidden;
border: 1px solid rgba(114, 119, 130, 0.32);
border: 1px solid var(--device-border);
border-radius: 28px;
background: var(--surface-container);
box-shadow: var(--shadow);
@@ -206,7 +395,7 @@ h3 {
display: flex;
gap: 8px;
padding: 16px 18px;
border-bottom: 1px solid rgba(114, 119, 130, 0.24);
border-bottom: 1px solid var(--card-border);
}
.window-bar span {
@@ -262,7 +451,7 @@ h3 {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 1px;
background: rgba(114, 119, 130, 0.24);
background: var(--grid-divider);
}
.stats-grid div {
@@ -312,7 +501,7 @@ h3 {
.download-card {
min-width: 0;
padding: 24px;
border: 1px solid rgba(114, 119, 130, 0.24);
border: 1px solid var(--card-border);
border-radius: 24px;
background: var(--surface-container);
}
@@ -326,7 +515,11 @@ h3 {
border-radius: 16px;
background: var(--primary);
color: var(--on-primary);
font-weight: 900;
}
.architecture-card .icon svg {
width: 26px;
height: 26px;
}
.architecture-card p,
@@ -345,9 +538,9 @@ h3 {
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 1px;
overflow: hidden;
border: 1px solid rgba(114, 119, 130, 0.24);
border: 1px solid var(--card-border);
border-radius: 28px;
background: rgba(114, 119, 130, 0.24);
background: var(--grid-divider);
}
.feature-item {
@@ -356,6 +549,19 @@ h3 {
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));
}
@@ -367,18 +573,31 @@ h3 {
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: rgba(20, 108, 46, 0.12);
background: var(--status-available-bg);
color: var(--success);
font-size: 0.78rem;
font-weight: 850;
}
.status.muted {
background: rgba(122, 89, 0, 0.12);
background: var(--status-planned-bg);
color: var(--warning);
}
@@ -431,14 +650,41 @@ h3 {
@media (max-width: 900px) {
.topbar {
align-items: flex-start;
flex-direction: column;
flex-direction: row;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
}
.hamburger {
display: flex;
}
.nav {
display: none;
width: 100%;
overflow-x: auto;
padding-bottom: 2px;
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 {
@@ -462,34 +708,97 @@ h3 {
}
@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;
}
.actions,
.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;
}
.stream-preview {
min-height: 220px;
.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 span,
.footer span:last-child {
text-align: left;
.footer-hide-mobile {
display: none;
}
}