diff --git a/public/css/style.css b/public/css/style.css
index a6e8646..3ba0492 100644
--- a/public/css/style.css
+++ b/public/css/style.css
@@ -240,23 +240,61 @@ header p {
}
.top-banner {
- background: rgba(255, 255, 255, 0.08);
+ background: rgba(255, 255, 255, 0.7);
border: 1px solid var(--panel-border);
border-radius: 16px;
padding: 1rem 1.25rem;
margin-bottom: 1.5rem;
color: var(--text-primary);
- font-size: 1.25rem;
- font-weight: 700;
display: flex;
align-items: center;
- justify-content: flex-start;
+ justify-content: space-between;
+ gap: 1rem;
+ backdrop-filter: blur(16px);
+ -webkit-backdrop-filter: blur(16px);
}
.top-banner.hidden {
display: none;
}
+.top-banner__title {
+ font-size: 1.25rem;
+ font-weight: 700;
+ min-width: 0;
+}
+
+.top-banner__actions {
+ display: flex;
+ align-items: center;
+ justify-content: flex-end;
+ gap: 0.75rem;
+ flex-wrap: wrap;
+}
+
+.user-chip {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.5rem;
+ padding: 0.65rem 0.9rem;
+ border-radius: 999px;
+ background: rgba(37, 99, 235, 0.08);
+ border: 1px solid rgba(37, 99, 235, 0.16);
+ color: var(--text-primary);
+ line-height: 1;
+}
+
+.user-chip__label {
+ color: var(--text-secondary);
+ font-size: 0.82rem;
+ font-weight: 600;
+}
+
+.user-chip__name {
+ font-size: 0.92rem;
+ font-weight: 700;
+}
+
.section-header {
display: block;
margin-bottom: 0.75rem;
@@ -291,6 +329,16 @@ header p {
transition: background 0.2s ease, transform 0.2s ease, box-shadow 0.2s ease;
}
+.action-btn-secondary {
+ background: #0f172a;
+ color: #ffffff;
+}
+
+.action-btn-secondary:hover {
+ background: #1e293b;
+ box-shadow: 0 12px 24px rgba(15, 23, 42, 0.16);
+}
+
.action-btn:hover {
background: #1d4ed8;
transform: translateY(-1px);
@@ -1044,6 +1092,16 @@ header p {
/* Responsive adjustments */
@media (max-width: 900px) {
+ .top-banner {
+ align-items: flex-start;
+ flex-direction: column;
+ }
+
+ .top-banner__actions {
+ width: 100%;
+ justify-content: space-between;
+ }
+
.dashboard {
grid-template-columns: 1fr;
}
diff --git a/public/index.html b/public/index.html
index 7f72e6d..c222a46 100644
--- a/public/index.html
+++ b/public/index.html
@@ -34,7 +34,16 @@
-
+
diff --git a/public/js/main.js b/public/js/main.js
index bba2303..b2225d0 100644
--- a/public/js/main.js
+++ b/public/js/main.js
@@ -22,6 +22,9 @@ document.addEventListener('DOMContentLoaded', () => {
const stopTranscodeBtn = document.getElementById('stop-transcode-btn');
const playBtn = document.getElementById('play-btn');
const topBanner = document.getElementById('top-banner');
+ const topBannerTitle = document.getElementById('top-banner-title');
+ const currentUsername = document.getElementById('current-username');
+ const logoutBtn = document.getElementById('logout-btn');
const customControls = document.getElementById('custom-controls');
const controlPlayToggle = document.getElementById('control-play-toggle');
const controlMuteToggle = document.getElementById('control-mute-toggle');
@@ -71,6 +74,7 @@ document.addEventListener('DOMContentLoaded', () => {
let currentVideoKey = null;
let s3Username = '';
let s3Password = '';
+ let authenticatedUsername = '';
let s3AuthHeaders = {};
const LEGACY_AUTH_STORAGE_KEY = 'media-coding-web:s3-auth';
const SAVED_BUCKET_STORAGE_KEY = 'media-coding-web:selected-bucket';
@@ -723,13 +727,13 @@ document.addEventListener('DOMContentLoaded', () => {
// --- End custom seek bar ---
const loadConfig = async () => {
- if (!topBanner) return;
+ if (!topBanner || !topBannerTitle) return;
try {
const res = await fetch('/api/config');
if (!res.ok) throw new Error('Failed to load config');
const data = await res.json();
const title = data.title || 'S3 Media Transcoder';
- topBanner.textContent = title;
+ topBannerTitle.textContent = title;
topBanner.classList.remove('hidden');
document.title = title;
populateSelect(encoderSelect, data.videoEncoders || [], data.defaultVideoEncoder || 'h264_rkmpp');
@@ -800,11 +804,20 @@ document.addEventListener('DOMContentLoaded', () => {
const showLogin = () => {
if (loginScreen) loginScreen.classList.remove('hidden');
if (appContainer) appContainer.classList.add('hidden');
+ if (topBanner) topBanner.classList.add('hidden');
};
const showApp = () => {
if (loginScreen) loginScreen.classList.add('hidden');
if (appContainer) appContainer.classList.remove('hidden');
+ if (topBanner) topBanner.classList.remove('hidden');
+ };
+
+ const updateAuthenticatedUser = (username) => {
+ authenticatedUsername = typeof username === 'string' ? username.trim() : '';
+ if (currentUsername) {
+ currentUsername.textContent = authenticatedUsername || '-';
+ }
};
const renderBuckets = (buckets) => {
@@ -830,11 +843,62 @@ document.addEventListener('DOMContentLoaded', () => {
loginError.classList.add('hidden');
};
- const applyAuthenticatedState = async (buckets) => {
+ const resetAuthenticatedState = () => {
+ stopPolling();
+ selectedBucket = null;
+ selectedKey = null;
+ currentVideoKey = null;
+ subscribedKey = null;
+ setAuthHeaders('', '');
+ saveSelectedBucket('');
+ updateAuthenticatedUser('');
+ clearLoginError();
+
+ if (bucketSelect) {
+ bucketSelect.innerHTML = '';
+ }
+ if (videoListEl) {
+ videoListEl.innerHTML = '';
+ videoListEl.classList.add('hidden');
+ }
+ if (loadingSpinner) {
+ loadingSpinner.classList.remove('hidden');
+ loadingSpinner.innerHTML = `
+
+ Fetching S3 Objects...
+ `;
+ }
+ if (nowPlaying) nowPlaying.classList.add('hidden');
+ if (currentVideoTitle) currentVideoTitle.textContent = 'video.mp4';
+ if (loginPasswordInput) loginPasswordInput.value = '';
+ if (transcodingOverlay) transcodingOverlay.classList.add('hidden');
+ if (playerOverlay) playerOverlay.classList.remove('hidden');
+ if (videoPlayer) {
+ videoPlayer.pause();
+ videoPlayer.removeAttribute('src');
+ videoPlayer.load();
+ videoPlayer.classList.add('hidden');
+ }
+ isStreamActive = false;
+ videoDuration = 0;
+ seekOffset = 0;
+ if (seekCurrentTime) seekCurrentTime.textContent = formatTime(0);
+ if (seekTotalTime) seekTotalTime.textContent = formatTime(0);
+ hideSeekBar();
+ hideCustomControls();
+ resetPhases();
+ resetSubtitleTracks();
+ setPlaybackStatus('Paused', 'paused');
+ updatePlayControls();
+ updateVolumeControls();
+ };
+
+ const applyAuthenticatedState = async (buckets, username = '') => {
if (!Array.isArray(buckets) || buckets.length === 0) {
throw new Error('No buckets available for this account');
}
+ updateAuthenticatedUser(username);
renderBuckets(buckets);
showApp();
const savedBucket = loadSelectedBucket();
@@ -851,6 +915,33 @@ document.addEventListener('DOMContentLoaded', () => {
await fetchVideos(selectedBucket);
};
+ const logout = async () => {
+ if (logoutBtn) {
+ logoutBtn.disabled = true;
+ logoutBtn.textContent = '退出中...';
+ }
+
+ try {
+ const res = await fetch('/api/logout', { method: 'POST' });
+ if (!res.ok) {
+ const data = await res.json().catch(() => ({}));
+ throw new Error(data.error || 'Logout failed');
+ }
+ } catch (error) {
+ console.error('Logout failed:', error);
+ alert(`退出登录失败: ${error.message}`);
+ return;
+ } finally {
+ if (logoutBtn) {
+ logoutBtn.disabled = false;
+ logoutBtn.textContent = '退出登录';
+ }
+ }
+
+ resetAuthenticatedState();
+ showLogin();
+ };
+
const login = async (options = {}) => {
if (!loginUsernameInput || !loginPasswordInput) return false;
const username = typeof options.username === 'string' ? options.username.trim() : loginUsernameInput.value.trim();
@@ -876,7 +967,7 @@ document.addEventListener('DOMContentLoaded', () => {
const data = await res.json();
if (loginUsernameInput) loginUsernameInput.value = username;
if (loginPasswordInput) loginPasswordInput.value = '';
- await applyAuthenticatedState(data.buckets);
+ await applyAuthenticatedState(data.buckets, data.username || username);
return true;
} catch (err) {
console.error('Login error:', err);
@@ -900,7 +991,7 @@ document.addEventListener('DOMContentLoaded', () => {
return false;
}
const data = await res.json();
- await applyAuthenticatedState(data.buckets || []);
+ await applyAuthenticatedState(data.buckets || [], data.username || '');
return true;
} catch (error) {
console.error('Session restore failed:', error);
@@ -1344,6 +1435,9 @@ document.addEventListener('DOMContentLoaded', () => {
if (loginBtn) {
loginBtn.addEventListener('click', login);
}
+ if (logoutBtn) {
+ logoutBtn.addEventListener('click', logout);
+ }
if (bucketSelect) {
bucketSelect.addEventListener('change', async (event) => {
selectedBucket = event.target.value;
diff --git a/server.js b/server.js
index b894530..187948f 100644
--- a/server.js
+++ b/server.js
@@ -195,6 +195,16 @@ const createValkeySession = async (res, credentials) => {
return sessionId;
};
+const destroyValkeySession = async (req, res) => {
+ const cookies = parseCookieHeader(req.headers.cookie || '');
+ const sessionId = cookies[SESSION_COOKIE_NAME];
+ if (sessionId && valkeyEnabled && valkeyClient) {
+ await valkeyClient.del(getSessionStorageKey(sessionId));
+ console.log(`[auth] session cleared sessionId=${sessionId}`);
+ }
+ clearSessionCookie(res);
+};
+
const loadValkeySession = async (req) => {
if (!valkeyEnabled || !valkeyClient) {
return null;
@@ -602,7 +612,7 @@ app.get('/api/buckets', async (req, res) => {
await createValkeySession(res, auth);
}
console.log(`[s3] list buckets complete count=${buckets.length}`);
- res.json({ buckets });
+ res.json({ buckets, username: auth.username || '' });
} catch (error) {
if (auth?.source === 'session') {
clearSessionCookie(res);
@@ -612,6 +622,16 @@ app.get('/api/buckets', async (req, res) => {
}
});
+app.post('/api/logout', async (req, res) => {
+ try {
+ await destroyValkeySession(req, res);
+ res.json({ ok: true });
+ } catch (error) {
+ console.error('[auth] logout failed:', error);
+ res.status(500).json({ error: 'Failed to logout', detail: error.message });
+ }
+});
+
// Endpoint to list videos in the bucket
app.get('/api/videos', async (req, res) => {
try {