Login
1 branch 0 tags
Ben (Desktop/Arch) Added a testsuite 7a0c574 28 days ago 84 Commits
moon / src / playlist.c
#include "playlist.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include "audio_player.h"
#include "bookmark.h"
#include "lvgl.h"
#include "screens/files_screen.h"
#include "storage.h"
#include "ui.h"

#define PLAYLIST_BUF_SIZE 4096

playlist_t playlist;

// --- Playback control state (moved from now_playing_screen) ---
static char pl_dir[STORAGE_MAX_PATH];
static char pl_files[MAX_AUDIOBOOK_FILES][STORAGE_MAX_NAME];
static int pl_file_count;
static int pl_current_file_index;
static uint32_t pl_bookmark_ms;
static bool pl_bookmark_applied;

void playlist_load(void) {
	memset(&playlist, 0, sizeof(playlist));

	storage_file_t f = storage_open(PLAYLIST_FILE, "r");
	if (!f) {
		return;
	}

	char buf[PLAYLIST_BUF_SIZE];
	size_t n = storage_read(f, buf, sizeof(buf) - 1);
	storage_close(f);
	buf[n] = '\0';

	// Parse line by line
	char* line = buf;
	while (line && *line) {
		char* eol = strchr(line, '\n');
		size_t line_len = eol ? (size_t)(eol - line) : strlen(line);

		// Null-terminate line temporarily
		char saved = line[line_len];
		line[line_len] = '\0';

		// Parse index = N
		if (strncmp(line, "index", 5) == 0) {
			char* eq = strchr(line, '=');
			if (eq) {
				playlist.index = atoi(eq + 1);
			}
		}
		// Parse type = "music" or type = "audiobook"
		else if (strncmp(line, "type", 4) == 0 &&
		         playlist.count < PLAYLIST_MAX_ENTRIES) {
			char* eq = strchr(line, '=');
			if (eq) {
				char* q1 = strchr(eq, '"');
				if (q1) {
					q1++;
					if (strncmp(q1, "audiobook", 9) == 0) {
						playlist.entries[playlist.count].type =
						    PLAYLIST_AUDIOBOOK;
					} else {
						playlist.entries[playlist.count].type = PLAYLIST_MUSIC;
					}
				}
			}
		}
		// Parse path = "/some/path"
		else if (strncmp(line, "path", 4) == 0 &&
		         playlist.count < PLAYLIST_MAX_ENTRIES) {
			char* eq = strchr(line, '=');
			if (eq) {
				char* q1 = strchr(eq, '"');
				if (q1) {
					q1++;
					char* q2 = strchr(q1, '"');
					if (q2) {
						size_t plen = (size_t)(q2 - q1);
						if (plen < STORAGE_MAX_PATH) {
							memcpy(playlist.entries[playlist.count].path, q1,
							       plen);
							playlist.entries[playlist.count].path[plen] = '\0';
							playlist.count++;
						}
					}
				}
			}
		}

		line[line_len] = saved;
		if (!eol) {
			break;
		}
		line = eol + 1;
	}

	// Clamp index
	if (playlist.count > 0) {
		if (playlist.index < 0) {
			playlist.index = 0;
		}
		if (playlist.index >= playlist.count) {
			playlist.index = playlist.count - 1;
		}
	} else {
		playlist.index = 0;
	}
}

void playlist_save(void) {
	storage_mkdir(DATA_DIR);

	char buf[PLAYLIST_BUF_SIZE];
	int pos = 0;

	pos += snprintf(buf + pos, sizeof(buf) - (size_t)pos,
	                "[playlist]\nindex = %d\n\n", playlist.index);

	for (int i = 0; i < playlist.count && pos < (int)sizeof(buf) - 128; i++) {
		const char* type_str = playlist.entries[i].type == PLAYLIST_AUDIOBOOK
		                           ? "audiobook"
		                           : "music";
		pos += snprintf(buf + pos, sizeof(buf) - (size_t)pos,
		                "[[entry]]\ntype = \"%s\"\npath = \"%s\"\n\n", type_str,
		                playlist.entries[i].path);
	}

	storage_file_t f = storage_open(PLAYLIST_FILE, "w");
	if (f) {
		storage_write(f, buf, (size_t)pos);
		storage_close(f);
	}
}

void playlist_add(const char* path, playlist_type_t type) {
	if (playlist.count >= PLAYLIST_MAX_ENTRIES) {
		return;
	}
	playlist.entries[playlist.count].type = type;
	strncpy(playlist.entries[playlist.count].path, path, STORAGE_MAX_PATH - 1);
	playlist.entries[playlist.count].path[STORAGE_MAX_PATH - 1] = '\0';
	playlist.count++;
}

void playlist_clear(void) {
	memset(&playlist, 0, sizeof(playlist));
}

playlist_entry_t* playlist_current(void) {
	if (playlist.count == 0) {
		return NULL;
	}
	if (playlist.index < 0 || playlist.index >= playlist.count) {
		return NULL;
	}
	return &playlist.entries[playlist.index];
}

bool playlist_next(void) {
	if (playlist.index + 1 >= playlist.count) {
		return false;
	}
	playlist.index++;
	return true;
}

bool playlist_prev(void) {
	if (playlist.index <= 0) {
		return false;
	}
	playlist.index--;
	return true;
}

bool playlist_is_empty(void) {
	return playlist.count == 0;
}

int playlist_count(void) {
	return playlist.count;
}

void playlist_set_index(int i) {
	if (i >= 0 && i < playlist.count) {
		playlist.index = i;
	}
}

void playlist_tick(void) {
	static uint32_t last_slot = 0;

	playlist_entry_t* entry = playlist_current();
	if (!entry || entry->type != PLAYLIST_AUDIOBOOK) {
		return;
	}
	if (audio_player_get_state() != AUDIO_STATE_PLAYING) {
		return;
	}

	uint32_t slot = lv_tick_get() / PLAYLIST_BOOKMARK_INTERVAL_MS;
	if (slot == last_slot) {
		return;
	}
	last_slot = slot;

	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);
	}
	// Also save chapter bookmark for directory audiobooks
	if (app_state.audiobook_dir[0]) {
		const char* filename = strrchr(app_state.last_played_path, '/');
		if (filename) {
			bookmark_save_string(app_state.audiobook_dir, filename + 1);
		}
	}
}

// --- High-level playback control ---

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

static void load_directory_files(void) {
	storage_entry_t* entries =
	    malloc(MAX_AUDIOBOOK_FILES * sizeof(storage_entry_t));
	if (!entries) {
		return;
	}

	int count = storage_list_dir(pl_dir, entries, MAX_AUDIOBOOK_FILES);
	pl_file_count = 0;

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

	qsort(pl_files, (size_t)pl_file_count, STORAGE_MAX_NAME, filename_cmp);
}

static int find_file_index(const char* filename) {
	for (int i = 0; i < pl_file_count; i++) {
		if (strcmp(pl_files[i], filename) == 0) {
			return i;
		}
	}
	return -1;
}

void playlist_save_bookmarks(void) {
	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 (pl_dir[0] && pl_current_file_index >= 0 &&
	    pl_current_file_index < pl_file_count) {
		bookmark_save_string(pl_dir, pl_files[pl_current_file_index]);
	}
}

static bool play_next_chapter(void) {
	int next = pl_current_file_index + 1;
	if (next >= pl_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 + 2];
	snprintf(file_path, sizeof(file_path), "%s/%s", pl_dir, pl_files[next]);

	if (!audio_player_play(file_path)) {
		return false;
	}

	pl_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(pl_dir, pl_files[next]);

	pl_bookmark_ms = bookmark_load(app_state.last_played_path);
	pl_bookmark_applied = false;

	return true;
}

bool playlist_start(void) {
	playlist_entry_t* entry = playlist_current();
	if (!entry) {
		return false;
	}

	audio_player_stop();

	pl_bookmark_applied = false;
	pl_bookmark_ms = 0;
	pl_dir[0] = '\0';
	pl_file_count = 0;
	pl_current_file_index = 0;

	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(pl_dir, entry->path, sizeof(pl_dir));
			pl_dir[sizeof(pl_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();

			// 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 + 2];
			if (resume_file[0]) {
				pl_current_file_index = find_file_index(resume_file);
				if (pl_current_file_index < 0) {
					pl_current_file_index = 0;
				}
			} else {
				if (pl_file_count == 0) {
					return false;
				}
				pl_current_file_index = 0;
			}
			char fname[STORAGE_MAX_NAME];
			strncpy(fname, pl_files[pl_current_file_index], sizeof(fname) - 1);
			fname[sizeof(fname) - 1] = '\0';
			snprintf(file_path, sizeof(file_path), "%s/%s", entry->path, fname);

			pl_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';
		} else {
			// Single file audiobook
			app_state.audiobook_dir[0] = '\0';
			pl_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';
	}

	playlist_save();
	return true;
}

bool playlist_play_next(void) {
	playlist_entry_t* entry = playlist_current();
	if (entry && entry->type == PLAYLIST_AUDIOBOOK) {
		playlist_save_bookmarks();
	}

	if (playlist_next()) {
		return playlist_start();
	}
	return false;
}

bool playlist_play_prev(void) {
	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 true;
		}
	}

	if (is_audiobook) {
		playlist_save_bookmarks();
	}

	if (playlist_prev()) {
		return playlist_start();
	} else if (!is_audiobook) {
		// Already at first music entry, just restart
		audio_player_seek_ms(0);
		return true;
	}
	return false;
}

bool playlist_check_advance(void) {
	audio_state_t astate = audio_player_get_state();

	if (astate != AUDIO_STATE_STOPPED || !pl_bookmark_applied) {
		return false;
	}

	// Try next chapter in audiobook directory first
	if (pl_dir[0] && play_next_chapter()) {
		return false;
	}

	// Remove completed bookmarks
	if (app_state.last_played_path[0]) {
		bookmark_remove(app_state.last_played_path);
	}
	if (pl_dir[0]) {
		bookmark_remove(pl_dir);
	}

	// Try next playlist entry
	if (playlist_next()) {
		playlist_start();
		return false;
	}

	// End of playlist
	playlist_clear();
	playlist_save();
	return true;
}

void playlist_apply_bookmark(void) {
	if (pl_bookmark_applied || pl_bookmark_ms == 0) {
		return;
	}
	if (audio_player_get_state() != AUDIO_STATE_PLAYING) {
		return;
	}
	pl_bookmark_applied = true;
	audio_player_seek_ms(pl_bookmark_ms);
}

bool playlist_get_chapter_info(int* current, int* total) {
	if (!pl_dir[0] || pl_file_count == 0) {
		return false;
	}
	*current = pl_current_file_index + 1;
	*total = pl_file_count;
	return true;
}

uint32_t playlist_get_bookmark_ms(void) {
	return pl_bookmark_ms;
}

bool playlist_is_bookmark_applied(void) {
	return pl_bookmark_applied;
}

// Sync internal state to match already-playing audio (called when screen opens
// while audio is already playing, so we didn't go through playlist_start).
void playlist_sync_state(void) {
	pl_bookmark_applied = true;
	pl_bookmark_ms = 0;
	pl_dir[0] = '\0';
	pl_file_count = 0;
	pl_current_file_index = 0;

	playlist_entry_t* entry = playlist_current();
	if (!entry || entry->type != PLAYLIST_AUDIOBOOK) {
		return;
	}

	storage_entry_t test_entry;
	int count = storage_list_dir(entry->path, &test_entry, 1);
	if (count < 0) {
		return;
	}

	// Directory audiobook — rebuild file list and find current chapter
	strncpy(pl_dir, entry->path, sizeof(pl_dir));
	pl_dir[sizeof(pl_dir) - 1] = '\0';

	load_directory_files();

	const char* filename = strrchr(app_state.last_played_path, '/');
	filename = filename ? filename + 1 : app_state.last_played_path;
	pl_current_file_index = find_file_index(filename);
	if (pl_current_file_index < 0) {
		pl_current_file_index = 0;
	}
}