first commit
This commit is contained in:
commit
10be425f9d
9 changed files with 96922 additions and 0 deletions
7
.gitignore
vendored
Normal file
7
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
.DS_Store
|
||||||
|
build/
|
||||||
|
tui-music-player
|
||||||
|
tui-music-player.exe
|
||||||
|
|
||||||
|
music/*
|
||||||
|
!music/.gitkeep
|
||||||
28
CMakeLists.txt
Normal file
28
CMakeLists.txt
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
cmake_minimum_required(VERSION 3.16)
|
||||||
|
project(tui_music_player C)
|
||||||
|
|
||||||
|
set(CMAKE_C_STANDARD 11)
|
||||||
|
set(CMAKE_C_STANDARD_REQUIRED ON)
|
||||||
|
|
||||||
|
add_executable(tui-music-player src/main.c)
|
||||||
|
target_include_directories(tui-music-player PRIVATE vendor)
|
||||||
|
|
||||||
|
if(MSVC)
|
||||||
|
target_compile_options(tui-music-player PRIVATE /W4)
|
||||||
|
else()
|
||||||
|
target_compile_options(tui-music-player PRIVATE -Wall -Wextra -Wpedantic)
|
||||||
|
endif()
|
||||||
|
|
||||||
|
if(WIN32)
|
||||||
|
target_link_libraries(tui-music-player PRIVATE ole32 uuid winmm avrt)
|
||||||
|
elseif(APPLE)
|
||||||
|
target_link_libraries(tui-music-player PRIVATE
|
||||||
|
"-framework CoreAudio"
|
||||||
|
"-framework CoreFoundation"
|
||||||
|
"-framework AudioToolbox"
|
||||||
|
pthread
|
||||||
|
m
|
||||||
|
)
|
||||||
|
else()
|
||||||
|
target_link_libraries(tui-music-player PRIVATE dl pthread m)
|
||||||
|
endif()
|
||||||
31
Makefile
Normal file
31
Makefile
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
CC ?= cc
|
||||||
|
TARGET := tui-music-player
|
||||||
|
SRC := src/main.c
|
||||||
|
|
||||||
|
CFLAGS ?= -std=c11 -Wall -Wextra -Wpedantic -O2
|
||||||
|
CPPFLAGS += -Ivendor
|
||||||
|
|
||||||
|
UNAME_S := $(shell uname -s 2>/dev/null)
|
||||||
|
|
||||||
|
ifeq ($(OS),Windows_NT)
|
||||||
|
TARGET := tui-music-player.exe
|
||||||
|
LDLIBS += -lole32 -luuid -lwinmm -lavrt
|
||||||
|
else ifeq ($(UNAME_S),Darwin)
|
||||||
|
LDFLAGS += -framework CoreAudio -framework CoreFoundation -framework AudioToolbox
|
||||||
|
LDLIBS += -lpthread -lm
|
||||||
|
else
|
||||||
|
LDLIBS += -ldl -lpthread -lm
|
||||||
|
endif
|
||||||
|
|
||||||
|
.PHONY: all clean run
|
||||||
|
|
||||||
|
all: $(TARGET)
|
||||||
|
|
||||||
|
$(TARGET): $(SRC)
|
||||||
|
$(CC) $(CFLAGS) $(CPPFLAGS) $(LDFLAGS) -o $@ $< $(LDLIBS)
|
||||||
|
|
||||||
|
run: $(TARGET)
|
||||||
|
./$(TARGET)
|
||||||
|
|
||||||
|
clean:
|
||||||
|
rm -f $(TARGET)
|
||||||
90
README.md
Normal file
90
README.md
Normal file
|
|
@ -0,0 +1,90 @@
|
||||||
|
# TUI Music Player
|
||||||
|
|
||||||
|
외부 앱 없이 동작하는 크로스플랫폼 TUI 음악 플레이어입니다.
|
||||||
|
|
||||||
|
터미널 UI는 ANSI escape sequence와 OS별 키 입력 처리로 구현했고, 재생 엔진은 단일 헤더 라이브러리인 `miniaudio`를 `vendor/miniaudio.h`에 포함했습니다.
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
- Windows, macOS, Linux
|
||||||
|
- C11 compiler
|
||||||
|
- CMake 3.16+ 또는 `make`
|
||||||
|
|
||||||
|
## Build
|
||||||
|
|
||||||
|
### Nix devshell
|
||||||
|
|
||||||
|
```sh
|
||||||
|
nix develop
|
||||||
|
tui-build
|
||||||
|
tui-run
|
||||||
|
```
|
||||||
|
|
||||||
|
한 줄로 실행할 수도 있습니다.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
nix develop -c tui-run
|
||||||
|
```
|
||||||
|
|
||||||
|
devshell에는 `cmake`, `ninja`, `make`, `pkg-config`, `clang-tools`와 플랫폼별 오디오 빌드/런타임 패키지가 포함됩니다.
|
||||||
|
|
||||||
|
### CMake
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cmake -S . -B build
|
||||||
|
cmake --build build
|
||||||
|
```
|
||||||
|
|
||||||
|
Windows에서는 Visual Studio, Ninja, MinGW 같은 CMake generator를 사용할 수 있습니다.
|
||||||
|
|
||||||
|
### Make
|
||||||
|
|
||||||
|
```sh
|
||||||
|
make
|
||||||
|
./tui-music-player
|
||||||
|
```
|
||||||
|
|
||||||
|
Windows + MinGW에서는 결과물이 `tui-music-player.exe`로 생성됩니다.
|
||||||
|
|
||||||
|
## Add Music
|
||||||
|
|
||||||
|
가장 쉬운 방법은 프로젝트의 `music/` 폴더에 음악 파일을 넣는 것입니다.
|
||||||
|
|
||||||
|
```text
|
||||||
|
TUIMusicPlayer/
|
||||||
|
music/
|
||||||
|
first-song.mp3
|
||||||
|
second-song.wav
|
||||||
|
```
|
||||||
|
|
||||||
|
앱을 실행하면 `music/` 안의 오디오 파일을 자동으로 플레이리스트에 넣습니다. 실행 중 파일을 추가했다면 `r` 키로 다시 스캔할 수 있습니다.
|
||||||
|
|
||||||
|
파일 경로를 직접 넘겨서 시작할 수도 있습니다.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
./tui-music-player ~/Music/song.mp3 ~/Music/another.wav
|
||||||
|
```
|
||||||
|
|
||||||
|
실행 중에는 `a` 키로 파일 경로를 직접 추가할 수 있습니다.
|
||||||
|
|
||||||
|
`miniaudio`는 WAV/FLAC/MP3 등 일반적인 로컬 오디오 파일을 디코딩할 수 있습니다.
|
||||||
|
|
||||||
|
## Controls
|
||||||
|
|
||||||
|
| Key | Action |
|
||||||
|
| --- | --- |
|
||||||
|
| `j` / `Down` | Move selection down |
|
||||||
|
| `k` / `Up` | Move selection up |
|
||||||
|
| `Enter` | Play selected track |
|
||||||
|
| `p` / `P` / Space | Pause or resume |
|
||||||
|
| `s` / `S` | Shuffle playlist |
|
||||||
|
| `a` | Add a track path after the selected row |
|
||||||
|
| `r` | Rescan `music/` and add new files |
|
||||||
|
| `d` / `x` | Delete selected track |
|
||||||
|
| `n` | Next track |
|
||||||
|
| `b` | Previous track |
|
||||||
|
| `g` | Jump to top |
|
||||||
|
| `G` | Jump to bottom |
|
||||||
|
| `q` | Quit |
|
||||||
|
|
||||||
|
Vim 스타일 이동을 기본으로 두고, 요청하신 `P`와 `S`도 대문자 그대로 동작합니다.
|
||||||
27
flake.lock
generated
Normal file
27
flake.lock
generated
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
{
|
||||||
|
"nodes": {
|
||||||
|
"nixpkgs": {
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1781577229,
|
||||||
|
"narHash": "sha256-lrp67w8AulE9Ks53n27I45ADSzbOCn4H+CNW1Ck8B+8=",
|
||||||
|
"owner": "NixOS",
|
||||||
|
"repo": "nixpkgs",
|
||||||
|
"rev": "567a49d1913ce81ac6e9582e3553dd90a955875f",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "NixOS",
|
||||||
|
"ref": "nixos-unstable",
|
||||||
|
"repo": "nixpkgs",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"root": {
|
||||||
|
"inputs": {
|
||||||
|
"nixpkgs": "nixpkgs"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"root": "root",
|
||||||
|
"version": 7
|
||||||
|
}
|
||||||
115
flake.nix
Normal file
115
flake.nix
Normal file
|
|
@ -0,0 +1,115 @@
|
||||||
|
{
|
||||||
|
description = "Cross-platform TUI music player development shell";
|
||||||
|
|
||||||
|
inputs = {
|
||||||
|
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
||||||
|
};
|
||||||
|
|
||||||
|
outputs =
|
||||||
|
{ nixpkgs, ... }:
|
||||||
|
let
|
||||||
|
systems = [
|
||||||
|
"aarch64-darwin"
|
||||||
|
"x86_64-darwin"
|
||||||
|
"aarch64-linux"
|
||||||
|
"x86_64-linux"
|
||||||
|
];
|
||||||
|
|
||||||
|
forAllSystems = nixpkgs.lib.genAttrs systems;
|
||||||
|
|
||||||
|
perSystem =
|
||||||
|
system:
|
||||||
|
let
|
||||||
|
pkgs = import nixpkgs { inherit system; };
|
||||||
|
lib = pkgs.lib;
|
||||||
|
|
||||||
|
linuxAudioRuntime = lib.optionals pkgs.stdenv.isLinux [
|
||||||
|
pkgs.alsa-lib
|
||||||
|
pkgs.pulseaudio
|
||||||
|
];
|
||||||
|
|
||||||
|
tui-build = pkgs.writeShellApplication {
|
||||||
|
name = "tui-build";
|
||||||
|
runtimeInputs = [
|
||||||
|
pkgs.cmake
|
||||||
|
pkgs.ninja
|
||||||
|
];
|
||||||
|
text = ''
|
||||||
|
cmake -S . -B build -G Ninja
|
||||||
|
cmake --build build
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
|
||||||
|
tui-run = pkgs.writeShellApplication {
|
||||||
|
name = "tui-run";
|
||||||
|
runtimeInputs = [
|
||||||
|
tui-build
|
||||||
|
];
|
||||||
|
text = ''
|
||||||
|
tui-build
|
||||||
|
exec ./build/tui-music-player "$@"
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
|
||||||
|
tui-clean = pkgs.writeShellApplication {
|
||||||
|
name = "tui-clean";
|
||||||
|
text = ''
|
||||||
|
rm -rf build tui-music-player tui-music-player.exe
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
in
|
||||||
|
{
|
||||||
|
packages.default = pkgs.stdenv.mkDerivation {
|
||||||
|
pname = "tui-music-player";
|
||||||
|
version = "0.1.0";
|
||||||
|
src = ./.;
|
||||||
|
|
||||||
|
nativeBuildInputs = [
|
||||||
|
pkgs.cmake
|
||||||
|
pkgs.ninja
|
||||||
|
];
|
||||||
|
|
||||||
|
buildInputs = linuxAudioRuntime;
|
||||||
|
|
||||||
|
cmakeFlags = [
|
||||||
|
"-G"
|
||||||
|
"Ninja"
|
||||||
|
];
|
||||||
|
|
||||||
|
installPhase = ''
|
||||||
|
runHook preInstall
|
||||||
|
install -Dm755 tui-music-player "$out/bin/tui-music-player"
|
||||||
|
runHook postInstall
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
|
||||||
|
devShells.default = pkgs.mkShell {
|
||||||
|
packages =
|
||||||
|
[
|
||||||
|
pkgs.cmake
|
||||||
|
pkgs.ninja
|
||||||
|
pkgs.gnumake
|
||||||
|
pkgs.pkg-config
|
||||||
|
pkgs.clang-tools
|
||||||
|
tui-build
|
||||||
|
tui-run
|
||||||
|
tui-clean
|
||||||
|
]
|
||||||
|
++ linuxAudioRuntime;
|
||||||
|
|
||||||
|
shellHook = ''
|
||||||
|
mkdir -p music
|
||||||
|
echo "TUI Music Player devshell"
|
||||||
|
echo " tui-build - configure and build with CMake + Ninja"
|
||||||
|
echo " tui-run - build, then run ./build/tui-music-player"
|
||||||
|
echo " tui-clean - remove generated build outputs"
|
||||||
|
echo "Put audio files in ./music, then run: tui-run"
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
};
|
||||||
|
in
|
||||||
|
{
|
||||||
|
packages = forAllSystems (system: (perSystem system).packages);
|
||||||
|
devShells = forAllSystems (system: (perSystem system).devShells);
|
||||||
|
};
|
||||||
|
}
|
||||||
1
music/.gitkeep
Normal file
1
music/.gitkeep
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
|
||||||
759
src/main.c
Normal file
759
src/main.c
Normal file
|
|
@ -0,0 +1,759 @@
|
||||||
|
#define MINIAUDIO_IMPLEMENTATION
|
||||||
|
#include "../vendor/miniaudio.h"
|
||||||
|
|
||||||
|
#include <ctype.h>
|
||||||
|
#include <errno.h>
|
||||||
|
#include <locale.h>
|
||||||
|
#include <stdbool.h>
|
||||||
|
#include <stdio.h>
|
||||||
|
#include <stdlib.h>
|
||||||
|
#include <string.h>
|
||||||
|
#include <time.h>
|
||||||
|
|
||||||
|
#ifdef _WIN32
|
||||||
|
#define WIN32_LEAN_AND_MEAN
|
||||||
|
#include <conio.h>
|
||||||
|
#include <windows.h>
|
||||||
|
#else
|
||||||
|
#include <dirent.h>
|
||||||
|
#include <sys/ioctl.h>
|
||||||
|
#include <sys/select.h>
|
||||||
|
#include <termios.h>
|
||||||
|
#include <unistd.h>
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#define MAX_TRACKS 256
|
||||||
|
#define PATH_BUFFER 1024
|
||||||
|
#define STATUS_BUFFER 256
|
||||||
|
#define MUSIC_DIRECTORY "music"
|
||||||
|
|
||||||
|
typedef enum {
|
||||||
|
KEY_NONE = 0,
|
||||||
|
KEY_ARROW_UP = 1000,
|
||||||
|
KEY_ARROW_DOWN,
|
||||||
|
KEY_ENTER_VALUE
|
||||||
|
} Key;
|
||||||
|
|
||||||
|
typedef struct {
|
||||||
|
char *path;
|
||||||
|
} Track;
|
||||||
|
|
||||||
|
typedef struct {
|
||||||
|
Track tracks[MAX_TRACKS];
|
||||||
|
size_t count;
|
||||||
|
size_t selected;
|
||||||
|
int current;
|
||||||
|
bool paused;
|
||||||
|
bool has_sound;
|
||||||
|
ma_engine engine;
|
||||||
|
ma_sound sound;
|
||||||
|
char status[STATUS_BUFFER];
|
||||||
|
} App;
|
||||||
|
|
||||||
|
typedef struct {
|
||||||
|
int rows;
|
||||||
|
int cols;
|
||||||
|
#ifdef _WIN32
|
||||||
|
DWORD original_input_mode;
|
||||||
|
DWORD original_output_mode;
|
||||||
|
HANDLE input;
|
||||||
|
HANDLE output;
|
||||||
|
#else
|
||||||
|
struct termios original_termios;
|
||||||
|
#endif
|
||||||
|
} Terminal;
|
||||||
|
|
||||||
|
static char *copy_string(const char *value) {
|
||||||
|
size_t len = strlen(value) + 1;
|
||||||
|
char *copy = malloc(len);
|
||||||
|
if (copy == NULL) {
|
||||||
|
return NULL;
|
||||||
|
}
|
||||||
|
memcpy(copy, value, len);
|
||||||
|
return copy;
|
||||||
|
}
|
||||||
|
|
||||||
|
static const char *basename_for_display(const char *path) {
|
||||||
|
const char *slash = strrchr(path, '/');
|
||||||
|
const char *backslash = strrchr(path, '\\');
|
||||||
|
const char *separator = slash;
|
||||||
|
if (separator == NULL || (backslash != NULL && backslash > separator)) {
|
||||||
|
separator = backslash;
|
||||||
|
}
|
||||||
|
return separator == NULL ? path : separator + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
static void set_status(App *app, const char *message) {
|
||||||
|
snprintf(app->status, sizeof(app->status), "%s", message);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void free_track(Track *track) {
|
||||||
|
free(track->path);
|
||||||
|
track->path = NULL;
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool has_audio_extension(const char *path) {
|
||||||
|
const char *dot = strrchr(path, '.');
|
||||||
|
if (dot == NULL) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
char extension[8];
|
||||||
|
size_t i = 0;
|
||||||
|
for (; dot[i] != '\0' && i + 1 < sizeof(extension); ++i) {
|
||||||
|
extension[i] = (char)tolower((unsigned char)dot[i]);
|
||||||
|
}
|
||||||
|
extension[i] = '\0';
|
||||||
|
|
||||||
|
return strcmp(extension, ".mp3") == 0 || strcmp(extension, ".wav") == 0 || strcmp(extension, ".flac") == 0 ||
|
||||||
|
strcmp(extension, ".ogg") == 0 || strcmp(extension, ".m4a") == 0 || strcmp(extension, ".aac") == 0 ||
|
||||||
|
strcmp(extension, ".opus") == 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool track_exists(App *app, const char *path) {
|
||||||
|
for (size_t i = 0; i < app->count; ++i) {
|
||||||
|
if (strcmp(app->tracks[i].path, path) == 0) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
static int compare_tracks_by_name(const void *left, const void *right) {
|
||||||
|
const Track *a = (const Track *)left;
|
||||||
|
const Track *b = (const Track *)right;
|
||||||
|
return strcmp(basename_for_display(a->path), basename_for_display(b->path));
|
||||||
|
}
|
||||||
|
|
||||||
|
static void sort_playlist(App *app) {
|
||||||
|
char *current_path = NULL;
|
||||||
|
char *selected_path = NULL;
|
||||||
|
|
||||||
|
if (app->current >= 0 && (size_t)app->current < app->count) {
|
||||||
|
current_path = app->tracks[app->current].path;
|
||||||
|
}
|
||||||
|
if (app->selected < app->count) {
|
||||||
|
selected_path = app->tracks[app->selected].path;
|
||||||
|
}
|
||||||
|
|
||||||
|
qsort(app->tracks, app->count, sizeof(app->tracks[0]), compare_tracks_by_name);
|
||||||
|
|
||||||
|
app->current = -1;
|
||||||
|
app->selected = 0;
|
||||||
|
for (size_t i = 0; i < app->count; ++i) {
|
||||||
|
if (current_path != NULL && app->tracks[i].path == current_path) {
|
||||||
|
app->current = (int)i;
|
||||||
|
}
|
||||||
|
if (selected_path != NULL && app->tracks[i].path == selected_path) {
|
||||||
|
app->selected = i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool terminal_begin(Terminal *terminal) {
|
||||||
|
#ifdef _WIN32
|
||||||
|
terminal->input = GetStdHandle(STD_INPUT_HANDLE);
|
||||||
|
terminal->output = GetStdHandle(STD_OUTPUT_HANDLE);
|
||||||
|
if (terminal->input == INVALID_HANDLE_VALUE || terminal->output == INVALID_HANDLE_VALUE) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
GetConsoleMode(terminal->input, &terminal->original_input_mode);
|
||||||
|
GetConsoleMode(terminal->output, &terminal->original_output_mode);
|
||||||
|
|
||||||
|
DWORD input_mode = terminal->original_input_mode;
|
||||||
|
input_mode &= ~(ENABLE_ECHO_INPUT | ENABLE_LINE_INPUT);
|
||||||
|
SetConsoleMode(terminal->input, input_mode);
|
||||||
|
|
||||||
|
DWORD output_mode = terminal->original_output_mode | ENABLE_VIRTUAL_TERMINAL_PROCESSING;
|
||||||
|
SetConsoleMode(terminal->output, output_mode);
|
||||||
|
#else
|
||||||
|
if (tcgetattr(STDIN_FILENO, &terminal->original_termios) != 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
struct termios raw = terminal->original_termios;
|
||||||
|
raw.c_lflag &= (tcflag_t) ~(ECHO | ICANON);
|
||||||
|
raw.c_cc[VMIN] = 0;
|
||||||
|
raw.c_cc[VTIME] = 0;
|
||||||
|
if (tcsetattr(STDIN_FILENO, TCSAFLUSH, &raw) != 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
printf("\x1b[?25l");
|
||||||
|
fflush(stdout);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
static void terminal_end(Terminal *terminal) {
|
||||||
|
printf("\x1b[?25h\x1b[0m");
|
||||||
|
fflush(stdout);
|
||||||
|
#ifdef _WIN32
|
||||||
|
SetConsoleMode(terminal->input, terminal->original_input_mode);
|
||||||
|
SetConsoleMode(terminal->output, terminal->original_output_mode);
|
||||||
|
#else
|
||||||
|
tcsetattr(STDIN_FILENO, TCSAFLUSH, &terminal->original_termios);
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
static void terminal_refresh_size(Terminal *terminal) {
|
||||||
|
#ifdef _WIN32
|
||||||
|
CONSOLE_SCREEN_BUFFER_INFO info;
|
||||||
|
if (GetConsoleScreenBufferInfo(terminal->output, &info)) {
|
||||||
|
terminal->cols = info.srWindow.Right - info.srWindow.Left + 1;
|
||||||
|
terminal->rows = info.srWindow.Bottom - info.srWindow.Top + 1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
#else
|
||||||
|
struct winsize ws;
|
||||||
|
if (ioctl(STDOUT_FILENO, TIOCGWINSZ, &ws) == 0 && ws.ws_col > 0 && ws.ws_row > 0) {
|
||||||
|
terminal->cols = ws.ws_col;
|
||||||
|
terminal->rows = ws.ws_row;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
terminal->cols = 80;
|
||||||
|
terminal->rows = 24;
|
||||||
|
}
|
||||||
|
|
||||||
|
static void sleep_ms(int milliseconds) {
|
||||||
|
#ifdef _WIN32
|
||||||
|
Sleep((DWORD)milliseconds);
|
||||||
|
#else
|
||||||
|
usleep((useconds_t)milliseconds * 1000);
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
static int read_key(void) {
|
||||||
|
#ifdef _WIN32
|
||||||
|
if (!_kbhit()) {
|
||||||
|
return KEY_NONE;
|
||||||
|
}
|
||||||
|
|
||||||
|
int ch = _getch();
|
||||||
|
if (ch == 0 || ch == 224) {
|
||||||
|
int extended = _getch();
|
||||||
|
if (extended == 72) {
|
||||||
|
return KEY_ARROW_UP;
|
||||||
|
}
|
||||||
|
if (extended == 80) {
|
||||||
|
return KEY_ARROW_DOWN;
|
||||||
|
}
|
||||||
|
return KEY_NONE;
|
||||||
|
}
|
||||||
|
if (ch == '\r') {
|
||||||
|
return KEY_ENTER_VALUE;
|
||||||
|
}
|
||||||
|
return ch;
|
||||||
|
#else
|
||||||
|
fd_set set;
|
||||||
|
struct timeval timeout = {0, 0};
|
||||||
|
FD_ZERO(&set);
|
||||||
|
FD_SET(STDIN_FILENO, &set);
|
||||||
|
|
||||||
|
if (select(STDIN_FILENO + 1, &set, NULL, NULL, &timeout) <= 0) {
|
||||||
|
return KEY_NONE;
|
||||||
|
}
|
||||||
|
|
||||||
|
unsigned char ch;
|
||||||
|
if (read(STDIN_FILENO, &ch, 1) != 1) {
|
||||||
|
return KEY_NONE;
|
||||||
|
}
|
||||||
|
if (ch == '\n' || ch == '\r') {
|
||||||
|
return KEY_ENTER_VALUE;
|
||||||
|
}
|
||||||
|
if (ch == '\x1b') {
|
||||||
|
unsigned char seq[2];
|
||||||
|
if (read(STDIN_FILENO, &seq[0], 1) != 1 || read(STDIN_FILENO, &seq[1], 1) != 1) {
|
||||||
|
return KEY_NONE;
|
||||||
|
}
|
||||||
|
if (seq[0] == '[' && seq[1] == 'A') {
|
||||||
|
return KEY_ARROW_UP;
|
||||||
|
}
|
||||||
|
if (seq[0] == '[' && seq[1] == 'B') {
|
||||||
|
return KEY_ARROW_DOWN;
|
||||||
|
}
|
||||||
|
return KEY_NONE;
|
||||||
|
}
|
||||||
|
return ch;
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool insert_track(App *app, size_t index, const char *path) {
|
||||||
|
if (app->count >= MAX_TRACKS || path[0] == '\0') {
|
||||||
|
set_status(app, "Cannot add track: playlist is full or path is empty.");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
char *copy = copy_string(path);
|
||||||
|
if (copy == NULL) {
|
||||||
|
set_status(app, "Cannot add track: out of memory.");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (index > app->count) {
|
||||||
|
index = app->count;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (size_t i = app->count; i > index; --i) {
|
||||||
|
app->tracks[i] = app->tracks[i - 1];
|
||||||
|
}
|
||||||
|
|
||||||
|
app->tracks[index].path = copy;
|
||||||
|
app->count++;
|
||||||
|
app->selected = index;
|
||||||
|
set_status(app, "Track added.");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
static void add_track_if_audio(App *app, const char *path, size_t *added) {
|
||||||
|
if (!has_audio_extension(path) || track_exists(app, path)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (insert_track(app, app->count, path)) {
|
||||||
|
(*added)++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static size_t scan_music_directory(App *app, const char *directory) {
|
||||||
|
size_t added = 0;
|
||||||
|
|
||||||
|
#ifdef _WIN32
|
||||||
|
char pattern[PATH_BUFFER];
|
||||||
|
snprintf(pattern, sizeof(pattern), "%s\\*", directory);
|
||||||
|
|
||||||
|
WIN32_FIND_DATAA data;
|
||||||
|
HANDLE find = FindFirstFileA(pattern, &data);
|
||||||
|
if (find == INVALID_HANDLE_VALUE) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
do {
|
||||||
|
if ((data.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY) != 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
char path[PATH_BUFFER];
|
||||||
|
snprintf(path, sizeof(path), "%s\\%s", directory, data.cFileName);
|
||||||
|
add_track_if_audio(app, path, &added);
|
||||||
|
} while (FindNextFileA(find, &data));
|
||||||
|
|
||||||
|
FindClose(find);
|
||||||
|
#else
|
||||||
|
DIR *dir = opendir(directory);
|
||||||
|
if (dir == NULL) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
struct dirent *entry;
|
||||||
|
while ((entry = readdir(dir)) != NULL) {
|
||||||
|
if (entry->d_name[0] == '.') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
char path[PATH_BUFFER];
|
||||||
|
snprintf(path, sizeof(path), "%s/%s", directory, entry->d_name);
|
||||||
|
add_track_if_audio(app, path, &added);
|
||||||
|
}
|
||||||
|
|
||||||
|
closedir(dir);
|
||||||
|
#endif
|
||||||
|
|
||||||
|
if (added > 0) {
|
||||||
|
sort_playlist(app);
|
||||||
|
snprintf(app->status, sizeof(app->status), "Loaded %zu track(s) from %s/.", added, directory);
|
||||||
|
}
|
||||||
|
return added;
|
||||||
|
}
|
||||||
|
|
||||||
|
static void load_startup_tracks(App *app, int argc, char **argv) {
|
||||||
|
size_t added = 0;
|
||||||
|
|
||||||
|
for (int i = 1; i < argc; ++i) {
|
||||||
|
add_track_if_audio(app, argv[i], &added);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (argc <= 1) {
|
||||||
|
added = scan_music_directory(app, MUSIC_DIRECTORY);
|
||||||
|
} else if (added > 0) {
|
||||||
|
sort_playlist(app);
|
||||||
|
snprintf(app->status, sizeof(app->status), "Loaded %zu track(s) from command line.", added);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (app->count == 0) {
|
||||||
|
snprintf(app->status, sizeof(app->status), "Put music files in %s/ or press 'a' to add a path.", MUSIC_DIRECTORY);
|
||||||
|
}
|
||||||
|
app->selected = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
static void stop_current(App *app) {
|
||||||
|
if (app->has_sound) {
|
||||||
|
ma_sound_stop(&app->sound);
|
||||||
|
ma_sound_uninit(&app->sound);
|
||||||
|
app->has_sound = false;
|
||||||
|
}
|
||||||
|
app->paused = false;
|
||||||
|
app->current = -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool play_track(App *app, size_t index) {
|
||||||
|
if (index >= app->count) {
|
||||||
|
set_status(app, "No track selected.");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
stop_current(app);
|
||||||
|
|
||||||
|
ma_result result = ma_sound_init_from_file(&app->engine, app->tracks[index].path, MA_SOUND_FLAG_STREAM, NULL, NULL,
|
||||||
|
&app->sound);
|
||||||
|
if (result != MA_SUCCESS) {
|
||||||
|
snprintf(app->status, sizeof(app->status), "Cannot open: %s", app->tracks[index].path);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
app->has_sound = true;
|
||||||
|
result = ma_sound_start(&app->sound);
|
||||||
|
if (result != MA_SUCCESS) {
|
||||||
|
stop_current(app);
|
||||||
|
set_status(app, "Playback failed.");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
app->current = (int)index;
|
||||||
|
app->selected = index;
|
||||||
|
app->paused = false;
|
||||||
|
snprintf(app->status, sizeof(app->status), "Playing: %s", basename_for_display(app->tracks[index].path));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
static void poll_audio(App *app) {
|
||||||
|
if (app->has_sound && !app->paused && ma_sound_at_end(&app->sound)) {
|
||||||
|
stop_current(app);
|
||||||
|
set_status(app, "Playback finished.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static void delete_selected(App *app) {
|
||||||
|
if (app->count == 0) {
|
||||||
|
set_status(app, "Playlist is already empty.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
size_t index = app->selected;
|
||||||
|
bool deleting_current = app->current == (int)index;
|
||||||
|
if (deleting_current) {
|
||||||
|
stop_current(app);
|
||||||
|
}
|
||||||
|
|
||||||
|
free_track(&app->tracks[index]);
|
||||||
|
for (size_t i = index; i + 1 < app->count; ++i) {
|
||||||
|
app->tracks[i] = app->tracks[i + 1];
|
||||||
|
}
|
||||||
|
|
||||||
|
app->count--;
|
||||||
|
if (app->count == 0) {
|
||||||
|
app->selected = 0;
|
||||||
|
app->current = -1;
|
||||||
|
} else if (app->selected >= app->count) {
|
||||||
|
app->selected = app->count - 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!deleting_current && app->current > (int)index) {
|
||||||
|
app->current--;
|
||||||
|
}
|
||||||
|
|
||||||
|
set_status(app, "Track deleted.");
|
||||||
|
}
|
||||||
|
|
||||||
|
static void shuffle_playlist(App *app) {
|
||||||
|
if (app->count < 2) {
|
||||||
|
set_status(app, "Need at least two tracks to shuffle.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
char *current_path = NULL;
|
||||||
|
if (app->current >= 0 && (size_t)app->current < app->count) {
|
||||||
|
current_path = app->tracks[app->current].path;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (size_t i = app->count - 1; i > 0; --i) {
|
||||||
|
size_t j = (size_t)(rand() % (int)(i + 1));
|
||||||
|
Track tmp = app->tracks[i];
|
||||||
|
app->tracks[i] = app->tracks[j];
|
||||||
|
app->tracks[j] = tmp;
|
||||||
|
}
|
||||||
|
|
||||||
|
app->selected = 0;
|
||||||
|
app->current = -1;
|
||||||
|
if (current_path != NULL) {
|
||||||
|
for (size_t i = 0; i < app->count; ++i) {
|
||||||
|
if (app->tracks[i].path == current_path) {
|
||||||
|
app->current = (int)i;
|
||||||
|
app->selected = i;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
set_status(app, "Playlist shuffled.");
|
||||||
|
}
|
||||||
|
|
||||||
|
static void toggle_pause(App *app) {
|
||||||
|
if (!app->has_sound) {
|
||||||
|
if (app->count > 0) {
|
||||||
|
play_track(app, app->selected);
|
||||||
|
} else {
|
||||||
|
set_status(app, "Nothing to pause: playlist is empty.");
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (app->paused) {
|
||||||
|
if (ma_sound_start(&app->sound) == MA_SUCCESS) {
|
||||||
|
app->paused = false;
|
||||||
|
set_status(app, "Resumed.");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (ma_sound_stop(&app->sound) == MA_SUCCESS) {
|
||||||
|
app->paused = true;
|
||||||
|
set_status(app, "Paused.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static void move_selection(App *app, int delta) {
|
||||||
|
if (app->count == 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (delta < 0 && app->selected > 0) {
|
||||||
|
app->selected--;
|
||||||
|
} else if (delta > 0 && app->selected + 1 < app->count) {
|
||||||
|
app->selected++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static void play_relative(App *app, int delta) {
|
||||||
|
if (app->count == 0) {
|
||||||
|
set_status(app, "Playlist is empty.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
int base = app->current >= 0 ? app->current : (int)app->selected;
|
||||||
|
int next = base + delta;
|
||||||
|
if (next < 0) {
|
||||||
|
next = (int)app->count - 1;
|
||||||
|
} else if (next >= (int)app->count) {
|
||||||
|
next = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
play_track(app, (size_t)next);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void prompt_add_track(App *app, Terminal *terminal) {
|
||||||
|
char path[PATH_BUFFER];
|
||||||
|
terminal_end(terminal);
|
||||||
|
printf("\nAdd path: ");
|
||||||
|
fflush(stdout);
|
||||||
|
|
||||||
|
if (fgets(path, sizeof(path), stdin) == NULL) {
|
||||||
|
terminal_begin(terminal);
|
||||||
|
set_status(app, "Add cancelled.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
path[strcspn(path, "\r\n")] = '\0';
|
||||||
|
terminal_begin(terminal);
|
||||||
|
|
||||||
|
if (path[0] == '\0') {
|
||||||
|
set_status(app, "Add cancelled.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
size_t index = app->count == 0 ? 0 : app->selected + 1;
|
||||||
|
insert_track(app, index, path);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void print_clipped(const char *text, int width) {
|
||||||
|
for (int i = 0; i < width && text[i] != '\0'; ++i) {
|
||||||
|
putchar(text[i]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static void draw(App *app, Terminal *terminal) {
|
||||||
|
terminal_refresh_size(terminal);
|
||||||
|
int rows = terminal->rows;
|
||||||
|
int cols = terminal->cols;
|
||||||
|
|
||||||
|
printf("\x1b[2J\x1b[H");
|
||||||
|
if (rows < 10 || cols < 48) {
|
||||||
|
printf("Terminal is too small. Resize to at least 48x10.\n");
|
||||||
|
fflush(stdout);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const char *state = "Stopped";
|
||||||
|
if (app->has_sound) {
|
||||||
|
state = app->paused ? "Paused" : "Playing";
|
||||||
|
}
|
||||||
|
|
||||||
|
printf("\x1b[1mTUI Music Player\x1b[0m\n");
|
||||||
|
printf("State: %-8s Tracks: %zu/%d\n", state, app->count, MAX_TRACKS);
|
||||||
|
for (int i = 0; i < cols; ++i) {
|
||||||
|
putchar('-');
|
||||||
|
}
|
||||||
|
putchar('\n');
|
||||||
|
|
||||||
|
int list_top = 4;
|
||||||
|
int list_bottom = rows - 5;
|
||||||
|
int visible = list_bottom - list_top + 1;
|
||||||
|
size_t offset = 0;
|
||||||
|
if (visible > 0 && app->selected >= (size_t)visible) {
|
||||||
|
offset = app->selected - (size_t)visible + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (app->count == 0) {
|
||||||
|
printf("Playlist is empty. Put files in music/ and press 'r', or press 'a' to add a path.\n");
|
||||||
|
} else {
|
||||||
|
for (int line = 0; line < visible; ++line) {
|
||||||
|
size_t index = offset + (size_t)line;
|
||||||
|
if (index >= app->count) {
|
||||||
|
putchar('\n');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool selected = index == app->selected;
|
||||||
|
bool current = app->current == (int)index;
|
||||||
|
if (selected) {
|
||||||
|
printf("\x1b[7m");
|
||||||
|
}
|
||||||
|
if (current) {
|
||||||
|
printf("\x1b[1m");
|
||||||
|
}
|
||||||
|
|
||||||
|
printf("%c %3zu ", current ? '>' : ' ', index + 1);
|
||||||
|
print_clipped(basename_for_display(app->tracks[index].path), cols - 8);
|
||||||
|
printf("\x1b[0m\n");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
printf("\x1b[%d;1H", rows - 3);
|
||||||
|
for (int i = 0; i < cols; ++i) {
|
||||||
|
putchar('-');
|
||||||
|
}
|
||||||
|
printf("\n");
|
||||||
|
print_clipped(
|
||||||
|
"j/k or arrows move | Enter play | P pause | S shuffle | a add | r rescan | d delete | n/b next/prev | q quit",
|
||||||
|
cols);
|
||||||
|
printf("\nStatus: ");
|
||||||
|
print_clipped(app->status, cols - 8);
|
||||||
|
fflush(stdout);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void cleanup(App *app) {
|
||||||
|
stop_current(app);
|
||||||
|
ma_engine_uninit(&app->engine);
|
||||||
|
for (size_t i = 0; i < app->count; ++i) {
|
||||||
|
free_track(&app->tracks[i]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
int main(int argc, char **argv) {
|
||||||
|
setlocale(LC_ALL, "");
|
||||||
|
srand((unsigned int)time(NULL));
|
||||||
|
|
||||||
|
App app = {
|
||||||
|
.count = 0,
|
||||||
|
.selected = 0,
|
||||||
|
.current = -1,
|
||||||
|
.paused = false,
|
||||||
|
.has_sound = false,
|
||||||
|
};
|
||||||
|
set_status(&app, "Starting.");
|
||||||
|
|
||||||
|
if (ma_engine_init(NULL, &app.engine) != MA_SUCCESS) {
|
||||||
|
fprintf(stderr, "Could not initialize audio engine.\n");
|
||||||
|
return EXIT_FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
load_startup_tracks(&app, argc, argv);
|
||||||
|
|
||||||
|
Terminal terminal = {0};
|
||||||
|
if (!terminal_begin(&terminal)) {
|
||||||
|
cleanup(&app);
|
||||||
|
fprintf(stderr, "Could not initialize terminal.\n");
|
||||||
|
return EXIT_FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool running = true;
|
||||||
|
while (running) {
|
||||||
|
poll_audio(&app);
|
||||||
|
draw(&app, &terminal);
|
||||||
|
|
||||||
|
int key = read_key();
|
||||||
|
switch (key) {
|
||||||
|
case KEY_NONE:
|
||||||
|
sleep_ms(40);
|
||||||
|
break;
|
||||||
|
case 'q':
|
||||||
|
running = false;
|
||||||
|
break;
|
||||||
|
case KEY_ARROW_UP:
|
||||||
|
case 'k':
|
||||||
|
move_selection(&app, -1);
|
||||||
|
break;
|
||||||
|
case KEY_ARROW_DOWN:
|
||||||
|
case 'j':
|
||||||
|
move_selection(&app, 1);
|
||||||
|
break;
|
||||||
|
case 'g':
|
||||||
|
app.selected = 0;
|
||||||
|
break;
|
||||||
|
case 'G':
|
||||||
|
if (app.count > 0) {
|
||||||
|
app.selected = app.count - 1;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case KEY_ENTER_VALUE:
|
||||||
|
play_track(&app, app.selected);
|
||||||
|
break;
|
||||||
|
case 'p':
|
||||||
|
case 'P':
|
||||||
|
case ' ':
|
||||||
|
toggle_pause(&app);
|
||||||
|
break;
|
||||||
|
case 's':
|
||||||
|
case 'S':
|
||||||
|
shuffle_playlist(&app);
|
||||||
|
break;
|
||||||
|
case 'a':
|
||||||
|
prompt_add_track(&app, &terminal);
|
||||||
|
break;
|
||||||
|
case 'r': {
|
||||||
|
size_t added = scan_music_directory(&app, MUSIC_DIRECTORY);
|
||||||
|
if (added == 0) {
|
||||||
|
snprintf(app.status, sizeof(app.status), "No new audio files found in %s/.", MUSIC_DIRECTORY);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'd':
|
||||||
|
case 'x':
|
||||||
|
delete_selected(&app);
|
||||||
|
break;
|
||||||
|
case 'n':
|
||||||
|
play_relative(&app, 1);
|
||||||
|
break;
|
||||||
|
case 'b':
|
||||||
|
play_relative(&app, -1);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
if (isprint(key)) {
|
||||||
|
set_status(&app, "Unknown key.");
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
terminal_end(&terminal);
|
||||||
|
cleanup(&app);
|
||||||
|
return EXIT_SUCCESS;
|
||||||
|
}
|
||||||
95864
vendor/miniaudio.h
vendored
Normal file
95864
vendor/miniaudio.h
vendored
Normal file
File diff suppressed because it is too large
Load diff
Loading…
Add table
Add a link
Reference in a new issue