Skip to content

Commit fefebe5

Browse files
committed
Menu bottom sticky + moving tests + better experience cross-devices
1 parent 62d54df commit fefebe5

39 files changed

+1101
-357
lines changed

.vscode/settings.json

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,23 @@
11
{
2-
"python-envs.pythonProjects": []
2+
"python-envs.pythonProjects": [],
3+
"workbench.colorCustomizations": {
4+
"activityBar.activeBackground": "#82ac6b",
5+
"activityBar.background": "#82ac6b",
6+
"activityBar.foreground": "#15202b",
7+
"activityBar.inactiveForeground": "#15202b99",
8+
"activityBarBadge.background": "#50668f",
9+
"activityBarBadge.foreground": "#e7e7e7",
10+
"commandCenter.border": "#e7e7e799",
11+
"sash.hoverBorder": "#82ac6b",
12+
"statusBar.background": "#699252",
13+
"statusBar.foreground": "#e7e7e7",
14+
"statusBarItem.hoverBackground": "#82ac6b",
15+
"statusBarItem.remoteBackground": "#699252",
16+
"statusBarItem.remoteForeground": "#e7e7e7",
17+
"titleBar.activeBackground": "#699252",
18+
"titleBar.activeForeground": "#e7e7e7",
19+
"titleBar.inactiveBackground": "#69925299",
20+
"titleBar.inactiveForeground": "#e7e7e799"
21+
},
22+
"peacock.remoteColor": "#699252"
323
}

pytest.ini

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,5 +13,5 @@ addopts =
1313
--cov-fail-under=76
1414
-v
1515
--tb=short
16-
--ignore=tests/services/test_cache.py
16+
--ignore=tests/unit/services/test_cache.py
1717
asyncio_mode = auto

routes/stream.py

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
add_to_history,
1515
get_history,
1616
clear_history,
17+
get_queue_hash,
1718
save_playback_position,
1819
get_playback_position,
1920
clear_playback_position,
@@ -35,6 +36,7 @@
3536
class StreamRequest(BaseModel):
3637
youtube_video_id: str
3738
skip_transcription: bool = False
39+
queue_id: Optional[int] = None
3840

3941

4042
class StreamState:
@@ -44,6 +46,8 @@ def __init__(self, lock):
4446
self._lock = lock
4547
self._current_process = None
4648
self._download_thread = None
49+
self._current_video_id: Optional[str] = None
50+
self._current_queue_id: Optional[int] = None
4751

4852
def start_stream(self, video_id: str, skip_transcription: bool):
4953
"""Start new download, stopping existing one."""
@@ -96,6 +100,21 @@ def is_streaming(self) -> bool:
96100
with self._lock:
97101
return self._current_process is not None
98102

103+
@property
104+
def current_video_id(self) -> Optional[str]:
105+
with self._lock:
106+
return self._current_video_id
107+
108+
@property
109+
def current_queue_id(self) -> Optional[int]:
110+
with self._lock:
111+
return self._current_queue_id
112+
113+
def set_current(self, video_id: Optional[str], queue_id: Optional[int]) -> None:
114+
with self._lock:
115+
self._current_video_id = video_id
116+
self._current_queue_id = queue_id
117+
99118

100119
# Global instance
101120
_stream_state = None
@@ -170,6 +189,7 @@ def stream_video(request: StreamRequest) -> dict:
170189

171190
# Start new stream (handles stopping existing stream internally)
172191
state.start_stream(video_id, request.skip_transcription)
192+
state.set_current(video_id, request.queue_id)
173193

174194
return {
175195
"status": "stream started",
@@ -246,6 +266,7 @@ def stop_stream() -> dict:
246266
"""Stop the current stream."""
247267
state = get_stream_state()
248268
if state.stop_stream():
269+
state.set_current(None, None)
249270
return {"status": "stream stopped"}
250271
raise HTTPException(status_code=400, detail="No stream running")
251272

@@ -254,7 +275,17 @@ def stop_stream() -> dict:
254275
def get_status() -> dict:
255276
"""Get the current streaming status."""
256277
state = get_stream_state()
257-
return {"status": "streaming" if state.is_streaming() else "idle"}
278+
try:
279+
queue_hash = get_queue_hash()
280+
except Exception as e:
281+
logger.warning(f"Failed to compute queue hash: {e}")
282+
queue_hash = 0
283+
return {
284+
"status": "streaming" if state.is_streaming() else "idle",
285+
"current_video_id": state.current_video_id,
286+
"current_queue_id": state.current_queue_id,
287+
"queue_hash": queue_hash,
288+
}
258289

259290

260291
@router.get("/history")

services/database.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -457,6 +457,15 @@ def clear_queue():
457457
logger.info("Queue cleared")
458458

459459

460+
def get_queue_hash() -> int:
461+
"""Return an integer representing current queue state for cheap change detection."""
462+
with get_db_connection() as conn:
463+
cursor = conn.cursor()
464+
cursor.execute("SELECT COUNT(*), COALESCE(MAX(id), 0) FROM queue")
465+
row = cursor.fetchone()
466+
return row[1] * 10000 + row[0]
467+
468+
460469
def reorder_queue(queue_item_ids: List[int]) -> bool:
461470
"""
462471
Reorder queue items by updating their positions.

static/app.js

Lines changed: 84 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,9 @@ const transcriptionEnabled = appConfig.transcriptionEnabled;
1111
let currentVideoId = null;
1212
let currentQueueId = null;
1313
let isPlaying = false;
14+
let lastQueueHash = null;
15+
let loadingQueueId = null;
16+
let isDraggingQueue = false;
1417
let currentTrackTitle = null;
1518
let serverAudioDuration = null; // Authoritative duration from server (ffprobe)
1619
const defaultTitle = 'YouTube Radio';
@@ -389,6 +392,7 @@ player.addEventListener('playing', function () {
389392
}
390393

391394
console.log('Playback started/resumed');
395+
clearQueueItemLoading();
392396
hideStreamStatus();
393397
retryCount = 0; // Reset retry count when successfully playing
394398

@@ -713,6 +717,25 @@ async function fetchSavedPosition(videoId) {
713717
}
714718
}
715719

720+
async function refreshPositionFromServer() {
721+
if (!currentVideoId) return;
722+
try {
723+
const res = await fetch(`/playback-position/${currentVideoId}`);
724+
if (!res.ok) return;
725+
const data = await res.json();
726+
const serverPos = data.position_seconds || 0;
727+
const drift = Math.abs(serverPos - player.currentTime);
728+
if (drift > 15) {
729+
remoteLog('log', 'Position sync: seeking to server position', {
730+
local: Math.round(player.currentTime), server: Math.round(serverPos), drift: Math.round(drift)
731+
});
732+
player.currentTime = serverPos;
733+
}
734+
} catch (e) {
735+
console.warn('Failed to refresh position from server:', e);
736+
}
737+
}
738+
716739
// Queue Management
717740
async function fetchQueue() {
718741
try {
@@ -897,6 +920,7 @@ async function playNext() {
897920
}
898921

899922
async function startStreamFromQueue(youtube_video_id, queue_id) {
923+
setQueueItemLoading(queue_id);
900924
// Reset position save throttle so first save of new track isn't suppressed
901925
lastPositionSaveTime = 0;
902926

@@ -907,7 +931,7 @@ async function startStreamFromQueue(youtube_video_id, queue_id) {
907931
const res = await fetch('/stream', {
908932
method: 'POST',
909933
headers: { 'Content-Type': 'application/json' },
910-
body: JSON.stringify({ youtube_video_id, skip_transcription })
934+
body: JSON.stringify({ youtube_video_id, skip_transcription, queue_id })
911935
});
912936
const data = await res.json();
913937
updateStatus(data.status || data.detail, res.ok ? 'streaming' : 'error');
@@ -973,16 +997,19 @@ async function startStreamFromQueue(youtube_video_id, queue_id) {
973997
}
974998
});
975999
} else {
1000+
clearQueueItemLoading();
9761001
updateStatus('Failed to start stream', 'error');
9771002
}
9781003
} catch (error) {
1004+
clearQueueItemLoading();
9791005
updateStatus('Failed to start stream', 'error');
9801006
console.error(error);
9811007
}
9821008
}
9831009

9841010
async function startSummaryFromQueue(weekYear, queue_id) {
9851011
try {
1012+
setQueueItemLoading(queue_id);
9861013
// For summaries, we directly play the audio file from the server
9871014
updateStatus('Loading weekly summary...', 'streaming');
9881015

@@ -1020,16 +1047,36 @@ async function startSummaryFromQueue(weekYear, queue_id) {
10201047
console.error('Audio playback failed:', e);
10211048
updateStatus('Failed to play summary', 'error');
10221049
isPlaying = false;
1050+
clearQueueItemLoading();
10231051
});
10241052

10251053
updateStatus('Playing: ' + (currentItem ? currentItem.title : 'Weekly Summary'), 'streaming');
10261054
} catch (error) {
1055+
clearQueueItemLoading();
10271056
updateStatus('Failed to play summary', 'error');
10281057
console.error(error);
10291058
isPlaying = false;
10301059
}
10311060
}
10321061

1062+
function setQueueItemLoading(queueId) {
1063+
loadingQueueId = queueId;
1064+
_applyQueueLoadingClass();
1065+
}
1066+
1067+
function clearQueueItemLoading() {
1068+
loadingQueueId = null;
1069+
_applyQueueLoadingClass();
1070+
}
1071+
1072+
function _applyQueueLoadingClass() {
1073+
document.querySelectorAll('.queue-item').forEach(el => el.classList.remove('queue-item-loading'));
1074+
if (loadingQueueId != null) {
1075+
const target = document.querySelector(`.queue-item[data-queue-id="${loadingQueueId}"]`);
1076+
if (target) target.classList.add('queue-item-loading');
1077+
}
1078+
}
1079+
10331080
async function renderQueue() {
10341081
const queueContainer = document.getElementById('queue-list');
10351082
const queueCountEl = document.getElementById('queue-count');
@@ -1086,6 +1133,8 @@ async function renderQueue() {
10861133

10871134
// Initialize drag-and-drop after rendering
10881135
initializeQueueDragAndDrop();
1136+
// Restore loading shimmer if still active (survives DOM rebuild)
1137+
_applyQueueLoadingClass();
10891138
}
10901139

10911140
// Queue drag-and-drop functionality
@@ -1116,13 +1165,15 @@ function initializeQueueDragAndDrop() {
11161165
}
11171166

11181167
function handleDragStart(e) {
1168+
isDraggingQueue = true;
11191169
draggedElement = this;
11201170
this.classList.add('dragging');
11211171
e.dataTransfer.effectAllowed = 'move';
11221172
e.dataTransfer.setData('text/html', this.innerHTML);
11231173
}
11241174

11251175
function handleDragEnd(e) {
1176+
isDraggingQueue = false;
11261177
this.classList.remove('dragging');
11271178
// Remove all drag-over classes
11281179
document.querySelectorAll('.queue-item').forEach(item => {
@@ -1211,6 +1262,7 @@ function handleTouchStart(e) {
12111262

12121263
draggedElement = this;
12131264
isTouchDragging = true;
1265+
isDraggingQueue = true;
12141266

12151267
const touch = e.touches[0];
12161268
touchStartY = touch.clientY;
@@ -1327,6 +1379,7 @@ async function handleTouchEnd(e) {
13271379
draggedElement = null;
13281380
draggedOverElement = null;
13291381
isTouchDragging = false;
1382+
isDraggingQueue = false;
13301383
touchStartY = 0;
13311384
touchCurrentY = 0;
13321385
}
@@ -1769,6 +1822,25 @@ async function fetchStatus() {
17691822
const res = await fetch('/status');
17701823
const data = await res.json();
17711824
updateStatus(data.status, data.status);
1825+
1826+
// Queue sync: detect changes from other devices
1827+
const serverHash = data.queue_hash;
1828+
if (serverHash !== undefined) {
1829+
if (lastQueueHash !== null && lastQueueHash !== serverHash && !isDraggingQueue) {
1830+
remoteLog('log', 'Queue hash changed, refreshing queue', { old: lastQueueHash, new: serverHash });
1831+
await renderQueue();
1832+
}
1833+
lastQueueHash = serverHash;
1834+
}
1835+
1836+
// Sync "now playing" indicator when this device is passive (not playing).
1837+
// No status guard needed: backend correctly sets current_queue_id=null on /stop,
1838+
// so a non-null value always means something was started and not yet stopped.
1839+
const serverQueueId = data.current_queue_id;
1840+
if (serverQueueId !== undefined && serverQueueId !== currentQueueId && !isPlaying) {
1841+
currentQueueId = serverQueueId;
1842+
await renderQueue();
1843+
}
17721844
} catch (error) {
17731845
console.error('Failed to fetch status:', error);
17741846
}
@@ -1929,6 +2001,17 @@ window.onclick = function (event) {
19292001
}
19302002
}
19312003

2004+
// Resync state when tab becomes visible again (e.g. switching from PC to phone)
2005+
document.addEventListener('visibilitychange', async function () {
2006+
if (document.hidden) return;
2007+
remoteLog('log', 'Page became visible, resyncing state');
2008+
await fetchStatus();
2009+
await renderQueue();
2010+
if (currentVideoId && player.paused) {
2011+
await refreshPositionFromServer();
2012+
}
2013+
});
2014+
19322015
// Poll status every 3 seconds
19332016
setInterval(fetchStatus, 3000);
19342017

0 commit comments

Comments
 (0)