Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 36c9b58a8b | |||
| 6d3036fc9c | |||
| d93806d223 | |||
| 804adf94a3 | |||
| b0e3d6069a | |||
| 80d4f692e0 | |||
| 2281c98b1b | |||
| 7cb51e70a3 | |||
| 44318e0e4d | |||
| da577299a0 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -4,6 +4,7 @@
|
||||
.vscode/
|
||||
docs/
|
||||
.codex
|
||||
build/
|
||||
|
||||
# --- Backend (Go) ---
|
||||
backend/hightube.db
|
||||
|
||||
13
README.md
13
README.md
@@ -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 许可证开源。
|
||||
|
||||
@@ -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)
|
||||
|
||||
28
backend/internal/monitor/disk_linux.go
Normal file
28
backend/internal/monitor/disk_linux.go
Normal 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
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
//go:build !windows
|
||||
//go:build !windows && !linux
|
||||
|
||||
package monitor
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -50,6 +50,8 @@
|
||||
"availableNow": "利用可能",
|
||||
"waitingForTranscoding": "バックエンドのトランスコード出力を待機中",
|
||||
"sendMessage": "メッセージを送信...",
|
||||
"liveStreamEnded": "配信者が退出したため、ライブ配信は終了しました。",
|
||||
"liveStreamEndedShort": "ライブ配信は終了しました",
|
||||
"liveChat": "ライブチャット",
|
||||
"refresh": "更新",
|
||||
"volume": "音量",
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -159,6 +159,12 @@ class AppLocalizationsJa extends AppLocalizations {
|
||||
@override
|
||||
String get sendMessage => 'メッセージを送信...';
|
||||
|
||||
@override
|
||||
String get liveStreamEnded => '配信者が退出したため、ライブ配信は終了しました。';
|
||||
|
||||
@override
|
||||
String get liveStreamEndedShort => 'ライブ配信は終了しました';
|
||||
|
||||
@override
|
||||
String get liveChat => 'ライブチャット';
|
||||
|
||||
|
||||
@@ -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 => '即時聊天';
|
||||
|
||||
|
||||
@@ -50,6 +50,8 @@
|
||||
"availableNow": "当前可用",
|
||||
"waitingForTranscoding": "正在等待后端转码输出",
|
||||
"sendMessage": "发送消息...",
|
||||
"liveStreamEnded": "主播已退出,直播已结束。",
|
||||
"liveStreamEndedShort": "直播已结束",
|
||||
"liveChat": "实时聊天",
|
||||
"refresh": "刷新",
|
||||
"volume": "音量",
|
||||
|
||||
@@ -50,6 +50,8 @@
|
||||
"availableNow": "目前可用",
|
||||
"waitingForTranscoding": "正在等待後端轉碼輸出",
|
||||
"sendMessage": "發送訊息...",
|
||||
"liveStreamEnded": "主播已退出,直播已結束。",
|
||||
"liveStreamEndedShort": "直播已結束",
|
||||
"liveChat": "即時聊天",
|
||||
"refresh": "重新整理",
|
||||
"volume": "音量",
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -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}) {
|
||||
|
||||
@@ -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
114
scripts/package_linux_appimage.sh
Executable 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
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user