| """ |
| utils/workout_components.py β Reusable UI components for workout pages. |
| Includes: countdown timer, safety cautions, pre/post stretch video embeds. |
| """ |
|
|
| import streamlit as st |
| import json, os |
|
|
| |
| |
| |
|
|
| PRE_WORKOUT_CAUTIONS = [ |
| ("π₯", "Always warm up for at least 5 minutes before starting"), |
| ("π§", "Drink water before you begin β stay hydrated throughout"), |
| ("π©Ί", "If you have any injury or pain, consult a doctor first"), |
| ("π", "Wear proper footwear and workout on a non-slip surface"), |
| ("π΄", "Never train on no sleep β rest is part of the programme"), |
| ("π§", "Take deep breaths β controlled breathing improves performance"), |
| ] |
|
|
| DURING_WORKOUT_CAUTIONS = [ |
| ("β
", "Maintain proper form β quality over quantity always"), |
| ("π", "Stop immediately if you feel sharp pain or dizziness"), |
| ("π§", "Sip water between sets β do not chug large amounts"), |
| ("β±οΈ", "Respect your rest periods β they are part of the programme"), |
| ("π", "Control the movement in both directions (concentric + eccentric)"), |
| ("π", "Use full range of motion unless instructed otherwise"), |
| ] |
|
|
| POST_WORKOUT_CAUTIONS = [ |
| ("π§", "Cool down for 5β10 minutes β never stop suddenly"), |
| ("π₯", "Eat a protein + carb meal within 45 minutes of finishing"), |
| ("π§", "Rehydrate β drink at least 500ml water post workout"), |
| ("π΄", "Get 7β9 hours of sleep for muscle recovery"), |
| ("π
", "Log your workout β tracking drives progress"), |
| ("πΏ", "Shower after workout to prevent skin irritation"), |
| ] |
|
|
|
|
| def render_safety_cautions(phase="pre"): |
| """ |
| Render safety caution cards. |
| phase: 'pre' | 'during' | 'post' |
| """ |
| cautions_map = { |
| "pre": (PRE_WORKOUT_CAUTIONS, "β οΈ PRE-WORKOUT SAFETY CAUTIONS", "rgba(251,191,36,0.12)", "rgba(251,191,36,0.30)"), |
| "during": (DURING_WORKOUT_CAUTIONS, "π‘οΈ DURING WORKOUT β STAY SAFE", "rgba(239,68,68,0.10)", "rgba(239,68,68,0.28)"), |
| "post": (POST_WORKOUT_CAUTIONS, "β
POST-WORKOUT RECOVERY TIPS", "rgba(34,197,94,0.10)", "rgba(34,197,94,0.28)"), |
| } |
|
|
| cautions, title, bg, border = cautions_map.get(phase, cautions_map["pre"]) |
|
|
| items_html = "".join( |
| f"""<div style='display:flex;align-items:flex-start;gap:12px; |
| padding:9px 0;border-bottom:1px solid rgba(255,255,255,0.04)'> |
| <span style='font-size:1.1rem;flex-shrink:0'>{icon}</span> |
| <span style='font-size:0.83rem;color:rgba(255,255,255,0.75); |
| line-height:1.5'>{text}</span> |
| </div>""" |
| for icon, text in cautions |
| ) |
|
|
| st.markdown(f""" |
| <div style='background:{bg};border:1px solid {border}; |
| border-radius:14px;padding:20px 24px;margin:16px 0'> |
| <div style='font-size:0.60rem;font-weight:700;letter-spacing:3px; |
| text-transform:uppercase;color:rgba(255,255,255,0.60); |
| margin-bottom:14px'>{title}</div> |
| {items_html} |
| </div> |
| """, unsafe_allow_html=True) |
|
|
|
|
| |
| |
| |
|
|
| def render_exercise_timer(exercise_name, seconds, timer_key): |
| """ |
| Render a JavaScript countdown timer for an exercise. |
| |
| exercise_name : display name of the exercise |
| seconds : timer duration in seconds |
| timer_key : unique key to avoid conflicts between multiple timers |
| """ |
| mins = seconds // 60 |
| secs = seconds % 60 |
| display_time = f"{mins:02d}:{secs:02d}" if mins > 0 else f"{seconds}s" |
|
|
| st.markdown(f""" |
| <div id="timer-container-{timer_key}" style=' |
| background:rgba(10,6,4,0.70); |
| border:1.5px solid rgba(229,9,20,0.35); |
| border-radius:16px;padding:24px 20px; |
| text-align:center;margin:12px 0; |
| backdrop-filter:blur(12px); |
| box-shadow:0 0 20px rgba(229,9,20,0.15); |
| position:relative;overflow:hidden;'> |
| |
| <!-- red top line --> |
| <div style='position:absolute;top:0;left:0;right:0;height:2px; |
| background:linear-gradient(90deg,transparent, |
| rgba(229,9,20,0.60),transparent)'></div> |
| |
| <!-- Exercise label --> |
| <div style='font-size:0.60rem;font-weight:700;letter-spacing:3px; |
| text-transform:uppercase;color:rgba(229,9,20,0.70); |
| margin-bottom:8px'>β± TIMER</div> |
| <div style='font-size:0.92rem;color:rgba(255,255,255,0.65); |
| margin-bottom:12px;font-weight:500'>{exercise_name}</div> |
| |
| <!-- Timer display --> |
| <div id="timer-display-{timer_key}" style=' |
| font-family:Bebas Neue,sans-serif; |
| font-size:3.8rem;color:#E50914; |
| letter-spacing:4px;line-height:1; |
| text-shadow:0 0 30px rgba(229,9,20,0.55); |
| margin-bottom:16px; |
| transition:color 0.3s;'>{display_time}</div> |
| |
| <!-- Progress ring (CSS circle) --> |
| <div style='margin:0 auto 16px;width:80px;height:6px; |
| background:rgba(255,255,255,0.08);border-radius:3px;overflow:hidden'> |
| <div id="timer-bar-{timer_key}" style='width:100%;height:100%; |
| background:linear-gradient(90deg,#E50914,#ff4444); |
| border-radius:3px;transition:width 0.9s linear'></div> |
| </div> |
| |
| <!-- Buttons --> |
| <div style='display:flex;gap:10px;justify-content:center;flex-wrap:wrap'> |
| <button onclick="startTimer_{timer_key}()" id="btn-start-{timer_key}" |
| style='background:linear-gradient(135deg,#E50914,#c0000c); |
| border:none;color:white;padding:10px 24px;border-radius:8px; |
| cursor:pointer;font-size:0.85rem;font-weight:700; |
| letter-spacing:1px;text-transform:uppercase; |
| box-shadow:0 0 16px rgba(229,9,20,0.45); |
| transition:all 0.2s'>βΆ START</button> |
| <button onclick="pauseTimer_{timer_key}()" id="btn-pause-{timer_key}" |
| style='background:rgba(255,255,255,0.08); |
| border:1px solid rgba(255,255,255,0.20); |
| color:rgba(255,255,255,0.70);padding:10px 20px; |
| border-radius:8px;cursor:pointer;font-size:0.85rem; |
| font-weight:700;letter-spacing:1px;text-transform:uppercase; |
| transition:all 0.2s'>βΈ PAUSE</button> |
| <button onclick="resetTimer_{timer_key}()" id="btn-reset-{timer_key}" |
| style='background:rgba(255,255,255,0.05); |
| border:1px solid rgba(255,255,255,0.12); |
| color:rgba(255,255,255,0.50);padding:10px 18px; |
| border-radius:8px;cursor:pointer;font-size:0.85rem; |
| font-weight:700;letter-spacing:1px;text-transform:uppercase'>βΊ RESET</button> |
| </div> |
| |
| <!-- Done message (hidden) --> |
| <div id="timer-done-{timer_key}" style='display:none;margin-top:14px; |
| font-family:Bebas Neue,sans-serif;font-size:1.6rem; |
| letter-spacing:3px;color:#22c55e; |
| text-shadow:0 0 20px rgba(34,197,94,0.55)'> |
| β TIME'S UP! WELL DONE! |
| </div> |
| </div> |
| |
| <script> |
| (function() {{ |
| const TOTAL_{timer_key} = {seconds}; |
| let timeLeft_{timer_key} = TOTAL_{timer_key}; |
| let running_{timer_key} = false; |
| let interval_{timer_key} = null; |
| |
| function formatTime(s) {{ |
| const m = Math.floor(s / 60); |
| const sec = s % 60; |
| if (m > 0) return String(m).padStart(2,'0') + ':' + String(sec).padStart(2,'0'); |
| return s + 's'; |
| }} |
| |
| function updateDisplay() {{ |
| const disp = document.getElementById('timer-display-{timer_key}'); |
| const bar = document.getElementById('timer-bar-{timer_key}'); |
| const done = document.getElementById('timer-done-{timer_key}'); |
| if (!disp) return; |
| |
| const pct = (timeLeft_{timer_key} / TOTAL_{timer_key}) * 100; |
| disp.textContent = formatTime(timeLeft_{timer_key}); |
| if (bar) bar.style.width = pct + '%'; |
| |
| // Change color when low |
| if (timeLeft_{timer_key} <= 5) {{ |
| disp.style.color = '#ef4444'; |
| disp.style.textShadow = '0 0 40px rgba(239,68,68,0.80)'; |
| }} else if (timeLeft_{timer_key} <= 10) {{ |
| disp.style.color = '#fbbf24'; |
| disp.style.textShadow = '0 0 30px rgba(251,191,36,0.60)'; |
| }} |
| |
| if (timeLeft_{timer_key} <= 0) {{ |
| clearInterval(interval_{timer_key}); |
| running_{timer_key} = false; |
| disp.textContent = 'β'; |
| disp.style.color = '#22c55e'; |
| disp.style.textShadow = '0 0 30px rgba(34,197,94,0.70)'; |
| if (bar) bar.style.width = '0%'; |
| if (done) done.style.display = 'block'; |
| // Play beep if available |
| try {{ |
| const ctx = new (window.AudioContext || window.webkitAudioContext)(); |
| [880, 1100, 1320].forEach((freq, i) => {{ |
| const osc = ctx.createOscillator(); |
| const gain = ctx.createGain(); |
| osc.connect(gain); gain.connect(ctx.destination); |
| osc.frequency.value = freq; |
| gain.gain.setValueAtTime(0.3, ctx.currentTime + i*0.18); |
| gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + i*0.18 + 0.3); |
| osc.start(ctx.currentTime + i*0.18); |
| osc.stop(ctx.currentTime + i*0.18 + 0.3); |
| }}); |
| }} catch(e) {{}} |
| }} |
| }} |
| |
| window['startTimer_{timer_key}'] = function() {{ |
| if (running_{timer_key} || timeLeft_{timer_key} <= 0) return; |
| running_{timer_key} = true; |
| const done = document.getElementById('timer-done-{timer_key}'); |
| if (done) done.style.display = 'none'; |
| interval_{timer_key} = setInterval(() => {{ |
| timeLeft_{timer_key}--; |
| updateDisplay(); |
| }}, 1000); |
| }}; |
| |
| window['pauseTimer_{timer_key}'] = function() {{ |
| if (!running_{timer_key}) return; |
| clearInterval(interval_{timer_key}); |
| running_{timer_key} = false; |
| }}; |
| |
| window['resetTimer_{timer_key}'] = function() {{ |
| clearInterval(interval_{timer_key}); |
| running_{timer_key} = false; |
| timeLeft_{timer_key} = TOTAL_{timer_key}; |
| const disp = document.getElementById('timer-display-{timer_key}'); |
| const bar = document.getElementById('timer-bar-{timer_key}'); |
| const done = document.getElementById('timer-done-{timer_key}'); |
| if (disp) {{ disp.textContent = formatTime(TOTAL_{timer_key}); disp.style.color = '#E50914'; disp.style.textShadow = '0 0 30px rgba(229,9,20,0.55)'; }} |
| if (bar) bar.style.width = '100%'; |
| if (done) done.style.display = 'none'; |
| }}; |
| }})(); |
| </script> |
| """, unsafe_allow_html=True) |
|
|
|
|
| |
| |
| |
|
|
| def _load_stretch_videos(): |
| """Load stretch video data from JSON file.""" |
| paths = [ |
| "data/stretch_videos.json", |
| os.path.join(os.path.dirname(__file__), "..", "data", "stretch_videos.json"), |
| ] |
| for p in paths: |
| if os.path.exists(p): |
| try: |
| return json.load(open(p)) |
| except Exception: |
| pass |
| |
| return { |
| "pre_workout": [ |
| {"name": "Full Body Warm-Up", "duration": "5 min", |
| "url": "https://www.youtube.com/embed/HDiHMHBpHBQ"}, |
| ], |
| "post_workout": [ |
| {"name": "Full Body Cool Down", "duration": "5 min", |
| "url": "https://www.youtube.com/embed/qULTwquOuT4"}, |
| ] |
| } |
|
|
|
|
| def render_stretch_videos(phase="pre"): |
| """ |
| Render embedded YouTube stretch/warm-up videos. |
| phase: 'pre' | 'post' |
| """ |
| data = _load_stretch_videos() |
| key = "pre_workout" if phase == "pre" else "post_workout" |
| videos = data.get(key, []) |
|
|
| if not videos: |
| return |
|
|
| title = "π₯ PRE-WORKOUT WARM-UP VIDEOS" if phase == "pre" else "π§ POST-WORKOUT COOL-DOWN VIDEOS" |
| border_color = "rgba(251,191,36,0.30)" if phase == "pre" else "rgba(34,197,94,0.28)" |
| header_color = "#fbbf24" if phase == "pre" else "#22c55e" |
|
|
| st.markdown(f""" |
| <div style='border-top:1px solid {border_color};padding-top:20px;margin-top:20px'> |
| <div style='font-size:0.60rem;font-weight:700;letter-spacing:3px; |
| text-transform:uppercase;color:{header_color}; |
| margin-bottom:16px;display:flex;align-items:center;gap:8px'> |
| <span style='display:block;width:18px;height:1.5px; |
| background:{header_color};border-radius:1px'></span> |
| {title} |
| </div> |
| </div> |
| """, unsafe_allow_html=True) |
|
|
| cols = st.columns(min(len(videos), 3)) |
| for col, video in zip(cols, videos): |
| with col: |
| st.markdown(f""" |
| <div style='background:rgba(10,6,4,0.60); |
| border:1px solid rgba(255,255,255,0.08); |
| border-radius:12px;overflow:hidden;margin-bottom:12px'> |
| <div style='position:relative;padding-bottom:56.25%;height:0;overflow:hidden'> |
| <iframe src="{video['url']}" |
| style='position:absolute;top:0;left:0;width:100%;height:100%;border:none' |
| allow="accelerometer;autoplay;clipboard-write;encrypted-media; |
| gyroscope;picture-in-picture" |
| allowfullscreen></iframe> |
| </div> |
| <div style='padding:10px 14px'> |
| <div style='font-size:0.82rem;font-weight:600;color:#fff; |
| margin-bottom:4px'>{video['name']}</div> |
| <div style='font-size:0.70rem;color:rgba(255,255,255,0.40)'> |
| β± {video['duration']} |
| </div> |
| </div> |
| </div> |
| """, unsafe_allow_html=True) |
|
|
|
|
| |
| |
| |
|
|
| def render_full_workout_day(day_data, day_num): |
| """ |
| Render a complete workout day with: |
| 1. Pre-workout cautions |
| 2. Pre-workout stretch videos |
| 3. Exercise cards with individual timers |
| 4. During-workout cautions |
| 5. Post-workout stretch videos |
| 6. Post-workout cautions |
| """ |
| exercises = day_data.get("workout", []) |
| pre_stretch = day_data.get("pre_stretch", []) |
| post_stretch= day_data.get("post_stretch", []) |
| muscle_grp = day_data.get("muscle_group", "Full Body") |
|
|
| |
| st.markdown(f""" |
| <div style='display:flex;align-items:center;gap:12px;margin-bottom:20px'> |
| <div style='font-family:Barlow Condensed,sans-serif;font-size:2.2rem; |
| font-weight:900;text-transform:uppercase;color:#fff'> |
| Day {day_num} |
| <span style='color:#E50914'>β {muscle_grp}</span> |
| </div> |
| </div> |
| """, unsafe_allow_html=True) |
|
|
| |
| with st.expander("β οΈ Pre-Workout Safety Cautions β Read Before Starting", expanded=False): |
| render_safety_cautions("pre") |
|
|
| |
| with st.expander("π₯ Warm-Up Videos β Watch Before Starting", expanded=False): |
| if pre_stretch: |
| |
| for stretch in pre_stretch: |
| st.markdown(f""" |
| <div style='display:flex;align-items:center;gap:12px; |
| padding:10px 0;border-bottom:1px solid rgba(255,255,255,0.05)'> |
| <span style='color:#fbbf24;font-size:1.1rem'>π₯</span> |
| <div> |
| <div style='font-size:0.88rem;color:#fff;font-weight:600'> |
| {stretch.get('name','Stretch')}</div> |
| <div style='font-size:0.72rem;color:rgba(255,255,255,0.42)'> |
| β± {stretch.get('duration','30s')}</div> |
| </div> |
| </div> |
| """, unsafe_allow_html=True) |
| render_stretch_videos("pre") |
|
|
| st.markdown("<div style='height:8px'></div>", unsafe_allow_html=True) |
|
|
| |
| st.markdown(""" |
| <div style='font-size:0.62rem;font-weight:700;letter-spacing:3px; |
| text-transform:uppercase;color:rgba(229,9,20,0.75); |
| margin-bottom:16px;display:flex;align-items:center;gap:8px'> |
| <span style='display:block;width:18px;height:1.5px; |
| background:#E50914;border-radius:1px'></span> |
| πͺ MAIN WORKOUT |
| </div> |
| """, unsafe_allow_html=True) |
|
|
| EX_ICONS = ["ποΈ","πͺ","π","β¬οΈ","π¦΅","π€Έ","π","π΄","π§","π₯","π","π€Ό"] |
|
|
| for idx, ex in enumerate(exercises): |
| name = ex.get("name", f"Exercise {idx+1}") |
| sets = ex.get("sets", 3) |
| reps = ex.get("reps", "12") |
| rest = ex.get("rest", "60s") |
| timer = ex.get("timer", 60) |
| notes = ex.get("notes", "Maintain proper form throughout") |
| icon = EX_ICONS[idx % len(EX_ICONS)] |
|
|
| with st.expander(f"{icon} {name} β {sets} sets Γ {reps} reps", expanded=False): |
| col_info, col_timer = st.columns([1.4, 1]) |
|
|
| with col_info: |
| st.markdown(f""" |
| <div style='background:rgba(10,6,4,0.55); |
| border:1px solid rgba(229,9,20,0.18); |
| border-radius:12px;padding:18px 20px;height:100%'> |
| <div style='font-size:0.60rem;font-weight:700;letter-spacing:2.5px; |
| text-transform:uppercase; |
| color:rgba(229,9,20,0.70);margin-bottom:12px'> |
| EXERCISE DETAILS |
| </div> |
| <div style='display:flex;gap:10px;margin-bottom:14px;flex-wrap:wrap'> |
| <div style='background:rgba(100,160,255,0.13); |
| border:1px solid rgba(100,160,255,0.24); |
| border-radius:8px;padding:8px 14px;text-align:center'> |
| <div style='font-size:1.3rem;font-weight:800;color:#93c5fd'>{sets}</div> |
| <div style='font-size:0.50rem;letter-spacing:1.5px; |
| color:rgba(255,255,255,0.45)'>SETS</div> |
| </div> |
| <div style='background:rgba(100,230,180,0.11); |
| border:1px solid rgba(100,230,180,0.21); |
| border-radius:8px;padding:8px 14px;text-align:center'> |
| <div style='font-size:1.3rem;font-weight:800;color:#6ee7b7'>{reps}</div> |
| <div style='font-size:0.50rem;letter-spacing:1.5px; |
| color:rgba(255,255,255,0.45)'>REPS</div> |
| </div> |
| <div style='background:rgba(255,180,80,0.11); |
| border:1px solid rgba(255,180,80,0.21); |
| border-radius:8px;padding:8px 14px;text-align:center'> |
| <div style='font-size:1.3rem;font-weight:800;color:#fdba74'>{rest}</div> |
| <div style='font-size:0.50rem;letter-spacing:1.5px; |
| color:rgba(255,255,255,0.45)'>REST</div> |
| </div> |
| </div> |
| <div style='font-size:0.75rem;color:rgba(255,255,255,0.60); |
| line-height:1.6;border-top:1px solid rgba(255,255,255,0.07); |
| padding-top:10px'> |
| <span style='color:rgba(229,9,20,0.70);font-weight:700'>Form tip: </span> |
| {notes} |
| </div> |
| </div> |
| """, unsafe_allow_html=True) |
|
|
| with col_timer: |
| render_exercise_timer(name, timer, f"d{day_num}_ex{idx}") |
|
|
| |
| with st.expander("π‘οΈ During Workout β Safety Reminders", expanded=False): |
| render_safety_cautions("during") |
|
|
| st.markdown("<div style='height:8px'></div>", unsafe_allow_html=True) |
|
|
| |
| with st.expander("π§ Cool-Down Videos β Do These After Workout", expanded=False): |
| if post_stretch: |
| for stretch in post_stretch: |
| st.markdown(f""" |
| <div style='display:flex;align-items:center;gap:12px; |
| padding:10px 0;border-bottom:1px solid rgba(255,255,255,0.05)'> |
| <span style='color:#22c55e;font-size:1.1rem'>π§</span> |
| <div> |
| <div style='font-size:0.88rem;color:#fff;font-weight:600'> |
| {stretch.get('name','Cool-down')}</div> |
| <div style='font-size:0.72rem;color:rgba(255,255,255,0.42)'> |
| β± {stretch.get('duration','30s')}</div> |
| </div> |
| </div> |
| """, unsafe_allow_html=True) |
| render_stretch_videos("post") |
|
|
| |
| with st.expander("β
Post-Workout Recovery Tips", expanded=False): |
| render_safety_cautions("post") |