Spaces:
Paused
Paused
feat: full story text display with paragraph highlight on slider
Browse files- Load story txt paragraphs on book select (split by blank line)
- Render all paragraphs in scrollable panel; current paragraph highlighted in orange
- Slider max = paragraph count, updates highlight on drag
- Play/Pause show paragraph N / total instead of chunk count
- Story-finished triggers when slider reaches last paragraph
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
app.py
CHANGED
|
@@ -293,6 +293,27 @@ def generate_library_html(books_list, query="", category_filt="All", empty_mode=
|
|
| 293 |
html += "</div>"
|
| 294 |
return html
|
| 295 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 296 |
def render_cloned_voices_html(voices_list):
|
| 297 |
html = """<div class="book-shelf-grid">"""
|
| 298 |
for v in voices_list:
|
|
@@ -330,6 +351,7 @@ with gr.Blocks(css=css_code, title="VoiceBook Gradio Hub") as demo:
|
|
| 330 |
|
| 331 |
books_state = gr.State(mock_books)
|
| 332 |
voices_state = gr.State(mock_voices)
|
|
|
|
| 333 |
|
| 334 |
gr.HTML("""
|
| 335 |
<div style="display: flex; align-items: center; justify-content: space-between; padding: 16px 0; border-bottom: 1px solid #ebdccb; margin-bottom: 24px; flex-wrap: wrap; gap: 16px;">
|
|
@@ -433,15 +455,9 @@ with gr.Blocks(css=css_code, title="VoiceBook Gradio Hub") as demo:
|
|
| 433 |
</div>
|
| 434 |
""", visible=False)
|
| 435 |
|
| 436 |
-
#
|
| 437 |
-
|
| 438 |
-
|
| 439 |
-
“”
|
| 440 |
-
</div>
|
| 441 |
-
""")
|
| 442 |
-
timeline_slider = gr.Slider(minimum=0, maximum=5, step=1, value=0, label="Story position", visible=False)
|
| 443 |
-
|
| 444 |
-
m_synopsis = gr.Textbox(interactive=False, label="Synopsis", lines=3, value="", visible=False)
|
| 445 |
|
| 446 |
# Q&A Panel
|
| 447 |
qa_panel = gr.HTML("""
|
|
@@ -607,19 +623,20 @@ with gr.Blocks(css=css_code, title="VoiceBook Gradio Hub") as demo:
|
|
| 607 |
# 2. Book card click β load into player
|
| 608 |
def handle_book_select(title_chosen, current_inventory):
|
| 609 |
if not title_chosen:
|
| 610 |
-
return [gr.update()] *
|
| 611 |
selected = next((b for b in current_inventory if b["title"] == title_chosen), current_inventory[0])
|
| 612 |
|
| 613 |
player_html = f"""
|
| 614 |
-
<div style="text-align: center; margin-bottom:
|
| 615 |
-
<img src="{selected['cover_url']}" style="width:
|
| 616 |
-
<h3 style="font-family: 'Playfair Display', Georgia, serif; font-size:
|
| 617 |
<p style="font-size: 12px; color: #94a3b8; font-style: italic; margin-top:2px;">by {selected['author']}</p>
|
| 618 |
-
<span style="display: inline-block; background: rgba(245, 132, 31, 0.15); color: #ffd3a6; font-size: 10.5px; font-weight: 700; padding: 3px 8px; border-radius: 999px; margin-top:
|
| 619 |
</div>
|
| 620 |
"""
|
| 621 |
-
|
| 622 |
-
|
|
|
|
| 623 |
status_html = """
|
| 624 |
<div style="margin-top: 12px; display: flex; align-items: center; gap: 10px; justify-content: center;">
|
| 625 |
<span style="width: 8px; height: 8px; border-radius: 50%; background: #94a3b8; display: inline-block;"></span>
|
|
@@ -627,6 +644,7 @@ with gr.Blocks(css=css_code, title="VoiceBook Gradio Hub") as demo:
|
|
| 627 |
</div>
|
| 628 |
"""
|
| 629 |
audio = selected["audio_path"] or "sample_sounds/willow.wav"
|
|
|
|
| 630 |
return (
|
| 631 |
player_html,
|
| 632 |
gr.Audio(value=audio, visible=True),
|
|
@@ -636,14 +654,17 @@ with gr.Blocks(css=css_code, title="VoiceBook Gradio Hub") as demo:
|
|
| 636 |
gr.Button(visible=False), # pause_btn
|
| 637 |
gr.Button(visible=False), # ask_btn
|
| 638 |
gr.HTML(visible=False), # story_finished_panel
|
| 639 |
-
|
|
|
|
|
|
|
| 640 |
)
|
| 641 |
|
| 642 |
book_selector.change(
|
| 643 |
handle_book_select,
|
| 644 |
inputs=[book_selector, books_state],
|
| 645 |
outputs=[player_title_info, player_audio_control, player_status_bar, chunk_status,
|
| 646 |
-
play_btn, pause_btn, ask_btn, story_finished_panel,
|
|
|
|
| 647 |
)
|
| 648 |
|
| 649 |
# 3. Extract vocal weights trigger
|
|
@@ -743,9 +764,9 @@ with gr.Blocks(css=css_code, title="VoiceBook Gradio Hub") as demo:
|
|
| 743 |
)
|
| 744 |
|
| 745 |
# 5. Play button β PLAYING state
|
| 746 |
-
def enter_playing_state(slider_val):
|
| 747 |
-
|
| 748 |
-
chunk_html = f"""<div style="margin-top: 8px; font-size: 10px; color: #4ade80; font-family: monospace; text-align: center;">
|
| 749 |
status_html = """
|
| 750 |
<div style="margin-top: 12px; display: flex; align-items: center; gap: 10px; justify-content: center;">
|
| 751 |
<span style="width: 8px; height: 8px; border-radius: 50%; background: #4ade80; display: inline-block;"></span>
|
|
@@ -762,14 +783,14 @@ with gr.Blocks(css=css_code, title="VoiceBook Gradio Hub") as demo:
|
|
| 762 |
|
| 763 |
play_btn.click(
|
| 764 |
enter_playing_state,
|
| 765 |
-
inputs=[timeline_slider],
|
| 766 |
outputs=[player_status_bar, chunk_status, play_btn, pause_btn, ask_btn]
|
| 767 |
)
|
| 768 |
|
| 769 |
# 6. Pause button β PAUSED state
|
| 770 |
-
def enter_paused_state(slider_val):
|
| 771 |
-
|
| 772 |
-
chunk_html = f"""<div style="margin-top: 8px; font-size: 10px; color: #f59e0b; font-family: monospace; text-align: center;">
|
| 773 |
status_html = """
|
| 774 |
<div style="margin-top: 12px; display: flex; align-items: center; gap: 10px; justify-content: center;">
|
| 775 |
<span style="width: 8px; height: 8px; border-radius: 50%; background: #f59e0b; display: inline-block;"></span>
|
|
@@ -785,17 +806,18 @@ with gr.Blocks(css=css_code, title="VoiceBook Gradio Hub") as demo:
|
|
| 785 |
|
| 786 |
pause_btn.click(
|
| 787 |
enter_paused_state,
|
| 788 |
-
inputs=[timeline_slider],
|
| 789 |
outputs=[player_status_bar, chunk_status, play_btn, pause_btn]
|
| 790 |
)
|
| 791 |
|
| 792 |
-
# 7. Timeline slider β
|
| 793 |
-
def
|
| 794 |
-
idx = int(
|
| 795 |
-
|
| 796 |
-
|
|
|
|
| 797 |
|
| 798 |
-
timeline_slider.change(
|
| 799 |
|
| 800 |
# 8. Ask button β PAUSED-ASKING state
|
| 801 |
def enter_asking_state():
|
|
@@ -883,12 +905,12 @@ with gr.Blocks(css=css_code, title="VoiceBook Gradio Hub") as demo:
|
|
| 883 |
)
|
| 884 |
|
| 885 |
# 11. Story-finished check
|
| 886 |
-
def check_story_finished(slider_val):
|
| 887 |
-
if int(slider_val) >=
|
| 888 |
return gr.HTML(visible=True)
|
| 889 |
return gr.HTML(visible=False)
|
| 890 |
|
| 891 |
-
timeline_slider.change(check_story_finished, inputs=[timeline_slider], outputs=[story_finished_panel])
|
| 892 |
|
| 893 |
# 12. Sandbox outage toggle
|
| 894 |
def toggle_outage_sandbox(checked):
|
|
|
|
| 293 |
html += "</div>"
|
| 294 |
return html
|
| 295 |
|
| 296 |
+
def load_paragraphs(story_path: str) -> list:
|
| 297 |
+
try:
|
| 298 |
+
with open(story_path, encoding="utf-8") as f:
|
| 299 |
+
raw = f.read()
|
| 300 |
+
paras = [p.strip() for p in raw.split("\n\n") if p.strip()]
|
| 301 |
+
return paras
|
| 302 |
+
except Exception:
|
| 303 |
+
return []
|
| 304 |
+
|
| 305 |
+
def render_story_text(paragraphs: list, current_idx: int) -> str:
|
| 306 |
+
if not paragraphs:
|
| 307 |
+
return ""
|
| 308 |
+
html = """<div style="max-height: 420px; overflow-y: auto; padding: 4px 2px; margin-top: 12px;">"""
|
| 309 |
+
for i, para in enumerate(paragraphs):
|
| 310 |
+
if i == current_idx:
|
| 311 |
+
html += f"""<p style="font-family: 'Playfair Display', Georgia, serif; font-size: 13px; line-height: 1.8; color: #FAF7F2; background: rgba(245,132,31,0.18); border-left: 3px solid #f5841f; padding: 8px 12px; border-radius: 6px; margin: 6px 0;">{para}</p>"""
|
| 312 |
+
else:
|
| 313 |
+
html += f"""<p style="font-family: 'Playfair Display', Georgia, serif; font-size: 13px; line-height: 1.8; color: #94a3b8; padding: 4px 12px; margin: 4px 0;">{para}</p>"""
|
| 314 |
+
html += "</div>"
|
| 315 |
+
return html
|
| 316 |
+
|
| 317 |
def render_cloned_voices_html(voices_list):
|
| 318 |
html = """<div class="book-shelf-grid">"""
|
| 319 |
for v in voices_list:
|
|
|
|
| 351 |
|
| 352 |
books_state = gr.State(mock_books)
|
| 353 |
voices_state = gr.State(mock_voices)
|
| 354 |
+
paragraphs_state = gr.State([])
|
| 355 |
|
| 356 |
gr.HTML("""
|
| 357 |
<div style="display: flex; align-items: center; justify-content: space-between; padding: 16px 0; border-bottom: 1px solid #ebdccb; margin-bottom: 24px; flex-wrap: wrap; gap: 16px;">
|
|
|
|
| 455 |
</div>
|
| 456 |
""", visible=False)
|
| 457 |
|
| 458 |
+
# Story text display + slider
|
| 459 |
+
story_text_display = gr.HTML(visible=False)
|
| 460 |
+
timeline_slider = gr.Slider(minimum=0, maximum=0, step=1, value=0, label="π Story position (paragraph)", visible=False)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 461 |
|
| 462 |
# Q&A Panel
|
| 463 |
qa_panel = gr.HTML("""
|
|
|
|
| 623 |
# 2. Book card click β load into player
|
| 624 |
def handle_book_select(title_chosen, current_inventory):
|
| 625 |
if not title_chosen:
|
| 626 |
+
return [gr.update()] * 12
|
| 627 |
selected = next((b for b in current_inventory if b["title"] == title_chosen), current_inventory[0])
|
| 628 |
|
| 629 |
player_html = f"""
|
| 630 |
+
<div style="text-align: center; margin-bottom: 16px; margin-top: 8px;">
|
| 631 |
+
<img src="{selected['cover_url']}" style="width: 110px; height: 155px; object-fit: cover; border-radius: 12px; box-shadow: 0 4px 16px rgba(0,0,0,0.4); margin-bottom: 12px;" />
|
| 632 |
+
<h3 style="font-family: 'Playfair Display', Georgia, serif; font-size: 20px; margin:0; color:#FAF7F2;">{selected['title']}</h3>
|
| 633 |
<p style="font-size: 12px; color: #94a3b8; font-style: italic; margin-top:2px;">by {selected['author']}</p>
|
| 634 |
+
<span style="display: inline-block; background: rgba(245, 132, 31, 0.15); color: #ffd3a6; font-size: 10.5px; font-weight: 700; padding: 3px 8px; border-radius: 999px; margin-top: 6px;">Active Narrator: {selected['voice_name']}</span>
|
| 635 |
</div>
|
| 636 |
"""
|
| 637 |
+
paras = load_paragraphs(selected["story_path"])
|
| 638 |
+
total = max(len(paras) - 1, 0)
|
| 639 |
+
chunk_html = f"""<div style="margin-top: 8px; font-size: 10px; color: #64748b; font-family: monospace; text-align: center;">Paragraph 1 / {len(paras)} β ready to play</div>"""
|
| 640 |
status_html = """
|
| 641 |
<div style="margin-top: 12px; display: flex; align-items: center; gap: 10px; justify-content: center;">
|
| 642 |
<span style="width: 8px; height: 8px; border-radius: 50%; background: #94a3b8; display: inline-block;"></span>
|
|
|
|
| 644 |
</div>
|
| 645 |
"""
|
| 646 |
audio = selected["audio_path"] or "sample_sounds/willow.wav"
|
| 647 |
+
story_html = render_story_text(paras, 0)
|
| 648 |
return (
|
| 649 |
player_html,
|
| 650 |
gr.Audio(value=audio, visible=True),
|
|
|
|
| 654 |
gr.Button(visible=False), # pause_btn
|
| 655 |
gr.Button(visible=False), # ask_btn
|
| 656 |
gr.HTML(visible=False), # story_finished_panel
|
| 657 |
+
paras, # paragraphs_state
|
| 658 |
+
gr.HTML(value=story_html, visible=True), # story_text_display
|
| 659 |
+
gr.Slider(minimum=0, maximum=total, step=1, value=0, visible=True), # timeline_slider
|
| 660 |
)
|
| 661 |
|
| 662 |
book_selector.change(
|
| 663 |
handle_book_select,
|
| 664 |
inputs=[book_selector, books_state],
|
| 665 |
outputs=[player_title_info, player_audio_control, player_status_bar, chunk_status,
|
| 666 |
+
play_btn, pause_btn, ask_btn, story_finished_panel,
|
| 667 |
+
paragraphs_state, story_text_display, timeline_slider]
|
| 668 |
)
|
| 669 |
|
| 670 |
# 3. Extract vocal weights trigger
|
|
|
|
| 764 |
)
|
| 765 |
|
| 766 |
# 5. Play button β PLAYING state
|
| 767 |
+
def enter_playing_state(slider_val, paras):
|
| 768 |
+
total = len(paras) if paras else 1
|
| 769 |
+
chunk_html = f"""<div style="margin-top: 8px; font-size: 10px; color: #4ade80; font-family: monospace; text-align: center;">Paragraph {int(slider_val) + 1} / {total} β streaming</div>"""
|
| 770 |
status_html = """
|
| 771 |
<div style="margin-top: 12px; display: flex; align-items: center; gap: 10px; justify-content: center;">
|
| 772 |
<span style="width: 8px; height: 8px; border-radius: 50%; background: #4ade80; display: inline-block;"></span>
|
|
|
|
| 783 |
|
| 784 |
play_btn.click(
|
| 785 |
enter_playing_state,
|
| 786 |
+
inputs=[timeline_slider, paragraphs_state],
|
| 787 |
outputs=[player_status_bar, chunk_status, play_btn, pause_btn, ask_btn]
|
| 788 |
)
|
| 789 |
|
| 790 |
# 6. Pause button β PAUSED state
|
| 791 |
+
def enter_paused_state(slider_val, paras):
|
| 792 |
+
total = len(paras) if paras else 1
|
| 793 |
+
chunk_html = f"""<div style="margin-top: 8px; font-size: 10px; color: #f59e0b; font-family: monospace; text-align: center;">Paragraph {int(slider_val) + 1} / {total} β paused</div>"""
|
| 794 |
status_html = """
|
| 795 |
<div style="margin-top: 12px; display: flex; align-items: center; gap: 10px; justify-content: center;">
|
| 796 |
<span style="width: 8px; height: 8px; border-radius: 50%; background: #f59e0b; display: inline-block;"></span>
|
|
|
|
| 806 |
|
| 807 |
pause_btn.click(
|
| 808 |
enter_paused_state,
|
| 809 |
+
inputs=[timeline_slider, paragraphs_state],
|
| 810 |
outputs=[player_status_bar, chunk_status, play_btn, pause_btn]
|
| 811 |
)
|
| 812 |
|
| 813 |
+
# 7. Timeline slider β highlight current paragraph
|
| 814 |
+
def update_story_highlight(slider_val, paras):
|
| 815 |
+
idx = int(slider_val)
|
| 816 |
+
total = len(paras)
|
| 817 |
+
chunk_html = f"""<div style="margin-top: 8px; font-size: 10px; color: #64748b; font-family: monospace; text-align: center;">Paragraph {idx + 1} / {total}</div>"""
|
| 818 |
+
return render_story_text(paras, idx), chunk_html
|
| 819 |
|
| 820 |
+
timeline_slider.change(update_story_highlight, inputs=[timeline_slider, paragraphs_state], outputs=[story_text_display, chunk_status])
|
| 821 |
|
| 822 |
# 8. Ask button β PAUSED-ASKING state
|
| 823 |
def enter_asking_state():
|
|
|
|
| 905 |
)
|
| 906 |
|
| 907 |
# 11. Story-finished check
|
| 908 |
+
def check_story_finished(slider_val, paras):
|
| 909 |
+
if paras and int(slider_val) >= len(paras) - 1:
|
| 910 |
return gr.HTML(visible=True)
|
| 911 |
return gr.HTML(visible=False)
|
| 912 |
|
| 913 |
+
timeline_slider.change(check_story_finished, inputs=[timeline_slider, paragraphs_state], outputs=[story_finished_panel])
|
| 914 |
|
| 915 |
# 12. Sandbox outage toggle
|
| 916 |
def toggle_outage_sandbox(checked):
|