Login
1 branch 0 tags
Ben (Desktop/Arch) Removed placeholder games section d4023dd 1 month ago 47 Commits
moon / firmware / src / screens / now_playing_screen.c
#include "now_playing_screen.h"
#include "../audio_player.h"
#include "../bookmark.h"
#include "../moon.h"
#include "../playlist.h"
#include "../storage.h"
#include "../topbar.h"
#include "files_screen.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

static void format_time(uint32_t ms, char *buf, size_t buf_size) {
  uint32_t total_sec = ms / 1000;
  uint32_t hours = total_sec / 3600;
  uint32_t mins = (total_sec % 3600) / 60;
  uint32_t secs = total_sec % 60;

  if (hours > 0) {
    snprintf(buf, buf_size, "%u:%02u:%02u", (unsigned)hours, (unsigned)mins,
             (unsigned)secs);
  } else {
    snprintf(buf, buf_size, "%u:%02u", (unsigned)mins, (unsigned)secs);
  }
}

static int filename_cmp(const void *a, const void *b) {
  return strcmp((const char *)a, (const char *)b);
}

// Load sorted audio files from directory into state
static void load_directory_files(now_playing_screen_state_t *state) {
  storage_entry_t *entries =
      malloc(MAX_AUDIOBOOK_FILES * sizeof(storage_entry_t));
  if (!entries)
    return;

  int count = storage_list_dir(state->dir, entries, MAX_AUDIOBOOK_FILES);
  state->file_count = 0;

  for (int i = 0; i < count && state->file_count < MAX_AUDIOBOOK_FILES; i++) {
    if (entries[i].type == STORAGE_TYPE_FILE &&
        is_audio_file(entries[i].name)) {
      strncpy(state->files[state->file_count], entries[i].name,
              STORAGE_MAX_NAME - 1);
      state->files[state->file_count][STORAGE_MAX_NAME - 1] = '\0';
      state->file_count++;
    }
  }
  free(entries);

  qsort(state->files, (size_t)state->file_count, STORAGE_MAX_NAME,
        filename_cmp);
}

// Find index of filename in sorted file list
static int find_file_index(now_playing_screen_state_t *state,
                           const char *filename) {
  for (int i = 0; i < state->file_count; i++) {
    if (strcmp(state->files[i], filename) == 0) {
      return i;
    }
  }
  return -1;
}

// Save audiobook bookmarks (position + chapter)
static void save_bookmarks(now_playing_screen_state_t *state) {
  uint32_t pos = audio_player_get_position_ms();
  if (pos > 0 && app_state.last_played_path[0]) {
    bookmark_save(app_state.last_played_path, pos);
  }
  if (state->dir[0] && state->current_file_index >= 0 &&
      state->current_file_index < state->file_count) {
    bookmark_save_string(state->dir, state->files[state->current_file_index]);
  }
}

// Update chapter label text (audiobook directory chapters)
static void update_chapter_label(now_playing_screen_state_t *state) {
  if (state->chapter_label && state->file_count > 0) {
    snprintf(string_buffer, sizeof(string_buffer), "%d / %d",
             state->current_file_index + 1, state->file_count);
    lv_label_set_text(state->chapter_label, string_buffer);
  }
}

// Update playlist position label
static void update_playlist_label(now_playing_screen_state_t *state) {
  if (state->playlist_label && playlist_count() > 1) {
    snprintf(string_buffer, sizeof(string_buffer), "%d/%d",
             playlist.index + 1, playlist.count);
    lv_label_set_text(state->playlist_label, string_buffer);
  }
}

// Start playing the current playlist entry. Returns true on success.
// This is the single function used for all playback starts.
// Callers must save bookmarks and call audio_player_stop() before
// changing the playlist index, since save_bookmarks uses the current entry.
static bool start_current_entry(now_playing_screen_state_t *state) {
  playlist_entry_t *entry = playlist_current();
  if (!entry)
    return false;

  // Stop any current playback
  audio_player_stop();

  state->bookmark_applied = false;
  state->bookmark_ms = 0;
  state->dir[0] = '\0';
  state->file_count = 0;
  state->current_file_index = 0;

  if (state->chapter_label) {
    lv_obj_delete(state->chapter_label);
    state->chapter_label = NULL;
  }

  if (entry->type == PLAYLIST_AUDIOBOOK) {
    // Check if path is a directory by trying to list it
    storage_entry_t test_entry;
    int count = storage_list_dir(entry->path, &test_entry, 1);
    bool is_dir = (count >= 0);

    if (is_dir) {
      // Directory audiobook mode
      strncpy(state->dir, entry->path, sizeof(state->dir));
      state->dir[sizeof(state->dir) - 1] = '\0';
      strncpy(app_state.audiobook_dir, entry->path,
              sizeof(app_state.audiobook_dir));
      app_state.audiobook_dir[sizeof(app_state.audiobook_dir) - 1] = '\0';

      load_directory_files(state);

      // Find resume file from bookmark, fall back to first file
      char resume_file[STORAGE_MAX_NAME] = {0};
      bookmark_load_string(entry->path, resume_file, sizeof(resume_file));

      char file_path[STORAGE_MAX_PATH + STORAGE_MAX_NAME];
      if (resume_file[0]) {
        state->current_file_index = find_file_index(state, resume_file);
        if (state->current_file_index < 0)
          state->current_file_index = 0;
        snprintf(file_path, sizeof(file_path), "%s/%s", entry->path,
                 state->files[state->current_file_index]);
      } else {
        if (state->file_count == 0)
          return false;
        state->current_file_index = 0;
        snprintf(file_path, sizeof(file_path), "%s/%s", entry->path,
                 state->files[0]);
      }

      state->bookmark_ms = bookmark_load(file_path);

      if (!audio_player_play(file_path))
        return false;

      strncpy(app_state.last_played_path, file_path,
              sizeof(app_state.last_played_path));
      app_state.last_played_path[sizeof(app_state.last_played_path) - 1] =
          '\0';

      // Create chapter label
      state->chapter_label = lv_label_create(state->container);
      lv_obj_set_style_text_color(state->chapter_label, lv_color_hex(0xAAAAAA),
                                  0);
      // Move chapter label after title
      lv_obj_move_to_index(state->chapter_label, 1);
      update_chapter_label(state);
    } else {
      // Single file audiobook
      app_state.audiobook_dir[0] = '\0';
      state->bookmark_ms = bookmark_load(entry->path);

      if (!audio_player_play(entry->path))
        return false;

      strncpy(app_state.last_played_path, entry->path,
              sizeof(app_state.last_played_path));
      app_state.last_played_path[sizeof(app_state.last_played_path) - 1] =
          '\0';
    }
  } else {
    // Music: simple playback, no bookmarks
    app_state.audiobook_dir[0] = '\0';

    if (!audio_player_play(entry->path))
      return false;

    strncpy(app_state.last_played_path, entry->path,
            sizeof(app_state.last_played_path));
    app_state.last_played_path[sizeof(app_state.last_played_path) - 1] = '\0';
  }

  // Update title
  const char *title = audio_player_get_title();
  lv_label_set_text(state->title_label, title ? title : "Unknown");

  // Update playlist position
  update_playlist_label(state);

  playlist_save();
  return true;
}

// Advance to next file within an audiobook directory
static bool play_next_chapter(now_playing_screen_state_t *state) {
  int next = state->current_file_index + 1;
  if (next >= state->file_count)
    return false;

  // Remove completed file's bookmark
  if (app_state.last_played_path[0]) {
    bookmark_remove(app_state.last_played_path);
  }

  char file_path[STORAGE_MAX_PATH + STORAGE_MAX_NAME];
  snprintf(file_path, sizeof(file_path), "%s/%s", state->dir,
           state->files[next]);

  if (!audio_player_play(file_path))
    return false;

  state->current_file_index = next;
  strncpy(app_state.last_played_path, file_path,
          sizeof(app_state.last_played_path));
  app_state.last_played_path[sizeof(app_state.last_played_path) - 1] = '\0';

  bookmark_save_string(state->dir, state->files[next]);

  state->bookmark_ms = bookmark_load(app_state.last_played_path);
  state->bookmark_applied = false;

  const char *title = audio_player_get_title();
  lv_label_set_text(state->title_label, title ? title : "Unknown");
  update_chapter_label(state);

  return true;
}

static void cancel_event_cb(lv_event_t *e) {
  (void)e;
  now_playing_screen_state_t *state = ui_state.now_playing;
  if (state) {
    playlist_entry_t *entry = playlist_current();
    if (entry && entry->type == PLAYLIST_AUDIOBOOK) {
      save_bookmarks(state);
    }
  }
  // Don't stop playback — let it continue in the background
  navigate_to(SCREEN_HOME);
}

static void play_btn_cb(lv_event_t *e) {
  if (lv_event_get_code(e) != LV_EVENT_CLICKED)
    return;

  audio_state_t astate = audio_player_get_state();
  if (astate == AUDIO_STATE_PLAYING) {
    now_playing_screen_state_t *state = ui_state.now_playing;
    playlist_entry_t *entry = playlist_current();
    if (state && entry && entry->type == PLAYLIST_AUDIOBOOK) {
      save_bookmarks(state);
    }
    audio_player_pause();
  } else if (astate == AUDIO_STATE_PAUSED) {
    audio_player_resume();
  }
}

static void prev_btn_cb(lv_event_t *e) {
  if (lv_event_get_code(e) != LV_EVENT_CLICKED)
    return;

  now_playing_screen_state_t *state = ui_state.now_playing;
  if (!state)
    return;

  playlist_entry_t *entry = playlist_current();
  bool is_audiobook = entry && entry->type == PLAYLIST_AUDIOBOOK;

  if (!is_audiobook) {
    // Music: if more than 10 seconds in, restart current song
    uint32_t pos = audio_player_get_position_ms();
    if (pos > PREV_RESTART_THRESHOLD_MS) {
      audio_player_seek_ms(0);
      return;
    }
  }

  // For audiobooks: go to previous chapter/entry (never restart)
  // For music at beginning: go to previous entry
  if (is_audiobook) {
    save_bookmarks(state);
  }

  if (playlist_prev()) {
    start_current_entry(state);
  } else if (!is_audiobook) {
    // Already at first music entry, just restart
    audio_player_seek_ms(0);
  }
}

static void next_btn_cb(lv_event_t *e) {
  if (lv_event_get_code(e) != LV_EVENT_CLICKED)
    return;

  now_playing_screen_state_t *state = ui_state.now_playing;
  if (!state)
    return;

  playlist_entry_t *entry = playlist_current();
  if (entry && entry->type == PLAYLIST_AUDIOBOOK) {
    save_bookmarks(state);
  }

  if (playlist_next()) {
    start_current_entry(state);
  }
}

ui_state_t setup_now_playing_screen(void) {
  now_playing_screen_state_t *state =
      calloc(1, sizeof(now_playing_screen_state_t));

  topbar_set_title("Now Playing");

  // Main container
  state->container = lv_obj_create(lv_screen_active());
  lv_obj_set_pos(state->container, 0, TOPBAR_H);
  lv_obj_set_size(state->container, SCREEN_WIDTH, SCREEN_HEIGHT - TOPBAR_H);
  lv_obj_set_flex_flow(state->container, LV_FLEX_FLOW_COLUMN);
  lv_obj_set_flex_align(state->container, LV_FLEX_ALIGN_START,
                        LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
  lv_obj_set_style_pad_all(state->container, 4, 0);
  lv_obj_set_style_pad_gap(state->container, 6, 0);
  lv_obj_set_style_radius(state->container, 0, 0);
  lv_obj_set_style_border_side(state->container, 0, 0);
  lv_obj_clear_flag(state->container, LV_OBJ_FLAG_SCROLLABLE);

  playlist_entry_t *entry = playlist_current();

  if (!entry) {
    // Nothing playing
    state->title_label = lv_label_create(state->container);
    lv_label_set_text(state->title_label, "Nothing playing");
    lv_obj_set_width(state->title_label, LV_PCT(100));

    // Just a back button
    lv_obj_t *back_btn = lv_button_create(state->container);
    lv_obj_set_size(back_btn, LV_SIZE_CONTENT, 24);
    lv_obj_t *back_label = lv_label_create(back_btn);
    lv_label_set_text(back_label, "Back");
    lv_obj_center(back_label);
    lv_obj_add_event_cb(back_btn, cancel_event_cb, LV_EVENT_CLICKED, NULL);
    lv_obj_add_event_cb(back_btn, cancel_event_cb, LV_EVENT_CANCEL, NULL);
    lv_group_add_obj(lv_group_get_default(), back_btn);
    lv_group_focus_obj(back_btn);

    // Dummy widgets so update doesn't crash
    state->progress_bar = lv_bar_create(state->container);
    lv_obj_add_flag(state->progress_bar, LV_OBJ_FLAG_HIDDEN);
    state->position_label = lv_label_create(state->container);
    lv_obj_add_flag(state->position_label, LV_OBJ_FLAG_HIDDEN);
    state->duration_label = lv_label_create(state->container);
    lv_obj_add_flag(state->duration_label, LV_OBJ_FLAG_HIDDEN);

    return (ui_state_t){.type = SCREEN_NOW_PLAYING, .now_playing = state};
  }

  // Title label (scrolling)
  state->title_label = lv_label_create(state->container);
  const char *title = audio_player_get_title();
  lv_label_set_text(state->title_label, title ? title : "Unknown");
  lv_label_set_long_mode(state->title_label, LV_LABEL_LONG_SCROLL_CIRCULAR);
  lv_obj_set_width(state->title_label, LV_PCT(100));

  // If audio is stopped, start playback via start_current_entry
  // (this is the single entry point for all playback starts)
  if (audio_player_get_state() == AUDIO_STATE_STOPPED) {
    if (!start_current_entry(state)) {
      lv_label_set_text(state->title_label, "Playback failed");
    }
  } else {
    // Already playing — set up UI state to match current playback
    state->bookmark_applied = true;

    if (entry->type == PLAYLIST_AUDIOBOOK) {
      storage_entry_t test_entry;
      int count = storage_list_dir(entry->path, &test_entry, 1);
      bool is_dir = (count >= 0);

      if (is_dir) {
        strncpy(state->dir, entry->path, sizeof(state->dir));
        state->dir[sizeof(state->dir) - 1] = '\0';

        load_directory_files(state);

        const char *filename = strrchr(app_state.last_played_path, '/');
        filename = filename ? filename + 1 : app_state.last_played_path;
        state->current_file_index = find_file_index(state, filename);
        if (state->current_file_index < 0)
          state->current_file_index = 0;

        state->chapter_label = lv_label_create(state->container);
        lv_obj_set_style_text_color(state->chapter_label,
                                    lv_color_hex(0xAAAAAA), 0);
        update_chapter_label(state);
      }
    }
  }

  // Time row container
  lv_obj_t *time_cont = lv_obj_create(state->container);
  lv_obj_set_size(time_cont, SCREEN_WIDTH - 8, LV_SIZE_CONTENT);
  lv_obj_set_flex_flow(time_cont, LV_FLEX_FLOW_ROW);
  lv_obj_set_flex_align(time_cont, LV_FLEX_ALIGN_SPACE_BETWEEN,
                        LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
  lv_obj_set_style_pad_all(time_cont, 0, 0);
  lv_obj_set_style_border_width(time_cont, 0, 0);
  lv_obj_set_style_bg_opa(time_cont, LV_OPA_TRANSP, 0);

  state->position_label = lv_label_create(time_cont);
  lv_label_set_text(state->position_label, "0:00");
  lv_obj_set_style_text_color(state->position_label, lv_color_hex(0xAAAAAA),
                              0);

  // Playlist position label (e.g. "1/3") in center of time row
  if (playlist_count() > 1) {
    state->playlist_label = lv_label_create(time_cont);
    lv_obj_set_style_text_color(state->playlist_label, lv_color_hex(0xAAAAAA),
                                0);
    update_playlist_label(state);
  }

  state->duration_label = lv_label_create(time_cont);
  lv_label_set_text(state->duration_label, "0:00");
  lv_obj_set_style_text_color(state->duration_label, lv_color_hex(0xAAAAAA),
                              0);

  // Progress bar
  state->progress_bar = lv_bar_create(state->container);
  lv_obj_set_size(state->progress_bar, SCREEN_WIDTH - 8, 6);
  lv_bar_set_range(state->progress_bar, 0, 100);
  lv_bar_set_value(state->progress_bar, 0, LV_ANIM_OFF);
  lv_obj_set_style_bg_color(state->progress_bar, lv_color_hex(0x444444),
                            LV_PART_MAIN);
  lv_obj_set_style_bg_color(state->progress_bar, lv_color_hex(0xE91E63),
                            LV_PART_INDICATOR);

  // Button container
  lv_obj_t *btn_cont = lv_obj_create(state->container);
  lv_obj_set_size(btn_cont, LV_PCT(100), LV_SIZE_CONTENT);
  lv_obj_set_flex_flow(btn_cont, LV_FLEX_FLOW_ROW);
  lv_obj_set_flex_align(btn_cont, LV_FLEX_ALIGN_SPACE_EVENLY,
                        LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
  lv_obj_set_style_pad_all(btn_cont, 4, 0);
  lv_obj_set_style_border_width(btn_cont, 0, 0);
  lv_obj_set_style_bg_opa(btn_cont, LV_OPA_TRANSP, 0);

  // Prev button
  state->prev_btn = lv_button_create(btn_cont);
  lv_obj_set_size(state->prev_btn, 24, 24);
  lv_obj_t *prev_label = lv_label_create(state->prev_btn);
  lv_label_set_text(prev_label, LV_SYMBOL_PREV);
  lv_obj_center(prev_label);
  lv_obj_add_event_cb(state->prev_btn, prev_btn_cb, LV_EVENT_CLICKED, NULL);
  lv_obj_add_event_cb(state->prev_btn, cancel_event_cb, LV_EVENT_CANCEL, NULL);
  lv_group_add_obj(lv_group_get_default(), state->prev_btn);

  // Play/Pause button
  state->play_btn = lv_button_create(btn_cont);
  lv_obj_set_size(state->play_btn, 24, 24);
  lv_obj_t *play_label = lv_label_create(state->play_btn);
  lv_label_set_text(play_label, LV_SYMBOL_PAUSE);
  lv_obj_center(play_label);
  lv_obj_add_event_cb(state->play_btn, play_btn_cb, LV_EVENT_CLICKED, NULL);
  lv_obj_add_event_cb(state->play_btn, cancel_event_cb, LV_EVENT_CANCEL, NULL);
  lv_group_add_obj(lv_group_get_default(), state->play_btn);

  // Next button
  state->next_btn = lv_button_create(btn_cont);
  lv_obj_set_size(state->next_btn, 24, 24);
  lv_obj_t *next_label = lv_label_create(state->next_btn);
  lv_label_set_text(next_label, LV_SYMBOL_NEXT);
  lv_obj_center(next_label);
  lv_obj_add_event_cb(state->next_btn, next_btn_cb, LV_EVENT_CLICKED, NULL);
  lv_obj_add_event_cb(state->next_btn, cancel_event_cb, LV_EVENT_CANCEL, NULL);
  lv_group_add_obj(lv_group_get_default(), state->next_btn);

  // Focus play button
  lv_group_focus_obj(state->play_btn);

  return (ui_state_t){.type = SCREEN_NOW_PLAYING, .now_playing = state};
}

void free_now_playing_screen(now_playing_screen_state_t *state) {
  if (!state)
    return;

  if (state->prev_btn)
    lv_group_remove_obj(state->prev_btn);
  if (state->play_btn)
    lv_group_remove_obj(state->play_btn);
  if (state->next_btn)
    lv_group_remove_obj(state->next_btn);

  lv_obj_delete(state->container);
  free(state);
}

void update_now_playing_screen(now_playing_screen_state_t *state) {
  if (!state)
    return;

  playlist_entry_t *entry = playlist_current();
  if (!entry)
    return;

  audio_state_t audio_state = audio_player_get_state();

  // Apply bookmark seek on first frame where player is playing
  if (!state->bookmark_applied && state->bookmark_ms > 0 &&
      audio_state == AUDIO_STATE_PLAYING) {
    state->bookmark_applied = true;
    if (!audio_player_seek_ms(state->bookmark_ms)) {
      lv_label_set_text(state->title_label, "Resume not available");
    }
  }

  // Detect playback completion
  if (audio_state == AUDIO_STATE_STOPPED && state->bookmark_applied) {
    // Try next chapter in audiobook directory first
    if (state->dir[0] && play_next_chapter(state)) {
      return;
    }

    // Remove completed bookmarks
    if (app_state.last_played_path[0]) {
      bookmark_remove(app_state.last_played_path);
    }
    if (state->dir[0]) {
      bookmark_remove(state->dir);
    }

    // Try next playlist entry
    if (playlist_next()) {
      start_current_entry(state);
      return;
    }

    // End of playlist
    playlist_clear();
    playlist_save();
    navigate_to(SCREEN_HOME);
    return;
  }

  // Update play/pause button icon
  if (state->play_btn) {
    lv_obj_t *play_label = lv_obj_get_child(state->play_btn, 0);
    if (audio_state == AUDIO_STATE_PLAYING ||
        audio_state == AUDIO_STATE_PAUSED) {
      lv_label_set_text(play_label, audio_state == AUDIO_STATE_PLAYING
                                        ? LV_SYMBOL_PAUSE
                                        : LV_SYMBOL_PLAY);
    } else {
      lv_label_set_text(play_label, LV_SYMBOL_PLAY);
    }
  }

  // Update progress bar and time labels
  uint32_t position_ms = audio_player_get_position_ms();
  uint32_t duration_ms = audio_player_get_duration_ms();

  // Before bookmark seek is applied, show the bookmark position
  if (state->bookmark_ms > 0 && position_ms < state->bookmark_ms &&
      state->bookmark_applied) {
    position_ms = state->bookmark_ms;
  }

  uint8_t progress = duration_ms > 0
                         ? (uint8_t)((position_ms * 100) / duration_ms)
                         : audio_player_get_progress();

  lv_bar_set_value(state->progress_bar, progress, LV_ANIM_OFF);

  format_time(position_ms, string_buffer, sizeof(string_buffer));
  lv_label_set_text(state->position_label, string_buffer);

  if (duration_ms > 0) {
    format_time(duration_ms, string_buffer, sizeof(string_buffer));
    lv_label_set_text(state->duration_label, string_buffer);
  } else {
    lv_label_set_text(state->duration_label, "--:--");
  }
}