Commit 5d07af6

mo khan <mo@mokhan.ca>
2025-07-04 05:13:50
feat: implement CJSW music discovery feature main
Added music discovery screen with CJSW radio program browsing: - Added MusicDiscovery screen with genre-based navigation - Implemented CJSW show data structure with 8 sample programs - Added keyboard controls: 'm' key to access from episode/now playing screens - Genre browsing with j/k navigation showing show schedules - Updated help text to include music discovery option 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 99b5dd9
src/app.rs
@@ -8,6 +8,7 @@ pub enum CurrentScreen {
     FeedList,
     EpisodeList,
     NowPlaying,
+    MusicDiscovery,
 }
 
 pub struct App {
@@ -19,6 +20,8 @@ pub struct App {
     pub player: Player,
     pub current_track: Option<CurrentTrack>,
     pub volume: u8,
+    pub selected_genre: usize,
+    pub cjsw_shows: Vec<CjswShow>,
     feed_receiver: mpsc::Receiver<(usize, Feed)>,
 }
 
@@ -30,6 +33,15 @@ pub struct CurrentTrack {
     pub _position: f64, // seconds
 }
 
+#[derive(Debug, Clone)]
+pub struct CjswShow {
+    pub name: String,
+    pub genre: String,
+    pub time_slot: String,
+    pub description: String,
+    pub day: String,
+}
+
 impl App {
     pub fn new() -> Result<Self> {
         let config = Config::load()?;
@@ -64,6 +76,8 @@ impl App {
             player: Player::new()?,
             current_track: None,
             volume: 70, // Default volume 70%
+            selected_genre: 0,
+            cjsw_shows: Self::load_cjsw_shows(),
             feed_receiver: receiver,
         })
     }
@@ -212,4 +226,112 @@ impl App {
             }
         }
     }
+
+    // Music discovery navigation
+    pub fn go_to_music_discovery(&mut self) {
+        self.current_screen = CurrentScreen::MusicDiscovery;
+        self.selected_genre = 0;
+    }
+
+    pub fn next_genre(&mut self) {
+        if !self.cjsw_shows.is_empty() {
+            let unique_genres = self.get_unique_genres();
+            if !unique_genres.is_empty() {
+                self.selected_genre = (self.selected_genre + 1) % unique_genres.len();
+            }
+        }
+    }
+
+    pub fn previous_genre(&mut self) {
+        if !self.cjsw_shows.is_empty() {
+            let unique_genres = self.get_unique_genres();
+            if !unique_genres.is_empty() {
+                self.selected_genre = if self.selected_genre == 0 {
+                    unique_genres.len() - 1
+                } else {
+                    self.selected_genre - 1
+                };
+            }
+        }
+    }
+
+    pub fn get_unique_genres(&self) -> Vec<String> {
+        let mut genres: Vec<String> = self.cjsw_shows
+            .iter()
+            .map(|show| show.genre.clone())
+            .collect::<std::collections::HashSet<_>>()
+            .into_iter()
+            .collect();
+        genres.sort();
+        genres
+    }
+
+    pub fn get_shows_by_genre(&self, genre: &str) -> Vec<&CjswShow> {
+        self.cjsw_shows
+            .iter()
+            .filter(|show| show.genre == genre)
+            .collect()
+    }
+
+    // Load CJSW shows data
+    fn load_cjsw_shows() -> Vec<CjswShow> {
+        vec![
+            CjswShow {
+                name: "Black Milk".to_string(),
+                genre: "Electronic/Experimental".to_string(),
+                time_slot: "Monday 1:00-3:00 AM".to_string(),
+                description: "Electronic and experimental music exploration".to_string(),
+                day: "Monday".to_string(),
+            },
+            CjswShow {
+                name: "Soular Power".to_string(),
+                genre: "R&B/Soul".to_string(),
+                time_slot: "Tuesday 7:00-9:00 PM".to_string(),
+                description: "Classic and contemporary R&B and soul".to_string(),
+                day: "Tuesday".to_string(),
+            },
+            CjswShow {
+                name: "Fade to Bass".to_string(),
+                genre: "Electronic".to_string(),
+                time_slot: "Friday 11:00 PM-1:00 AM".to_string(),
+                description: "House and techno music journey".to_string(),
+                day: "Friday".to_string(),
+            },
+            CjswShow {
+                name: "Noise".to_string(),
+                genre: "Experimental".to_string(),
+                time_slot: "Wednesday 2:00-4:00 AM".to_string(),
+                description: "30+ years of avant-garde experimental music".to_string(),
+                day: "Wednesday".to_string(),
+            },
+            CjswShow {
+                name: "Local Singles".to_string(),
+                genre: "Local/Indie".to_string(),
+                time_slot: "Thursday 6:00-8:00 PM".to_string(),
+                description: "Featuring Calgary and Alberta local artists".to_string(),
+                day: "Thursday".to_string(),
+            },
+            CjswShow {
+                name: "CantoStars".to_string(),
+                genre: "Multicultural".to_string(),
+                time_slot: "Sunday 10:00 AM-12:00 PM".to_string(),
+                description: "Cantonese music and cultural programming".to_string(),
+                day: "Sunday".to_string(),
+            },
+            CjswShow {
+                name: "Sonic Cycle".to_string(),
+                genre: "Indie Pop/Rock".to_string(),
+                time_slot: "Saturday 3:00-5:00 PM".to_string(),
+                description: "Genre-blending indie music journey".to_string(),
+                day: "Saturday".to_string(),
+            },
+            CjswShow {
+                name: "Jazz Spectrum".to_string(),
+                genre: "Jazz".to_string(),
+                time_slot: "Monday 8:00-10:00 PM".to_string(),
+                description: "Classic to contemporary jazz exploration".to_string(),
+                day: "Monday".to_string(),
+            },
+        ]
+    }
 }
src/main.rs
@@ -6,7 +6,7 @@ use crossterm::{
 };
 use ratatui::{
     backend::{Backend, CrosstermBackend},
-    layout::{Constraint, Direction, Layout},
+    layout::{Constraint, Direction, Layout, Rect},
     style::{Color, Modifier, Style},
     text::{Line, Span},
     widgets::{Block, Borders, List, ListItem, ListState, Paragraph},
@@ -119,6 +119,7 @@ fn run_app<B: Backend>(terminal: &mut Terminal<B>, app: &mut App) -> Result<()>
                         KeyCode::Char('j') | KeyCode::Down => app.next_episode(),
                         KeyCode::Char('k') | KeyCode::Up => app.previous_episode(),
                         KeyCode::Enter | KeyCode::Char(' ') => app.play_episode()?,
+                        KeyCode::Char('m') => app.go_to_music_discovery(),
                         _ => {}
                     },
                     CurrentScreen::NowPlaying => match key.code {
@@ -134,6 +135,14 @@ fn run_app<B: Backend>(terminal: &mut Terminal<B>, app: &mut App) -> Result<()>
                         // 1-minute seeking
                         KeyCode::Char('L') => app.skip_forward_long()?,
                         KeyCode::Char('J') => app.skip_backward_long()?,
+                        KeyCode::Char('m') => app.go_to_music_discovery(),
+                        _ => {}
+                    },
+                    CurrentScreen::MusicDiscovery => match key.code {
+                        KeyCode::Char('q') => return Ok(()),
+                        KeyCode::Char('h') | KeyCode::Left | KeyCode::Esc => app.back_to_episodes(),
+                        KeyCode::Char('j') | KeyCode::Down => app.next_genre(),
+                        KeyCode::Char('k') | KeyCode::Up => app.previous_genre(),
                         _ => {}
                     },
                 }
@@ -142,6 +151,74 @@ fn run_app<B: Backend>(terminal: &mut Terminal<B>, app: &mut App) -> Result<()>
     }
 }
 
+fn render_music_discovery(f: &mut Frame, app: &App, area: Rect) {
+    let chunks = Layout::default()
+        .direction(Direction::Vertical)
+        .constraints([
+            Constraint::Length(3),  // Title
+            Constraint::Length(4),  // Genre selector
+            Constraint::Min(8),     // Shows list
+            Constraint::Length(3),  // Help
+        ])
+        .split(area);
+
+    // Title
+    let title = Paragraph::new("🎵 CJSW Music Discovery")
+        .style(Style::default().fg(Color::Green).add_modifier(Modifier::BOLD))
+        .block(Block::default().borders(Borders::ALL))
+        .alignment(ratatui::layout::Alignment::Center);
+    f.render_widget(title, chunks[0]);
+
+    // Genre selector
+    let genres = app.get_unique_genres();
+    let selected_genre = if genres.is_empty() {
+        "No genres available".to_string()
+    } else {
+        genres.get(app.selected_genre).unwrap_or(&"Unknown".to_string()).clone()
+    };
+    
+    let genre_info = format!("Genre: {} ({}/{})", selected_genre, app.selected_genre + 1, genres.len());
+    let genre_widget = Paragraph::new(genre_info)
+        .style(Style::default().fg(Color::Yellow))
+        .block(Block::default().title("Browse Genres").borders(Borders::ALL));
+    f.render_widget(genre_widget, chunks[1]);
+
+    // Shows list
+    if !genres.is_empty() {
+        let shows = app.get_shows_by_genre(&selected_genre);
+        let show_items: Vec<ListItem> = shows
+            .iter()
+            .map(|show| {
+                ListItem::new(Line::from(vec![
+                    Span::styled(&show.name, Style::default().fg(Color::White).add_modifier(Modifier::BOLD)),
+                    Span::raw(" - "),
+                    Span::styled(&show.time_slot, Style::default().fg(Color::Cyan)),
+                    Span::raw(" - "),
+                    Span::styled(&show.day, Style::default().fg(Color::Green)),
+                ]))
+            })
+            .collect();
+
+        let shows_list = List::new(show_items)
+            .block(Block::default().title(format!("{} Shows", selected_genre)).borders(Borders::ALL))
+            .style(Style::default().fg(Color::White));
+        f.render_widget(shows_list, chunks[2]);
+    } else {
+        let no_shows = Paragraph::new("No shows available")
+            .style(Style::default().fg(Color::DarkGray))
+            .block(Block::default().title("Shows").borders(Borders::ALL))
+            .alignment(ratatui::layout::Alignment::Center);
+        f.render_widget(no_shows, chunks[2]);
+    }
+
+    // Help
+    let help_text = "Navigation: j/k or ↑/↓ - browse genres | ESC/h - back | q - quit";
+    let help = Paragraph::new(help_text)
+        .style(Style::default().fg(Color::DarkGray))
+        .block(Block::default().title("Help").borders(Borders::ALL));
+    f.render_widget(help, chunks[3]);
+}
+
 fn ui(f: &mut Frame, app: &App) {
     let chunks = Layout::default()
         .direction(Direction::Horizontal)
@@ -274,5 +351,8 @@ Navigation:
         CurrentScreen::NowPlaying => {
             podcast_ui::render_now_playing_enhanced(f, app, chunks[1]);
         }
+        CurrentScreen::MusicDiscovery => {
+            render_music_discovery(f, app, chunks[1]);
+        }
     }
 }
src/podcast_ui.rs
@@ -229,9 +229,9 @@ fn render_progress_and_controls(f: &mut Frame, app: &App, area: Rect) {
 
     // Controls help with seeking info
     let controls_text = if app.player.is_paused() {
-        "⏸ PAUSED | SPACE:play | s:stop | ±:vol | j/l:±15s | J/L:±1m | ESC:back | q:quit"
+        "⏸ PAUSED | SPACE:play | s:stop | ±:vol | j/l:±15s | J/L:±1m | m:music | ESC:back | q:quit"
     } else {
-        "▶ PLAYING | SPACE:pause | s:stop | ±:vol | j/l:±15s | J/L:±1m | ESC:back | q:quit"
+        "▶ PLAYING | SPACE:pause | s:stop | ±:vol | j/l:±15s | J/L:±1m | m:music | ESC:back | q:quit"
     };
     let controls_widget = Paragraph::new(controls_text)
         .style(Style::default().fg(Color::DarkGray))