Login
1 branch 0 tags
Ben (Desktop/Arch) Put the display to sleep when brightness==0 43a8a00 1 month ago 70 Commits
moon / src / screens / now_playing_screen.c
#include "now_playing_screen.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include "../audio_player.h"
#include "../bookmark.h"
#include "../moon.h"
#include "../playlist.h"
#include "../storage.h"
#include "../topbar.h"
#include "files_screen.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);

	update_now_playing_screen(state);

	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, "--:--");
	}
}