32 Commits

Author SHA1 Message Date
ec2c20a3c9 Update unistore/mice.unistore
All checks were successful
Build (3DS) / build (push) Successful in 2m10s
2026-02-01 17:45:46 -06:00
54a6dfddad Merge branch 'master' of git.sillyangel.dev:angel/mice-3ds
All checks were successful
Build (3DS) / build (push) Successful in 2m16s
2026-02-01 16:41:12 -06:00
e3683a12b7 feat: update build tasks, enhance metadata handling, and improve GUI text display 2026-02-01 16:39:05 -06:00
877e40b073 Update Makefile.linux
All checks were successful
Build (3DS) / build (push) Successful in 2m6s
Signed-off-by: angel <angel@sillyangel.dev>
2025-12-09 09:11:28 -06:00
88026628ea fix: url
Some checks failed
Build (3DS) / build (push) Failing after 57s
2025-12-07 16:49:58 -06:00
87669c74a5 feat: add unistore configuration and spritesheet assets for mice music player
Some checks failed
Build (3DS) / build (push) Has been cancelled
2025-12-07 16:49:20 -06:00
906734a14c fix: update copyright year to 2025 in main header files
All checks were successful
Build (3DS) / build (push) Successful in 2m37s
2025-12-07 15:58:52 -06:00
92647e2f1d workflow: fix the gosh damn thing
All checks were successful
Build (3DS) / build (push) Successful in 2m27s
2025-12-07 15:53:37 -06:00
36924ddfae feat: refactor album art handling, implement progress bar display, and update application version to dev63
Some checks failed
Build (3DS) / build (push) Failing after 2m6s
2025-12-07 15:39:55 -06:00
d09cf0739e feat: implement album art display functionality and update application version to dev50
Some checks failed
Build (3DS) / build (push) Failing after 1m59s
2025-12-06 23:31:45 -06:00
8be23ca4fc feat: add M4A/AAC support and update application version to dev43 2025-12-06 23:06:32 -06:00
5d9ce1fdb9 fix: update application version to dev37 and streamline playback logging
Some checks failed
Build (3DS) / build (push) Failing after 1m58s
2025-12-06 22:44:34 -06:00
e491cddfcb GUI 2025-12-06 22:42:06 -06:00
ab9b766be3 fix: adjust console window sizes for better display
Some checks failed
Build (3DS) / build (push) Failing after 2m2s
2025-12-06 21:14:57 -06:00
2ded2a5a00 Refactor code structure for improved readability and maintainability
Some checks failed
Build (3DS) / build (push) Failing after 2m10s
2025-12-06 21:08:15 -06:00
e833b79458 new: added some server
Some checks failed
Build (3DS) / build (push) Failing after 2m14s
2025-12-07 01:21:43 +00:00
53322f8767 workflow: Install Node.js and update artifact name in 3DS build 2025-12-07 01:18:27 +00:00
285351e982 YAY
Some checks failed
Build (3DS) / build (push) Failing after 1m13s
2025-12-06 23:47:15 +00:00
Mahyar Koshkouei
ecac9e6064 README.md: Update QR code to 0.5.3
Updated QR code for the latest release.
2025-10-22 16:23:21 +01:00
Mahyar Koshkouei
8b028eb41b README.md: Update QR code URL 2025-10-15 12:12:33 +01:00
Narayan
909effaaef main: fix autoplaying with directory in list (#83) 2025-10-10 12:07:35 +02:00
Mahyar Koshkouei
227000e7e3 actions: compile on pull request and merge 2025-10-09 15:37:36 +01:00
Mahyar Koshkouei
f8c2ede98b Merge pull request #79 from narayanx/feature/switch-song-lr
Allow switching with L/R, fix with pause/play, remove stop song
2025-08-25 14:21:49 +01:00
Mahyar Koshkouei
81b85b6c53 Merge pull request #77 from narayanx/feature/save-parent-position
Save parent position in folder
2025-08-25 14:17:39 +01:00
Narayan
02007c48eb made requested modifications: removed wrong memset, moved declarations, removed extra newlines 2025-08-23 19:06:03 -07:00
Narayan
5820223214 deleted stop code 2025-08-22 00:35:39 -07:00
Narayan
4401238766 ZL switching wasn't working fixed, also made pause/play with L+R work if R pressed first, resolving: https://github.com/deltabeard/ctrmus/issues/78 2025-08-22 00:31:02 -07:00
Narayan
47106bd059 reword comment 2025-08-19 14:00:10 -07:00
Narayan
bd283f7f5f removed debug print and random whitespace 2025-08-19 13:51:23 -07:00
Narayan
28e3275bdc got initial impl of stack to save positions working 2025-08-19 13:47:03 -07:00
Narayan
576d320fb7 added new constant for impl 2025-08-19 12:11:55 -07:00
Narayan
fbf9db3e45 grammar fix in error message 2025-08-19 12:11:41 -07:00
39 changed files with 4967 additions and 274 deletions

59
.github/workflows/3ds.yml vendored Normal file
View File

@@ -0,0 +1,59 @@
name: Build (3DS)
on:
push:
branches: [ master, actions ]
pull_request:
workflow_dispatch:
jobs:
build:
runs-on: ubuntu-latest
defaults:
run:
shell: bash
# Build inside the official devkitPro image (has dkp-pacman & toolchains preconfigured)
container:
image: devkitpro/devkitarm:latest
options: --user root
env:
DEVKITPRO: /opt/devkitpro
DEVKITARM: /opt/devkitpro/devkitARM
PATH: /opt/devkitpro/tools/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
steps:
- name: Install Node.js (required for GitHub Actions)
run: |
apt-get update
apt-get install -y nodejs npm
- name: Checkout (with submodules)
uses: actions/checkout@v4
with:
submodules: recursive
- name: Install makerom & bannertool (Linux x64)
run: |
MAKEROM_VER=makerom-v0.18.4
BANNERP_URL="https://github.com/Epicpkmn11/bannertool/releases/download/v1.2.2/bannertool.zip"
set -euxo pipefail
mkdir -p /opt/devkitpro/tools/bin
curl -L "https://github.com/3DSGuy/Project_CTR/releases/download/${MAKEROM_VER}/${MAKEROM_VER}-ubuntu_x86_64.zip" -o /tmp/makerom.zip
unzip -j /tmp/makerom.zip -d /opt/devkitpro/tools/bin
chmod +x /opt/devkitpro/tools/bin/makerom
curl -L "$BANNERP_URL" -o /tmp/bannertool.zip
unzip -p /tmp/bannertool.zip 'linux-x86_64/bannertool' > /opt/devkitpro/tools/bin/bannertool
chmod +x /opt/devkitpro/tools/bin/bannertool
- name: Build
run: make -j"$(nproc)"
- name: Upload artifacts
uses: actions/upload-artifact@v3
with:
name: mice-build
path: output/
if-no-files-found: warn

1
.gitignore vendored
View File

@@ -1,5 +1,6 @@
build
output
node_modules
# Object files
*.o

View File

@@ -1,3 +1,4 @@
{
"C_Cpp.errorSquiggles": "disabled"
"C_Cpp.errorSquiggles": "disabled",
"cmake.sourceDirectory": "/home/angel/Documents/mice-3ds/include/dr_libs"
}

67
.vscode/tasks.json vendored
View File

@@ -1,6 +1,24 @@
{
"version": "2.0.0",
"tasks": [
{
"label": "Clean build artifacts",
"type": "shell",
"command": "rm",
"args": [
"-rf",
"${workspaceFolder}/build/*",
"${workspaceFolder}/output/3ds-arm/*"
],
"options": {
"cwd": "${workspaceFolder}"
},
"problemMatcher": [],
"presentation": {
"reveal": "always",
"panel": "shared"
}
},
{
"label": "Build 3DS ROM",
"type": "shell",
@@ -12,10 +30,59 @@
"problemMatcher": [
"$gcc"
],
"presentation": {
"reveal": "always",
"panel": "shared"
},
"group": {
"kind": "build",
"isDefault": true
}
},
{
"label": "Build (full)",
"type": "shell",
"command": "make",
"args": [],
"options": {
"cwd": "${workspaceFolder}"
},
"problemMatcher": [
"$gcc"
],
"presentation": {
"reveal": "always",
"panel": "shared"
},
"group": "build"
},
{
"label": "Serve (dev)",
"type": "shell",
"command": "npm",
"args": [ "run", "dev" ],
"options": {
"cwd": "${workspaceFolder}/server"
},
"isBackground": true,
"presentation": {
"echo": true,
"reveal": "always",
"panel": "dedicated"
}
},
{
"label": "Serve (prod)",
"type": "shell",
"command": "npm",
"args": [ "start" ],
"options": {
"cwd": "${workspaceFolder}/server"
},
"presentation": {
"reveal": "always",
"panel": "shared"
}
}
]
}

View File

@@ -39,7 +39,7 @@ endif
# COMMON CONFIGURATION #
NAME := ctrmus
NAME := mice
BUILD_DIR := build
OUTPUT_DIR := output
@@ -49,9 +49,9 @@ SOURCE_DIRS := source
EXTRA_OUTPUT_FILES :=
LIBRARY_DIRS := $(DEVKITPRO)/libctru $(DEVKITPRO)/portlibs/armv6k $(DEVKITPRO)/portlibs/3ds
LIBRARIES := sidplay mpg123 vorbisidec opusfile opus ogg ctru m
LIBRARIES := citro2d citro3d sidplay mpg123 vorbisidec opusfile opus ogg ctru m
BUILD_FLAGS := -Wall -Wextra -I$(DEVKITPRO)/portlibs/armv6k/include/opus -I$(DEVKITPRO)/portlibs/3ds/include/opus -O3 -g3 -ffunction-sections -fdata-sections
BUILD_FLAGS := -Wall -Wextra -I$(DEVKITPRO)/libctru/include -I$(DEVKITPRO)/portlibs/armv6k/include/opus -I$(DEVKITPRO)/portlibs/3ds/include/opus -O3 -g3 -ffunction-sections -fdata-sections
# -O0 -g3 -fstack-protector-strong -fsanitize=undefined -fsanitize-trap
RUN_FLAGS :=
@@ -64,10 +64,10 @@ VERSION_MICRO := $(word 3, $(VERSION_PARTS))
# 3DS CONFIGURATION #
TITLE := $(NAME)
DESCRIPTION := 3DS Music Player
AUTHOR := Deltabeard
PRODUCT_CODE := CTR-P-CMUS
UNIQUE_ID := 0xFF3CC
DESCRIPTION := local files music player
AUTHOR := sillyangel
PRODUCT_CODE := CTR-P-MICE
UNIQUE_ID := 0xFB1CE
SYSTEM_MODE := 64MB
SYSTEM_MODE_EXT := Legacy
@@ -81,4 +81,7 @@ ICON := meta/icon.png
# INTERNAL #
# Auto-increment version before build
$(shell ./increment_version.sh >/dev/null 2>&1)
include buildtools/make_base

View File

@@ -15,6 +15,8 @@ else
HOST_ARCH := x86_64
else ifeq ($(UNAME_M),$(filter $(UNAME_M),i386 i686))
HOST_ARCH := i686
else ifeq ($(UNAME_M),$(filter $(UNAME_M),aarch64 arm64))
HOST_ARCH := arm64
else
$(error "Unsupported host architecture.")
endif

View File

@@ -1,11 +1,12 @@
<img align="right" alt="The ctrmus icon" src="meta/icon.png">
<img align="right" alt="The mice icon" src="meta/icon.png">
# ctrmus: a music player for the Nintendo 3DS
# mice: a music player for the Nintendo 3DS
The latest 3DSX/CIA/3DS download can be found on the <a href="https://github.com/deltabeard/ctrmus/releases">releases</a> page, or by scanning <a href="https://zxing.org/w/chart?cht=qr&chs=230x230&chld=L&choe=UTF-8&chl=https%3A%2F%2Fgithub.com%2Fdeltabeard%2Fctrmus%2Freleases%2Fdownload%2F0.4.12%2Fctrmus.cia">this QR code</a>.
The latest 3DSX/CIA/3DS download can be found on the <a href="https://github.com/deltabeard/ctrmus/releases">releases</a> page, or by scanning <a href="https://zxing.org/w/chart?cht=qr&chs=350x350&chld=M&choe=UTF-8&chl=https%3A%2F%2Fgithub.com%2Fdeltabeard%2Fctrmus%2Freleases%2Fdownload%2Fv0.5.3%2Fctrmus.cia">this QR code</a>.
## Features
* Plays PCM WAV, AIFF, FLAC, Opus, Vorbis and MP3 files.
* M4A/AAC/ALAC support (file detection implemented, decoder integration in progress).
* Pause and play support.
* Plays music via headphones whilst system is closed.
* Ability to browse directories.

View File

@@ -27,10 +27,10 @@ struct errInfo_t
/**
* Return string describing error number. Extends strerror to include some
* custom errors used in ctrmus.
* custom errors used in mice.
*
* \param err Error number.
*/
char* ctrmus_strerror(int err);
char* mice_strerror(int err);
#endif

View File

@@ -6,7 +6,9 @@ enum file_types
FILE_TYPE_VORBIS,
FILE_TYPE_OPUS,
FILE_TYPE_MP3,
FILE_TYPE_SID
FILE_TYPE_SID,
FILE_TYPE_M4A,
FILE_TYPE_AAC
};
/**

126
include/gui.h Normal file
View File

@@ -0,0 +1,126 @@
#ifndef mice_gui_h
#define mice_gui_h
#include <3ds.h>
#include <citro2d.h>
#include <stdbool.h>
#include "metadata.h"
/* GUI color definitions */
#define GUI_COLOR_BG_TOP C2D_Color32(20, 20, 30, 255)
#define GUI_COLOR_BG_BOTTOM C2D_Color32(15, 15, 25, 255)
#define GUI_COLOR_TEXT C2D_Color32(255, 255, 255, 255)
#define GUI_COLOR_TEXT_DIM C2D_Color32(180, 180, 180, 255)
#define GUI_COLOR_ACCENT C2D_Color32(100, 150, 255, 255)
#define GUI_COLOR_HIGHLIGHT C2D_Color32(50, 80, 150, 255)
/* Screen dimensions */
#define TOP_SCREEN_WIDTH 400
#define TOP_SCREEN_HEIGHT 240
#define BOTTOM_SCREEN_WIDTH 320
#define BOTTOM_SCREEN_HEIGHT 240
/**
* Initialize the GUI system
*
* \return 0 on success, -1 on failure
*/
int guiInit(void);
/**
* Clean up and exit the GUI system
*/
void guiExit(void);
/**
* Begin rendering a frame
*/
void guiBeginFrame(void);
/**
* End rendering a frame and display it
*/
void guiEndFrame(void);
/**
* Clear the top screen
*/
void guiClearTopScreen(void);
/**
* Clear the bottom screen
*/
void guiClearBottomScreen(void);
/**
* Display metadata on the top screen
*
* \param metadata Pointer to metadata structure
* \param filename Filename to display if no title is available
*/
void guiDisplayMetadata(struct metadata_t* metadata, const char* filename);
/**
* Display log messages on the top screen
*
* \param messages Array of message strings
* \param count Number of messages
* \param scroll Scroll offset for messages
*/
void guiDisplayLog(const char** messages, int count, int scroll);
/**
* Display file list on the bottom screen
*
* \param files Array of filenames
* \param count Number of files
* \param selected Index of selected file
* \param scroll Scroll offset
*/
void guiDisplayFileList(const char** files, int count, int selected, int scroll);
/**
* Display playback controls and status on the bottom screen
*
* \param isPlaying Whether playback is active
* \param isPaused Whether playback is paused
* \param position Current position in seconds
* \param duration Total duration in seconds
*/
void guiDisplayPlaybackStatus(bool isPlaying, bool isPaused, float position, float duration);
/**
* Display version text
*
* \param version Version string to display
*/
void guiDisplayVersion(const char* version);
/**
* Draw a simple text string at specified position
*
* \param screen Target screen (GFX_TOP or GFX_BOTTOM)
* \param x X coordinate
* \param y Y coordinate
* \param text Text to display
* \param color Text color
* \param scale Text scale (default 0.5f)
*/
void guiDrawText(gfxScreen_t screen, float x, float y, const char* text, u32 color, float scale);
/**
* Display progress bar on top screen
*
* \param position Current position in seconds
* \param duration Total duration in seconds
*/
void guiDisplayProgressBar(float position, float duration);
/**
* Display current directory path on bottom screen
*
* \param path Current directory path
*/
void guiDisplayCurrentPath(const char* path);
#endif

21
include/m4a.h Normal file
View File

@@ -0,0 +1,21 @@
#ifndef M4A_H
#define M4A_H
#include "playback.h"
/**
* Set decoder parameters for M4A/AAC.
*
* \param decoder Structure to store parameters.
*/
void setM4a(struct decoder_fn* decoder);
/**
* Check if a file is an M4A/AAC/ALAC file.
*
* \param file File location.
* \return 0 on success, -1 on failure.
*/
int isM4a(const char* file);
#endif

View File

@@ -1,6 +1,6 @@
/**
* ctrmus - 3DS Music Player
* Copyright (C) 2016 Mahyar Koshkouei
* mice - 3DS Music Player
* Copyright (C) 2025 sillyangel
*
* This program comes with ABSOLUTELY NO WARRANTY and is free software. You are
* welcome to redistribute it under certain conditions; for details see the
@@ -9,14 +9,22 @@
#include <3ds.h>
#ifndef ctrmus_main_h
#define ctrmus_main_h
#ifndef mice_main_h
#define mice_main_h
/* Application version */
#define MICE_VERSION "dev68"
/* Default folder */
#define DEFAULT_DIR "sdmc:/"
/* Maximum number of lines that can be displayed */
/* Maximum number of lines that can be displayed on bottom screen */
#define MAX_LIST 28
/* Arbitrary cap for number of stored parent positions in folder to avoid
* unbounded memory consumption. If directories are added exceeding this,
* dequeues path closest to root to make space.
*/
#define MAX_DIRECTORIES 20
struct watchdogInfo
{

42
include/metadata.h Normal file
View File

@@ -0,0 +1,42 @@
#ifndef mice_metadata_h
#define mice_metadata_h
#include <stdint.h>
#include <stdbool.h>
#define METADATA_TITLE_MAX 64
#define METADATA_ARTIST_MAX 64
#define METADATA_ALBUM_MAX 64
struct metadata_t
{
char title[METADATA_TITLE_MAX];
char artist[METADATA_ARTIST_MAX];
char album[METADATA_ALBUM_MAX];
};
/**
* Extract metadata from an audio file
*
* \param file Path to the audio file
* \param metadata Pointer to metadata structure to fill
* \return 0 on success, -1 on failure
*/
int extractMetadata(const char* file, struct metadata_t* metadata);
/**
* Clear metadata structure and free any allocated memory
*
* \param metadata Pointer to metadata structure to clear
*/
void clearMetadata(struct metadata_t* metadata);
/**
* Display metadata on the top screen
*
* \param metadata Pointer to metadata structure to display
* \param filename Filename to display if no title is available
*/
void displayMetadata(struct metadata_t* metadata, const char* filename);
#endif

View File

@@ -1,8 +1,8 @@
#include <stdbool.h>
#include <limits.h>
#ifndef ctrmus_playback_h
#define ctrmus_playback_h
#ifndef mice_playback_h
#define mice_playback_h
/* Channel to play music on */
#define CHANNEL 0x08
@@ -80,6 +80,11 @@ void stopPlayback(void);
*/
bool isPlaying(void);
/**
* Returns whether playback is currently paused.
*/
bool isPaused(void);
/**
* Should only be called from a new thread only, and have only one playback
* thread at time. This function has not been written for more than one

19
increment_version.sh Executable file
View File

@@ -0,0 +1,19 @@
#!/bin/bash
# Auto-increment development version number
VERSION_FILE="include/main.h"
CURRENT_VERSION=$(grep "MICE_VERSION" "$VERSION_FILE" | sed -n 's/.*"dev\([0-9]*\)".*/\1/p')
if [ -z "$CURRENT_VERSION" ]; then
echo "Could not find version number, starting at dev1"
NEW_VERSION=1
else
NEW_VERSION=$((CURRENT_VERSION + 1))
fi
echo "Incrementing version: dev$CURRENT_VERSION -> dev$NEW_VERSION"
# Update the version in the header file
sed -i "s/dev[0-9]*/dev$NEW_VERSION/g" "$VERSION_FILE"
echo "Version updated to dev$NEW_VERSION"

View File

@@ -1,4 +1,4 @@
## audio.wav
Ctrmus uses a modified version of [Rad Adventure by Scott Holmes](http://freemusicarchive.org/music/Scott_Holmes/~/Rad_Adventure) of which is licensed under a [Attribution-NonCommercial License](https://creativecommons.org/licenses/by-nc/4.0/).
Permission for use in ctrmus is granted.
Mice uses [Internal Monologues by Jon Shuemaker](https://freemusicarchive.org/music/jon-shuemaker/anoka-vol2/internal-monologues/) of which is licensed under a [Attribution-NonCommercial 4.0 International License.](https://creativecommons.org/licenses/by-nc/4.0/).
Permission for use in mice is granted.

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 727 B

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

57
server/README.md Normal file
View File

@@ -0,0 +1,57 @@
# mice Download Server
Simple Node.js server to host mice 3DS homebrew files for easy download via QR code.
## Quick Start
1. **Install dependencies:**
```bash
cd server
npm install
```
2. **Build mice first:****
```bash
cd ..
make
```
3. **Start the server:**
```bash
cd server
npm start
```
4. **Access the download page:**
- Open browser to `http://localhost:3000`
- Use QR codes to download on 3DS browser
- Or find your local IP and access from any device on the network
## Features
- 📱 QR codes for easy 3DS browser access
- 📦 Automatic detection of build files (.3dsx, .cia, .3ds, .elf)
- 🎮 Installation instructions included
- 📊 File size information
- 🌐 Network accessible from any device
## Usage on 3DS
1. Connect your 3DS to the same WiFi network as your computer
2. Open the Internet Browser on your 3DS
3. Scan the QR code or manually enter the URL
4. Download the .3dsx or .cia file
5. Install according to the instructions on the page
## Development
```bash
npm run dev # Uses nodemon for auto-restart
```
## File Types
- **.3dsx** - Homebrew Launcher format (copy to `/3ds/` folder)
- **.cia** - Installable format (use FBI or similar installer)
- **.3ds** - 3DS ROM format
- **.elf** - Debug/development format

1465
server/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

20
server/package.json Normal file
View File

@@ -0,0 +1,20 @@
{
"name": "mice-server",
"version": "1.0.0",
"description": "Simple file server for mice 3DS homebrew downloads",
"main": "server.js",
"scripts": {
"start": "node server.js",
"dev": "nodemon server.js"
},
"dependencies": {
"express": "^4.18.2",
"qrcode": "^1.5.3"
},
"devDependencies": {
"nodemon": "^3.0.2"
},
"keywords": ["3ds", "homebrew", "file-server"],
"author": "3DS Dev",
"license": "MIT"
}

1072
server/pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

346
server/server.js Normal file
View File

@@ -0,0 +1,346 @@
const express = require('express');
const path = require('path');
const fs = require('fs');
const QRCode = require('qrcode');
const app = express();
const PORT = process.env.PORT || 4012;
// Function to read current version from main.h
function getCurrentVersion() {
try {
const mainHPath = path.join(__dirname, '../include/main.h');
const content = fs.readFileSync(mainHPath, 'utf8');
const versionMatch = content.match(/#define\s+MICE_VERSION\s+"([^"]+)"/);
return versionMatch ? versionMatch[1] : 'unknown';
} catch (error) {
console.log('Could not read version:', error.message);
return 'unknown';
}
}
// Function to get build timestamp
function getBuildTimestamp() {
try {
const outputDir = path.join(__dirname, '../output');
if (fs.existsSync(outputDir)) {
const stats = fs.statSync(outputDir);
return stats.mtime.toLocaleString();
}
} catch (error) {
console.log('Could not read build timestamp:', error.message);
}
return 'unknown';
}
// Serve static files from the output directory
app.use('/downloads', express.static(path.join(__dirname, '../output')));
// Basic styling for the web interface with dark mode support
const CSS = `
<style>
:root {
--bg-color: #f5f5f5;
--container-bg: white;
--text-color: #333;
--border-color: #ddd;
--section-bg: #fafafa;
--accent-color: #0066cc;
--button-hover: #0052a3;
}
@media (prefers-color-scheme: dark) {
:root {
--bg-color: #1a1a1a;
--container-bg: #2d2d2d;
--text-color: #e0e0e0;
--border-color: #555;
--section-bg: #3a3a3a;
--accent-color: #4da6ff;
--button-hover: #3d8bff;
}
}
body {
font-family: Arial, sans-serif;
max-width: 800px;
margin: 0 auto;
padding: 20px;
background-color: var(--bg-color);
color: var(--text-color);
transition: background-color 0.3s, color 0.3s;
}
.container {
background: var(--container-bg);
padding: 30px;
border-radius: 10px;
box-shadow: 0 2px 20px rgba(0,0,0,0.3);
transition: background-color 0.3s;
}
h1 {
color: var(--text-color);
text-align: center;
border-bottom: 3px solid var(--accent-color);
padding-bottom: 10px;
}
.version-info {
text-align: center;
margin: 10px 0 20px 0;
padding: 10px;
background: var(--section-bg);
border-radius: 5px;
font-size: 14px;
color: var(--text-color);
}
.download-section {
margin: 30px 0;
padding: 20px;
border: 2px solid var(--border-color);
border-radius: 8px;
background-color: var(--section-bg);
transition: background-color 0.3s;
}
.download-link {
display: inline-block;
padding: 12px 24px;
background-color: var(--accent-color);
color: white;
text-decoration: none;
border-radius: 5px;
margin: 10px 10px 10px 0;
font-weight: bold;
transition: background-color 0.3s;
}
.download-link:hover {
background-color: var(--button-hover);
}
.download-link:hover {
background-color: #0052a3;
}
.qr-code {
text-align: center;
margin: 20px 0;
}
.file-info {
background-color: #e8f4fd;
padding: 15px;
border-left: 4px solid #0066cc;
margin: 15px 0;
}
.installation-guide {
background-color: #fff8dc;
padding: 20px;
border-radius: 8px;
margin: 20px 0;
}
@media (prefers-color-scheme: dark) {
.file-info {
background-color: #1a2940;
border-left-color: #2d5aa0;
}
.installation-guide {
background-color: #2a3b5c;
}
}
</style>`;
// Main page
app.get('/', async (req, res) => {
const outputDir = path.join(__dirname, '../output');
// Use the client's actual request URL
const protocol = req.get('x-forwarded-proto') || req.protocol || 'http';
const host = req.get('x-forwarded-host') || req.get('host');
const baseUrl = `${protocol}://${host}`;
// Check if build files exist
const files = [];
const fileExtensions = ['.3dsx', '.cia', '.3ds', '.elf'];
try {
const items = fs.readdirSync(outputDir, { recursive: true });
for (const item of items) {
const itemPath = path.join(outputDir, item);
const stats = fs.statSync(itemPath);
if (stats.isFile()) {
const ext = path.extname(item).toLowerCase();
if (fileExtensions.includes(ext)) {
const filePath = `/downloads/${item}`;
const fileUrl = `${baseUrl}${filePath}`;
files.push({
name: item,
path: filePath,
url: fileUrl,
size: (stats.size / 1024).toFixed(1), // KB
type: ext.substring(1).toUpperCase()
});
}
}
}
} catch (err) {
console.error('Error reading output directory:', err);
}
let html = `
<!DOCTYPE html>
<html>
<head>
<title>mice - 3DS Music Player Downloads</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="refresh" content="30">
${CSS}
<script>
// Auto-refresh every 30 seconds to check for new builds
setInterval(() => {
fetch('/api/version')
.then(response => response.json())
.then(data => {
const versionElement = document.getElementById('current-version');
const buildElement = document.getElementById('build-time');
if (versionElement) versionElement.textContent = data.version;
if (buildElement) buildElement.textContent = data.buildTime;
})
.catch(console.error);
}, 30000);
</script>
</head>
<body>
<div class="container">
<h1>🎵 mice - 3DS Music Player 🎵</h1>
<div class="version-info">
<strong>Current Version:</strong> <span id="current-version">${getCurrentVersion()}</span><br>
<strong>Last Build:</strong> <span id="build-time">${getBuildTimestamp()}</span>
</div>
<div class="installation-guide">
<h3>📱 Installation Instructions:</h3>
<p><strong>For .3dsx files (Homebrew Launcher):</strong></p>
<ul>
<li>Copy the .3dsx file to <code>/3ds/</code> folder on your SD card</li>
<li>Launch via Homebrew Launcher</li>
</ul>
<p><strong>For .cia files (installed to HOME menu):</strong></p>
<ul>
<li>Install using FBI, DevMenu, or similar CIA installer</li>
<li>Appears on HOME menu after installation</li>
</ul>
</div>
`;
if (files.length === 0) {
html += `
<div class="file-info">
<h3>⚠️ No build files found</h3>
<p>Please run <code>make</code> in the mice directory to build the project first.</p>
</div>
`;
} else {
html += `<h2>📦 Available Downloads:</h2>`;
for (const file of files) {
// Generate QR code for each file
let qrCodeDataUrl = '';
try {
qrCodeDataUrl = await QRCode.toDataURL(file.url);
} catch (err) {
console.error('Error generating QR code:', err);
}
html += `
<div class="download-section">
<h3>🎮 ${file.name}</h3>
<div class="file-info">
<strong>Type:</strong> ${file.type} &nbsp;&nbsp;
<strong>Size:</strong> ${file.size} KB
</div>
<a href="${file.path}" class="download-link" download>
📥 Download ${file.type}
</a>
${qrCodeDataUrl ? `
<div class="qr-code">
<p><strong>📱 Scan QR Code for 3DS Browser:</strong></p>
<img src="${qrCodeDataUrl}" alt="QR Code for ${file.name}" style="border: 2px solid #ddd; padding: 10px; background: white;">
<br>
<small style="color: #666; word-break: break-all;">${file.url}</small>
</div>
` : ''}
</div>
`;
}
}
html += `
<div class="file-info">
<h3>🎵 Features:</h3>
<ul>
<li>Support for MP3, FLAC, OGG Vorbis, Opus, WAV, and SID formats</li>
<li>Metadata display (artist, album, title) on top screen</li>
<li>File browser with folder navigation</li>
<li>Playback controls and sleep mode support</li>
</ul>
</div>
<div class="installation-guide">
<h3>🔗 Access Information:</h3>
<p>Server running at: <strong>${baseUrl}</strong></p>
${baseUrl.includes('github.dev') ?
'<p>✅ GitHub Codespaces detected - URL is ready for 3DS browser!</p>' :
'<p>💡 For 3DS access, use your local network IP address</p>'
}
</div>
</div>
</body>
</html>
`;
res.send(html);
});
// API endpoint for version info
app.get('/api/version', (req, res) => {
res.json({
version: getCurrentVersion(),
buildTime: getBuildTimestamp(),
timestamp: new Date().toISOString()
});
});
// Health check endpoint
app.get('/health', (req, res) => {
res.json({ status: 'ok', timestamp: new Date().toISOString() });
});
// Start server
app.listen(PORT, '0.0.0.0', () => {
console.log(`🎵 mice Download Server`);
// Detect if we're in GitHub Codespaces
const codespace = process.env.CODESPACE_NAME;
if (codespace) {
const codespacesUrl = `https://${codespace}-${PORT}.app.github.dev/`;
console.log(`🌐 GitHub Codespaces URL: ${codespacesUrl}`);
console.log(`📱 Use this URL for 3DS browser access!`);
} else {
console.log(`🌐 Server running at: http://localhost:${PORT}`);
console.log(`📱 Access from 3DS browser using your local IP`);
}
console.log(`📦 Serving files from: ${path.join(__dirname, '../output')}`);
console.log(`\n🔗 To find your local IP address:`);
console.log(` - Windows: ipconfig`);
console.log(` - Mac/Linux: ifconfig or ip addr show`);
});
// Graceful shutdown
process.on('SIGTERM', () => {
console.log('🛑 Server shutting down...');
process.exit(0);
});
module.exports = app;

View File

@@ -4,11 +4,11 @@
/**
* Return string describing error number. Extends strerror to include some
* custom errors used in ctrmus.
* custom errors used in mice.
*
* \param err Error number.
*/
char* ctrmus_strerror(int err)
char* mice_strerror(int err)
{
char* error;
@@ -18,11 +18,11 @@ char* ctrmus_strerror(int err)
error = "NDSP Initialisation failed.\nYou may need to dump DSP firmware.\n"
"You can do this within Rosalina (using Luma CFW):\n"
" 1. Press L+Down+Select\n 2. Misc. Options.\n 3. Dump DSP Firmware.\n"
" 4. Restart ctrmus.";
" 4. Restart mice.";
break;
case DECODER_INIT_FAIL:
error = "Unable to initialised decoder";
error = "Unable to initialise decoder";
break;
case FILE_NOT_SUPPORTED:

View File

@@ -5,6 +5,7 @@
#include "error.h"
#include "file.h"
#include "flac.h"
#include "m4a.h"
#include "mp3.h"
#include "opus.h"
#include "vorbis.h"
@@ -26,7 +27,9 @@ const char* fileToStr(enum file_types ft)
"VORBIS",
"OPUS",
"MP3",
"SID"
"SID",
"M4A",
"AAC"
};
return file_types_str[ft];
@@ -90,24 +93,37 @@ enum file_types getFileType(const char *file)
file_type = FILE_TYPE_SID;
break;
default:
/*
* MP3 without ID3 tag, ID3v1 tag is at the end of file, or MP3
* with ID3 tag at the beginning of the file.
*/
if(isMp3(file) == 0)
{
file_type = FILE_TYPE_MP3;
break;
}
/* TODO: Add this again at some point */
//printf("Unknown magic number: %#010x\n.", fileSig);
errno = FILE_NOT_SUPPORTED;
default:
/* Check for M4A/AAC/ALAC (MP4 container with ftyp atom) */
if((fileSig == 0x70797466) || /* 'ftyp' at offset 4 */
(fileSig == 0x65657266)) /* 'free' at offset 4 (some M4A files) */
{
file_type = FILE_TYPE_M4A;
break;
}
}
/* Check for raw AAC (ADTS format) - sync word 0xFFF */
if((fileSig & 0xFFF60000) == 0xFFF00000)
{
file_type = FILE_TYPE_AAC;
break;
}
err:
/*
* MP3 without ID3 tag, ID3v1 tag is at the end of file, or MP3
* with ID3 tag at the beginning of the file.
*/
if(isMp3(file) == 0)
{
file_type = FILE_TYPE_MP3;
break;
}
/* TODO: Add this again at some point */
//printf("Unknown magic number: %#010x\n.", fileSig);
errno = FILE_NOT_SUPPORTED;
break;
}err:
fclose(ftest);
return file_type;
}

418
source/gui.c Normal file
View File

@@ -0,0 +1,418 @@
#include <3ds.h>
#include <citro2d.h>
#include <citro3d.h>
#include <string.h>
#include <stdio.h>
#include <stdlib.h>
#include "gui.h"
#include "metadata.h"
static C3D_RenderTarget* topTarget = NULL;
static C3D_RenderTarget* bottomTarget = NULL;
static C2D_TextBuf textBuf = NULL;
/**
* Initialize the GUI system
*/
int guiInit(void)
{
gfxInitDefault();
C3D_Init(C3D_DEFAULT_CMDBUF_SIZE);
C2D_Init(C2D_DEFAULT_MAX_OBJECTS);
C2D_Prepare();
/* Create render targets for top and bottom screens */
topTarget = C2D_CreateScreenTarget(GFX_TOP, GFX_LEFT);
bottomTarget = C2D_CreateScreenTarget(GFX_BOTTOM, GFX_LEFT);
if(!topTarget || !bottomTarget)
return -1;
/* Create text buffer */
textBuf = C2D_TextBufNew(4096);
if(!textBuf)
return -1;
return 0;
}
/**
* Clean up and exit the GUI system
*/
void guiExit(void)
{
if(textBuf)
C2D_TextBufDelete(textBuf);
C2D_Fini();
C3D_Fini();
gfxExit();
}
/**
* Begin rendering a frame
*/
void guiBeginFrame(void)
{
C3D_FrameBegin(C3D_FRAME_SYNCDRAW);
}
/**
* End rendering a frame and display it
*/
void guiEndFrame(void)
{
C3D_FrameEnd(0);
}
/**
* Clear the top screen
*/
void guiClearTopScreen(void)
{
C2D_TargetClear(topTarget, GUI_COLOR_BG_TOP);
}
/**
* Clear the bottom screen
*/
void guiClearBottomScreen(void)
{
C2D_TargetClear(bottomTarget, GUI_COLOR_BG_BOTTOM);
}
/**
* Draw a simple text string at specified position
* @param screen Screen to draw on (GFX_TOP or GFX_BOTTOM)
* @param x X position
* @param y Y position
* @param text Text string to draw
* @param color Text color
* @param scale Text scale
*/
void guiDrawText(gfxScreen_t screen, float x, float y, const char* text, u32 color, float scale)
{
if(!text || !textBuf)
return;
C2D_Text c2dText;
C2D_TextBufClear(textBuf);
C2D_TextParse(&c2dText, textBuf, text);
C2D_TextOptimize(&c2dText);
C3D_RenderTarget* target = (screen == GFX_TOP) ? topTarget : bottomTarget;
C2D_SceneBegin(target);
C2D_DrawText(&c2dText, C2D_WithColor, x, y, 0.5f, scale, scale, color);
}
/**
* Display metadata on the top screen
* @param metadata Pointer to metadata structure to display
* @param filename Filename to display if no title is available
*/
void guiDisplayMetadata(struct metadata_t* metadata, const char* filename)
{
if(!metadata || !filename || !textBuf)
return;
C2D_SceneBegin(topTarget);
/* Extract just the filename without path and extension for fallback */
const char* basename = strrchr(filename, '/');
if(!basename)
basename = filename;
else
basename++;
/* Remove file extension for display */
char displayName[64];
strncpy(displayName, basename, sizeof(displayName) - 1);
displayName[sizeof(displayName) - 1] = '\0';
char* dot = strrchr(displayName, '.');
if(dot) *dot = '\0';
C2D_Text text;
float y = 10.0f;
float scale = 0.6f;
float lineHeight = 20.0f;
C2D_TextBufClear(textBuf);
/* Draw title */
if(metadata->title[0])
{
char titleBuf[64];
snprintf(titleBuf, sizeof(titleBuf), "%.47s", metadata->title);
C2D_TextParse(&text, textBuf, titleBuf);
C2D_TextOptimize(&text);
C2D_DrawText(&text, C2D_WithColor, 10.0f, y, 0.5f, scale, scale, GUI_COLOR_TEXT);
}
else
{
char titleBuf[64];
snprintf(titleBuf, sizeof(titleBuf), "%.47s", displayName);
C2D_TextParse(&text, textBuf, titleBuf);
C2D_TextOptimize(&text);
C2D_DrawText(&text, C2D_WithColor, 10.0f, y, 0.5f, scale, scale, GUI_COLOR_TEXT);
}
y += lineHeight;
/* Draw artist */
if(metadata->artist[0])
{
char artistBuf[64];
snprintf(artistBuf, sizeof(artistBuf), "%.45s", metadata->artist);
C2D_TextParse(&text, textBuf, artistBuf);
C2D_TextOptimize(&text);
C2D_DrawText(&text, C2D_WithColor, 10.0f, y, 0.5f, scale * 0.8f, scale * 0.8f, GUI_COLOR_TEXT_DIM);
}
else
{
C2D_TextParse(&text, textBuf, "Unknown Artist");
C2D_TextOptimize(&text);
C2D_DrawText(&text, C2D_WithColor, 10.0f, y, 0.5f, scale * 0.8f, scale * 0.8f, GUI_COLOR_TEXT_DIM);
}
y += lineHeight;
/* Draw album */
if(metadata->album[0])
{
char albumBuf[64];
snprintf(albumBuf, sizeof(albumBuf), "%.45s", metadata->album);
C2D_TextParse(&text, textBuf, albumBuf);
C2D_TextOptimize(&text);
C2D_DrawText(&text, C2D_WithColor, 10.0f, y, 0.5f, scale * 0.8f, scale * 0.8f, GUI_COLOR_TEXT_DIM);
}
else
{
C2D_TextParse(&text, textBuf, "Unknown Album");
C2D_TextOptimize(&text);
C2D_DrawText(&text, C2D_WithColor, 10.0f, y, 0.5f, scale * 0.8f, scale * 0.8f, GUI_COLOR_TEXT_DIM);
}
}
/**
* Display log messages on the top screen
* @param messages Array of log message strings
* @param count Number of messages in the array
* @param scroll Index of first visible message for scrolling
*/
void guiDisplayLog(const char** messages, int count, int scroll)
{
if(!messages || count <= 0 || !textBuf)
return;
C2D_SceneBegin(topTarget);
C2D_Text text;
float y = 70.0f; /* Start below metadata area */
float scale = 0.4f;
float lineHeight = 12.0f;
int maxLines = 14;
C2D_TextBufClear(textBuf);
for(int i = scroll; i < count && (i - scroll) < maxLines; i++)
{
if(messages[i])
{
C2D_TextParse(&text, textBuf, messages[i]);
C2D_TextOptimize(&text);
C2D_DrawText(&text, C2D_WithColor, 10.0f, y, 0.5f, scale, scale, GUI_COLOR_TEXT);
y += lineHeight;
}
}
}
/**
* Display file list on the bottom screen
* @param files Array of file/folder names
* @param count Number of entries in the array
* @param selected Index of currently selected entry
* @param scroll Index of first visible entry for scrolling
*/
void guiDisplayFileList(const char** files, int count, int selected, int scroll)
{
if(!files || count <= 0 || !textBuf)
return;
C2D_SceneBegin(bottomTarget);
C2D_Text text;
float y = 18.0f; /* Start below path display */
float scale = 0.5f;
float lineHeight = 16.0f;
int maxLines = 13; /* One less line due to path at top */
C2D_TextBufClear(textBuf);
for(int i = scroll; i < count && (i - scroll) < maxLines; i++)
{
if(files[i])
{
/* Check if this is a directory */
size_t len = strlen(files[i]);
bool isDir = (len > 0 && files[i][len-1] == '/');
/* Draw selection highlight */
if(i == selected)
{
C2D_DrawRectSolid(5.0f, y - 2.0f, 0.5f, 310.0f, lineHeight, GUI_COLOR_HIGHLIGHT);
}
/* Truncate if too long */
char displayName[48];
snprintf(displayName, sizeof(displayName), "%.40s", files[i]);
C2D_TextParse(&text, textBuf, displayName);
C2D_TextOptimize(&text);
/* Use different color for directories */
u32 color;
if(i == selected)
color = GUI_COLOR_ACCENT;
else if(isDir)
color = C2D_Color32(100, 200, 255, 255); /* Light blue for folders */
else
color = GUI_COLOR_TEXT;
C2D_DrawText(&text, C2D_WithColor, 10.0f, y, 0.5f, scale, scale, color);
y += lineHeight;
}
}
}
/**
* Display playback controls and status on the top screen
* @param isPlaying Whether playback is active
* @param isPaused Whether playback is paused
* @param position Current playback position in seconds
* @param duration Total duration in seconds
*/
void guiDisplayPlaybackStatus(bool isPlaying, bool isPaused, float position, float duration)
{
if(!textBuf)
return;
C2D_SceneBegin(topTarget);
C2D_Text text;
C2D_TextBufClear(textBuf);
/* Display status and time at bottom of top screen */
float y = 215.0f;
/* Display status */
char statusBuf[64];
if(isPlaying)
{
if(isPaused)
snprintf(statusBuf, sizeof(statusBuf), "Paused");
else
snprintf(statusBuf, sizeof(statusBuf), "Playing");
}
else
{
snprintf(statusBuf, sizeof(statusBuf), "Stopped");
}
C2D_TextParse(&text, textBuf, statusBuf);
C2D_TextOptimize(&text);
C2D_DrawText(&text, C2D_WithColor, 10.0f, y, 0.5f, 0.5f, 0.5f, GUI_COLOR_TEXT);
/* Display time if playing */
if(isPlaying && duration > 0)
{
char timeBuf[32];
int posMin = (int)position / 60;
int posSec = (int)position % 60;
int durMin = (int)duration / 60;
int durSec = (int)duration % 60;
snprintf(timeBuf, sizeof(timeBuf), "%02d:%02d / %02d:%02d", posMin, posSec, durMin, durSec);
C2D_TextParse(&text, textBuf, timeBuf);
C2D_TextOptimize(&text);
C2D_DrawText(&text, C2D_WithColor, 280.0f, y, 0.5f, 0.5f, 0.5f, GUI_COLOR_TEXT);
}
}
/**
* Display version text and credits at bottom of bottom screen
* @param version Version string to display
*/
void guiDisplayVersion(const char* version)
{
if(!textBuf)
return;
C2D_SceneBegin(bottomTarget);
C2D_Text text;
C2D_TextBufClear(textBuf);
/* Display "mice - by sillyangel" at bottom center */
char credits[64];
if(version && version[0])
snprintf(credits, sizeof(credits), "mice %s - by sillyangel", version);
else
snprintf(credits, sizeof(credits), "mice - by sillyangel");
C2D_TextParse(&text, textBuf, credits);
C2D_TextOptimize(&text);
C2D_DrawText(&text, C2D_WithColor, 80.0f, 220.0f, 0.5f, 0.45f, 0.45f, GUI_COLOR_TEXT_DIM);
}
/**
* Display progress bar on the top screen
* @param position Current playback position in seconds
* @param duration Total duration in seconds
*/
void guiDisplayProgressBar(float position, float duration)
{
if(duration <= 0)
return;
C2D_SceneBegin(topTarget);
/* Progress bar at bottom of top screen */
float barY = 205.0f;
float barX = 10.0f;
float barWidth = 380.0f;
float barHeight = 6.0f;
/* Background bar */
C2D_DrawRectSolid(barX, barY, 0.5f, barWidth, barHeight, C2D_Color32(50, 50, 60, 255));
/* Progress fill */
float progress = position / duration;
if(progress > 1.0f) progress = 1.0f;
if(progress < 0.0f) progress = 0.0f;
float fillWidth = barWidth * progress;
if(fillWidth > 0)
{
C2D_DrawRectSolid(barX, barY, 0.5f, fillWidth, barHeight, GUI_COLOR_ACCENT);
}
}
/**
* Display current directory path on bottom screen
* @param path Current directory path
*/
void guiDisplayCurrentPath(const char* path)
{
if(!path || !textBuf)
return;
C2D_SceneBegin(bottomTarget);
C2D_Text text;
C2D_TextBufClear(textBuf);
/* Display path at top of bottom screen */
char pathBuf[64];
snprintf(pathBuf, sizeof(pathBuf), "%.55s", path);
C2D_TextParse(&text, textBuf, pathBuf);
C2D_TextOptimize(&text);
C2D_DrawText(&text, C2D_WithColor, 5.0f, 2.0f, 0.5f, 0.35f, 0.35f, GUI_COLOR_TEXT_DIM);
}

133
source/m4a.c Normal file
View File

@@ -0,0 +1,133 @@
#include <errno.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <3ds.h>
#include "error.h"
#include "m4a.h"
#include "playback.h"
/* TODO: Integrate proper AAC decoder library (libfaad2, fdk-aac, or minimp4) */
/* For now, this is a stub implementation */
static size_t* buffSize;
static uint32_t rate = 44100;
static uint8_t channels = 2;
static int initM4a(const char* file);
static uint32_t rateM4a(void);
static uint8_t channelM4a(void);
static uint64_t decodeM4a(void* buffer);
static void exitM4a(void);
static size_t getFileSamplesM4a(void);
/**
* Set decoder parameters for M4A/AAC.
*
* \param decoder Structure to store parameters.
*/
void setM4a(struct decoder_fn* decoder)
{
decoder->init = &initM4a;
decoder->rate = &rateM4a;
decoder->channels = &channelM4a;
buffSize = &(decoder->buffSize);
decoder->decode = &decodeM4a;
decoder->exit = &exitM4a;
decoder->getFileSamples = &getFileSamplesM4a;
}
/**
* Check if a file is an M4A/AAC/ALAC file.
*
* \param file File location.
* \return 0 on success, -1 on failure.
*/
int isM4a(const char* file)
{
FILE* ftest = fopen(file, "rb");
uint32_t fileSig;
uint32_t ftypSig;
if(ftest == NULL)
return -1;
/* Read first 4 bytes (should be size of ftyp atom) */
if(fread(&fileSig, 4, 1, ftest) == 0)
{
fclose(ftest);
return -1;
}
/* Read next 4 bytes (should be 'ftyp') */
if(fread(&ftypSig, 4, 1, ftest) == 0)
{
fclose(ftest);
return -1;
}
fclose(ftest);
/* Check for 'ftyp' signature (0x70797466 in little-endian) */
if(ftypSig == 0x70797466)
return 0;
return -1;
}
static int initM4a(const char* file)
{
(void)file;
/* TODO: Initialize AAC decoder */
/* This requires:
* 1. Parse MP4 container to find AAC audio track
* 2. Extract decoder config (sample rate, channels, etc.)
* 3. Initialize AAC decoder with config
*/
/* Set default values for now */
rate = 44100;
channels = 2;
*buffSize = rate * channels * sizeof(int16_t);
errno = FILE_NOT_SUPPORTED;
return -1;
}
static uint32_t rateM4a(void)
{
return rate;
}
static uint8_t channelM4a(void)
{
return channels;
}
static uint64_t decodeM4a(void* buffer)
{
(void)buffer;
/* TODO: Decode AAC frame */
/* This requires:
* 1. Read next AAC frame from MP4 container
* 2. Decode AAC frame to PCM samples
* 3. Write PCM samples to buffer
* 4. Return number of samples decoded
*/
return 0;
}
static void exitM4a(void)
{
/* TODO: Clean up AAC decoder */
}
static size_t getFileSamplesM4a(void)
{
/* TODO: Calculate total samples from MP4 metadata */
return 0;
}

View File

@@ -1,6 +1,6 @@
/**
* ctrmus - 3DS Music Player
* Copyright (C) 2016 Mahyar Koshkouei
* mice - 3DS Music Player
* Copyright (C) 2025 sillyangel
*
* This program comes with ABSOLUTELY NO WARRANTY and is free software. You are
* welcome to redistribute it under certain conditions; for details see the
@@ -11,6 +11,7 @@
#include <dirent.h>
#include <stdbool.h>
#include <stdio.h>
#include <ctype.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
@@ -19,24 +20,33 @@
#include "error.h"
#include "file.h"
#include "main.h"
#include "metadata.h"
#include "playback.h"
#include "gui.h"
volatile bool runThreads = true;
/**
* Prints the current key mappings to stdio.
*/
static void showControls(void)
{
printf("Button mappings:\n"
"Pause: L+R or L+Up\n"
"Previous/Next Song: ZL/ZR or L/R\n"
"A: Open File\n"
"B: Go up folder\n"
"Start: Exit\n"
"Browse: Up, Down, Left or Right\n");
/* Log message buffer for GUI display */
#define MAX_LOG_MESSAGES 100
static char* logMessages[MAX_LOG_MESSAGES] = {0};
static int logMessageCount = 0;
static int logScroll = 0;
static void addLogMessage(const char* msg) {
if (logMessageCount >= MAX_LOG_MESSAGES) {
/* Remove oldest message */
free(logMessages[0]);
memmove(logMessages, logMessages + 1, (MAX_LOG_MESSAGES - 1) * sizeof(char*));
logMessageCount--;
}
logMessages[logMessageCount++] = strdup(msg);
}
/**
* Prints the current key mappings (removed - not needed for GUI)
*/
/* Controls are now implied by the GUI interface */
/**
* Allows the playback thread to return any error messages that it may
* encounter.
@@ -55,19 +65,17 @@ void playbackWatchdog(void* infoIn)
if(*info->errInfo->error > 0)
{
continue;
consoleSelect(info->screen);
printf("Error %d: %s\n", *info->errInfo->error,
ctrmus_strerror(*info->errInfo->error));
char errorMsg[256];
snprintf(errorMsg, sizeof(errorMsg), "Error %d: %s",
*info->errInfo->error, mice_strerror(*info->errInfo->error));
addLogMessage(errorMsg);
}
else if (*info->errInfo->error == -1)
{
continue;
/* Used to signify that playback has stopped.
* Not technically an error.
* Not technically an error. Don't spam logs.
*/
consoleSelect(info->screen);
puts("Stopped");
/* addLogMessage("Stopped"); */
}
}
@@ -114,11 +122,12 @@ static int changeFile(const char* ep_file, struct playbackInfo_t* playbackInfo)
//playbackInfo->file = strdup(ep_file);
if (memccpy(playbackInfo->file, ep_file, '\0', sizeof(playbackInfo->file)) == NULL)
{
puts("Error: File path too long\n");
addLogMessage("Error: File path too long");
return -1;
}
printf("Playing: %s\n", playbackInfo->file);
/* Remove "Playing:" message that was causing display issues */
/* printf("Playing: %s\n", playbackInfo->file); */
playbackInfo->samples_total = 0;
playbackInfo->samples_played = 0;
playbackInfo->samples_per_second = 0;
@@ -138,8 +147,127 @@ static int cmpstringp(const void *p1, const void *p2)
return strcasecmp(* (char * const *) p1, * (char * const *) p2);
}
/* Check filename extension against supported audio types. Case-insensitive. */
static bool isMusicFilename(const char *name)
{
if(name == NULL)
return false;
const char *ext = strrchr(name, '.');
if(ext == NULL || *(ext + 1) == '\0')
return false;
ext++; /* skip dot */
char lext[16];
size_t i = 0;
for(; i < sizeof(lext)-1 && ext[i]; i++)
lext[i] = tolower((unsigned char)ext[i]);
lext[i] = '\0';
const char *allowed[] = {"mp3","wav","flac","ogg","opus","sid","m4a","aac", NULL};
for(int j = 0; allowed[j] != NULL; j++)
if(strcmp(lext, allowed[j]) == 0)
return true;
return false;
}
/* Build a list of music filenames (basenames) in the given directory. Caller must free array and each string. */
static int buildMusicFileListInDir(const char *dirpath, char ***outFiles)
{
DIR *dp;
struct dirent *ep;
char **files = NULL;
int fileNum = 0;
if((dp = opendir(dirpath)) == NULL)
return -1;
while((ep = readdir(dp)) != NULL)
{
if(ep->d_type == DT_DIR)
continue;
if(!isMusicFilename(ep->d_name))
continue;
files = realloc(files, (fileNum + 1) * sizeof(char*));
files[fileNum] = strdup(ep->d_name);
fileNum++;
}
if(fileNum > 0)
qsort(files, fileNum, sizeof(char*), cmpstringp);
closedir(dp);
*outFiles = files;
return fileNum;
}
/* Play next file in the same folder as the currently-playing file. Returns 0 on success, -1 on failure or no-next. */
static int playNextFromPath(struct playbackInfo_t *playbackInfo)
{
if(playbackInfo == NULL || playbackInfo->file[0] == '\0')
return -1;
char fullcpy[PATH_MAX];
strncpy(fullcpy, playbackInfo->file, sizeof(fullcpy));
fullcpy[sizeof(fullcpy)-1] = '\0';
/* find last slash */
char *slash = strrchr(fullcpy, '/');
char dirpath[PATH_MAX];
char *basename = NULL;
if(slash == NULL)
{
/* No directory component; assume current dir */
if(getcwd(dirpath, sizeof(dirpath)) == NULL)
return -1;
basename = fullcpy;
}
else
{
size_t dirlen = slash - fullcpy;
if(dirlen >= sizeof(dirpath))
return -1;
memcpy(dirpath, fullcpy, dirlen);
dirpath[dirlen] = '\0';
basename = slash + 1;
}
char **files = NULL;
int count = buildMusicFileListInDir(dirpath, &files);
if(count <= 0)
return -1;
int index = -1;
for(int i = 0; i < count; i++)
{
if(strcmp(files[i], basename) == 0)
{
index = i;
break;
}
}
int result = -1;
if(index >= 0 && index + 1 < count)
{
char nextpath[PATH_MAX];
snprintf(nextpath, sizeof(nextpath), "%s/%s", dirpath, files[index+1]);
result = changeFile(nextpath, playbackInfo);
}
for(int i = 0; i < count; i++)
free(files[i]);
free(files);
return result;
}
/**
* Store the list of files and folders in current director to an array.
* Store the list of files and folders in current directory to an array.
*/
static int getDir(struct dirList_t* dirList)
{
@@ -162,7 +290,7 @@ static int getDir(struct dirList_t* dirList)
free(dirList->currentDir);
if((dirList->currentDir = strdup(wd)) == NULL)
puts("Failure");
addLogMessage("Memory allocation failure");
if((dp = opendir(wd)) == NULL)
goto out;
@@ -175,7 +303,7 @@ static int getDir(struct dirList_t* dirList)
dirList->directories = realloc(dirList->directories, (dirNum + 1) * sizeof(char*));
if((dirList->directories[dirNum] = strdup(ep->d_name)) == NULL)
puts("Failure");
addLogMessage("Memory allocation failure");
dirNum++;
continue;
@@ -185,7 +313,7 @@ static int getDir(struct dirList_t* dirList)
dirList->files = realloc(dirList->files, (fileNum + 1) * sizeof(char*));
if((dirList->files[fileNum] = strdup(ep->d_name)) == NULL)
puts("Failure");
addLogMessage("Memory allocation failure");
fileNum++;
}
@@ -205,59 +333,53 @@ out:
}
/**
* List current directory.
*
* \param from First entry in directory to list.
* \param max Maximum number of entries to list. Must be > 0.
* \param select File to show as selected. Must be > 0.
* \return Number of entries listed or negative on error.
* Build file list for GUI display.
* Creates a combined list of directories and files for rendering.
*/
static int listDir(int from, int max, int select, struct dirList_t dirList)
static void buildFileListForGUI(struct dirList_t dirList, const char*** outList, int* outCount, int from)
{
int fileNum = 0;
int listed = 0;
printf("\033[0;0H");
printf("Dir: %.33s\n", dirList.currentDir);
static const char* combinedList[512];
static char entryBuffer[512][256];
int index = 0;
/* Add parent directory option */
if(from == 0)
{
printf("\33[2K%c../\n", select == 0 ? '>' : ' ');
listed++;
max--;
snprintf(entryBuffer[index], sizeof(entryBuffer[index]), "../");
combinedList[index] = entryBuffer[index];
index++;
}
while(dirList.fileNum + dirList.dirNum > fileNum)
/* Add all directories */
for(int i = 0; i < dirList.dirNum && index < 512; i++)
{
fileNum++;
if(fileNum <= from)
continue;
listed++;
if(dirList.dirNum >= fileNum)
{
printf("\33[2K%c\x1b[34;1m%.37s/\x1b[0m\n",
select == fileNum ? '>' : ' ',
dirList.directories[fileNum - 1]);
}
/* fileNum must be referring to a file instead of a directory. */
if(dirList.dirNum < fileNum)
{
printf("\33[2K%c%.37s\n",
select == fileNum ? '>' : ' ',
dirList.files[fileNum - dirList.dirNum - 1]);
}
if(fileNum == max + from)
break;
snprintf(entryBuffer[index], sizeof(entryBuffer[index]), "%s/", dirList.directories[i]);
combinedList[index] = entryBuffer[index];
index++;
}
/* Add all files */
for(int i = 0; i < dirList.fileNum && index < 512; i++)
{
snprintf(entryBuffer[index], sizeof(entryBuffer[index]), "%s", dirList.files[i]);
combinedList[index] = entryBuffer[index];
index++;
}
*outList = combinedList;
*outCount = index;
}
return listed;
/**
* Dummy function kept for compatibility (no longer used with GUI)
*/
static int listDir(int from __attribute__((unused)),
int max __attribute__((unused)),
int select __attribute__((unused)),
struct dirList_t dirList __attribute__((unused)))
{
/* This function is no longer used with GUI rendering */
return 0;
}
/**
@@ -288,60 +410,53 @@ err:
goto out;
}
int main(int argc, char **argv)
int main(int argc __attribute__((unused)), char **argv __attribute__((unused)))
{
PrintConsole topScreenLog, topScreenInfo, bottomScreen;
int fileMax;
int fileNum = 0;
int from = 0;
Thread watchdogThread;
Thread watchdogThread __attribute__((unused));
Handle playbackFailEvent;
struct watchdogInfo watchdogInfoIn;
struct errInfo_t errInfo;
struct playbackInfo_t playbackInfo = { 0 };
volatile int error = 0;
struct dirList_t dirList = { 0 };
struct metadata_t currentMetadata = { 0 };
/* ignore key release of L/R if L+R or L+down was pressed */
bool keyLComboPressed = false;
bool keyRComboPressed = false;
gfxInitDefault();
consoleInit(GFX_TOP, &topScreenLog);
consoleInit(GFX_TOP, &topScreenInfo);
consoleInit(GFX_BOTTOM, &bottomScreen);
/* Set console sizes. */
// (y-1) + (height) <= 30 (top screen only fits 30 lines)
consoleSetWindow(&topScreenLog, 1, 3, 50, 28);
consoleSetWindow(&topScreenInfo, 1, 1, 50, 2);
consoleSelect(&bottomScreen);
/* Initialize GUI system */
if(guiInit() != 0)
{
return -1;
}
svcCreateEvent(&playbackFailEvent, RESET_ONESHOT);
errInfo.error = &error;
errInfo.failEvent = &playbackFailEvent;
watchdogInfoIn.screen = &topScreenLog;
watchdogInfoIn.screen = NULL; /* No longer using console */
watchdogInfoIn.errInfo = &errInfo;
watchdogThread = threadCreate(playbackWatchdog,
&watchdogInfoIn, 4 * 1024, 0x20, -2, true);
playbackInfo.errInfo = &errInfo;
/* position of parent folder in parent directory */
int prevPosition[MAX_DIRECTORIES] = {0};
int prevFrom[MAX_DIRECTORIES] = {0};
int oldFileNum, oldFrom;
chdir(DEFAULT_DIR);
chdir("MUSIC");
/* TODO: Not actually possible to get less than 0 */
if(getDir(&dirList) < 0)
{
puts("Unable to obtain directory information");
goto err;
}
if(listDir(from, MAX_LIST, 0, dirList) < 0)
{
err_print("Unable to list directory.");
addLogMessage("Unable to obtain directory information");
goto err;
}
@@ -360,25 +475,22 @@ int main(int argc, char **argv)
u32 kUp;
static u64 mill = 0;
gfxFlushBuffers();
gspWaitForVBlank();
gfxSwapBuffers();
/* Begin GUI frame */
guiBeginFrame();
guiClearTopScreen();
guiClearBottomScreen();
hidScanInput();
kDown = hidKeysDown();
kHeld = hidKeysHeld();
kUp = hidKeysUp();
consoleSelect(&bottomScreen);
/* Exit ctrmus */
/* Exit mice */
if(kDown & KEY_START)
break;
#ifdef DEBUG
consoleSelect(&topScreenLog);
printf("\rNum: %d, Max: %d, from: %d ", fileNum, fileMax, from);
consoleSelect(&bottomScreen);
/* Debug info logged if needed */
#endif
if(kDown)
mill = osGetTime();
@@ -391,11 +503,7 @@ int main(int argc, char **argv)
if(isPlaying() == false)
continue;
consoleSelect(&topScreenLog);
if(togglePlayback() == true)
puts("Paused");
else
puts("Playing");
togglePlayback();
keyLComboPressed = true;
// distinguish between L+R and L+Up
@@ -405,33 +513,24 @@ int main(int argc, char **argv)
continue;
}
/* Show controls */
/* Show controls - no longer needed with GUI */
if(kDown & KEY_LEFT)
{
consoleSelect(&topScreenLog);
showControls();
keyLComboPressed = true;
continue;
}
}
// if R is pressed first
if ((kHeld & KEY_R) && (kDown & KEY_L))
{
if(isPlaying() == false)
continue;
// /* Stop */
// if(kDown & KEY_B)
// {
// stopPlayback();
togglePlayback();
// /* Clear playback information. */
// consoleSelect(&topScreenInfo);
// consoleClear();
// consoleSelect(&topScreenLog);
// //consoleClear();
// changeFile(NULL, &playbackInfo);
// /* If the playback thread is currently playing, it will now
// * stop and tell the Watchdog thread to display "Stopped".
// */
// continue;
// }
keyLComboPressed = true;
keyRComboPressed = true;
continue;
}
if((kDown & KEY_UP ||
@@ -443,9 +542,6 @@ int main(int argc, char **argv)
// one line taken up by cwd, other by ../
if(fileMax - fileNum > MAX_LIST-2 && from != 0)
from--;
if(listDir(from, MAX_LIST, fileNum, dirList) < 0)
err_print("Unable to list directory.");
}
if((kDown & KEY_DOWN ||
@@ -457,9 +553,6 @@ int main(int argc, char **argv)
if(fileNum >= MAX_LIST && fileMax - fileNum >= 0 &&
from < fileMax - MAX_LIST)
from++;
if(listDir(from, MAX_LIST, fileNum, dirList) < 0)
err_print("Unable to list directory.");
}
if((kDown & KEY_LEFT ||
@@ -477,12 +570,9 @@ int main(int argc, char **argv)
if(fileMax - fileNum > MAX_LIST-2 && from != 0)
{
from -= skip;
if(from < 0)
if(from < 0)
from = 0;
}
if(listDir(from, MAX_LIST, fileNum, dirList) < 0)
err_print("Unable to list directory.");
}
if((kDown & KEY_RIGHT ||
@@ -503,9 +593,6 @@ int main(int argc, char **argv)
if(from > fileMax - MAX_LIST)
from = fileMax - MAX_LIST;
}
if(listDir(from, MAX_LIST, fileNum, dirList) < 0)
err_print("Unable to list directory.");
}
/*
@@ -516,14 +603,17 @@ int main(int argc, char **argv)
((kDown & KEY_A) && (from == 0 && fileNum == 0)))
{
chdir("..");
consoleClear();
fileMax = getDir(&dirList);
fileNum = 0;
from = 0;
if(listDir(from, MAX_LIST, fileNum, dirList) < 0)
err_print("Unable to list directory.");
fileNum = prevPosition[0];
from = prevFrom[0];
for (int i=0; i<MAX_DIRECTORIES-1; i++) {
prevPosition[i] = prevPosition[i+1];
prevFrom[i] = prevFrom[i+1];
}
/* default to first entry */
prevPosition[MAX_DIRECTORIES-1] = 0;
prevFrom[MAX_DIRECTORIES-1] = 0;
continue;
}
@@ -533,22 +623,30 @@ int main(int argc, char **argv)
if(dirList.dirNum >= fileNum)
{
chdir(dirList.directories[fileNum - 1]);
consoleClear();
fileMax = getDir(&dirList);
oldFileNum = fileNum;
oldFrom = from;
fileNum = 0;
from = 0;
if(listDir(from, MAX_LIST, fileNum, dirList) < 0)
err_print("Unable to list directory.");
/* save old position in folder */
for (int i=MAX_DIRECTORIES-1; i>0; i--) {
prevPosition[i] = prevPosition[i-1];
prevFrom[i] = prevFrom[i-1];
}
prevPosition[0] = oldFileNum;
prevFrom[0] = oldFrom;
continue;
}
if(dirList.dirNum < fileNum)
{
consoleSelect(&topScreenInfo);
consoleClear();
consoleSelect(&topScreenLog);
//consoleClear();
/* Extract and display metadata */
char fullPath[512];
snprintf(fullPath, sizeof(fullPath), "%s", dirList.files[fileNum - dirList.dirNum - 1]);
extractMetadata(fullPath, &currentMetadata);
guiDisplayMetadata(&currentMetadata, dirList.files[fileNum - dirList.dirNum - 1]);
changeFile(dirList.files[fileNum - dirList.dirNum - 1], &playbackInfo);
error = 0;
@@ -565,19 +663,21 @@ int main(int argc, char **argv)
keyRComboPressed = false;
}
bool goToNextFile = (kDown & KEY_ZR) || keyRActivation;
if (goToNextFile && fileNum < fileMax) {
// check that next entry is a file
if (goToNextFile && fileNum < fileMax && dirList.dirNum < fileNum+1) {
fileNum += 1;
if(fileNum >= MAX_LIST && fileMax - fileNum >= 0 &&
from < fileMax - MAX_LIST)
from++;
consoleSelect(&topScreenInfo);
consoleClear();
consoleSelect(&topScreenLog);
//consoleClear();
/* Extract and display metadata */
char fullPath[512];
snprintf(fullPath, sizeof(fullPath), "%s", dirList.files[fileNum - dirList.dirNum - 1]);
extractMetadata(fullPath, &currentMetadata);
guiDisplayMetadata(&currentMetadata, dirList.files[fileNum - dirList.dirNum - 1]);
changeFile(dirList.files[fileNum - dirList.dirNum - 1], &playbackInfo);
error = 0;
consoleSelect(&bottomScreen);
if(listDir(from, MAX_LIST, fileNum, dirList) < 0) err_print("Unable to list directory.");
continue;
}
// ignore L release if key combo with L used
@@ -588,94 +688,111 @@ int main(int argc, char **argv)
}
keyLComboPressed = false;
}
bool goToPrevFile = (kDown & KEY_ZR) || keyLActivation;
// don't go to ../
if (goToPrevFile && fileNum > 1) {
bool goToPrevFile = (kDown & KEY_ZL) || keyLActivation;
// don't go to ../ and check that previous entry is a file
if (goToPrevFile && fileNum > 1 && dirList.dirNum < fileNum-1) {
fileNum -= 1;
if(fileMax - fileNum > MAX_LIST-2 && from != 0)
from--;
consoleSelect(&topScreenInfo);
consoleClear();
consoleSelect(&topScreenLog);
//consoleClear();
/* Extract and display metadata */
char fullPath[512];
snprintf(fullPath, sizeof(fullPath), "%s", dirList.files[fileNum - dirList.dirNum - 1]);
extractMetadata(fullPath, &currentMetadata);
guiDisplayMetadata(&currentMetadata, dirList.files[fileNum - dirList.dirNum - 1]);
changeFile(dirList.files[fileNum - dirList.dirNum - 1], &playbackInfo);
error = 0;
consoleSelect(&bottomScreen);
if(listDir(from, MAX_LIST, fileNum, dirList) < 0) err_print("Unable to list directory.");
continue;
}
// play next song automatically
if (error == -1) {
if (fileNum >= fileMax) {
// don't try to play folders
if (fileNum >= fileMax || dirList.dirNum >= fileNum) {
error = 0;
continue;
}
fileNum += 1;
consoleSelect(&topScreenInfo);
consoleClear();
consoleSelect(&topScreenLog);
//consoleClear();
/* Extract and display metadata */
char fullPath[512];
snprintf(fullPath, sizeof(fullPath), "%s", dirList.files[fileNum - dirList.dirNum - 1]);
extractMetadata(fullPath, &currentMetadata);
guiDisplayMetadata(&currentMetadata, dirList.files[fileNum - dirList.dirNum - 1]);
changeFile(dirList.files[fileNum - dirList.dirNum - 1], &playbackInfo);
error = 0;
consoleSelect(&bottomScreen);
if(listDir(from, MAX_LIST, fileNum, dirList) < 0) err_print("Unable to list directory.");
continue;
}
/* After 1000ms, update playback time. */
while(osGetTime() - mill > 1000)
/* Render GUI elements */
const char** fileList;
int fileListCount;
buildFileListForGUI(dirList, &fileList, &fileListCount, 0);
/* Display metadata if we have any */
if(currentMetadata.title[0] || currentMetadata.artist[0] || currentMetadata.album[0])
{
consoleSelect(&topScreenInfo);
/* Reset cursor position and print status. */
printf("\033[0;0H");
/* Avoid divide by zero. */
if(playbackInfo.samples_per_second == 0)
break;
{
unsigned hr, min, sec;
size_t seconds_played;
seconds_played = playbackInfo.samples_played / playbackInfo.samples_per_second;
hr = (seconds_played/3600);
min = (seconds_played - (3600*hr))/60;
sec = (seconds_played -(3600*hr)-(min*60));
printf("%02d:%02d:%02d", hr, min, sec);
}
if(playbackInfo.samples_total != 0)
{
unsigned hr, min, sec;
size_t seconds_total;
seconds_total = playbackInfo.samples_total / playbackInfo.samples_per_second;
hr = (seconds_total/3600);
min = (seconds_total - (3600*hr))/60;
sec = (seconds_total -(3600*hr)-(min*60));
printf(" %02d:%02d:%02d", hr, min, sec);
}
break;
const char* currentFile = (fileNum > 0 && fileNum <= dirList.dirNum + dirList.fileNum) ?
(fileNum > dirList.dirNum ? dirList.files[fileNum - dirList.dirNum - 1] : "..") : "";
guiDisplayMetadata(&currentMetadata, currentFile);
}
/* Calculate scroll position to keep selection visible (13 lines visible with path) */
int scroll = from;
if(fileNum < scroll)
scroll = fileNum;
else if(fileNum >= scroll + 13)
scroll = fileNum - 12;
/* Display current directory path */
char currentPath[256];
if(getcwd(currentPath, sizeof(currentPath)))
{
guiDisplayCurrentPath(currentPath);
}
/* Display file list on bottom screen */
guiDisplayFileList(fileList, fileListCount, fileNum, scroll);
/* Display logs on top screen */
guiDisplayLog((const char**)logMessages, logMessageCount, logScroll);
/* Display playback status and progress bar */
if(playbackInfo.samples_per_second > 0)
{
float position = (float)playbackInfo.samples_played / playbackInfo.samples_per_second;
float duration = (float)playbackInfo.samples_total / playbackInfo.samples_per_second;
guiDisplayProgressBar(position, duration);
guiDisplayPlaybackStatus(isPlaying(), isPaused(), position, duration);
}
/* Display version */
guiDisplayVersion(MICE_VERSION);
/* End GUI frame */
guiEndFrame();
}
out:
puts("Exiting...");
addLogMessage("Exiting...");
runThreads = false;
clearMetadata(&currentMetadata);
svcSignalEvent(playbackFailEvent);
changeFile(NULL, &playbackInfo);
gfxExit();
/* Cleanup GUI */
guiExit();
/* Cleanup log messages */
for(int i = 0; i < logMessageCount; i++)
free(logMessages[i]);
return 0;
err:
puts("A fatal error occurred. Press START to exit.");
addLogMessage("A fatal error occurred. Press START to exit.");
while(true)
{
@@ -690,4 +807,3 @@ err:
goto out;
}

632
source/metadata.c Normal file
View File

@@ -0,0 +1,632 @@
#include <3ds.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <ctype.h>
#include "metadata.h"
#include "file.h"
#include "all.h"
#include "gui.h"
/* Internal helper functions */
static int extractId3v2Metadata(FILE* fp, struct metadata_t* metadata);
static int extractId3v1Metadata(FILE* fp, struct metadata_t* metadata);
static int extractVorbisComment(FILE* fp, struct metadata_t* metadata);
static int extractFlacMetadata(FILE* fp, struct metadata_t* metadata);
static void trimWhitespace(char* str);
static void parseFilename(const char* file, struct metadata_t* metadata);
/**
* Extract metadata from an audio file
*/
int extractMetadata(const char* file, struct metadata_t* metadata)
{
FILE* fp;
enum file_types fileType;
if(!file || !metadata)
return -1;
/* Clear metadata structure */
clearMetadata(metadata);
fileType = getFileType(file);
fp = fopen(file, "rb");
if(!fp)
return -1;
switch(fileType)
{
case FILE_TYPE_MP3:
/* Try ID3v2 first, then ID3v1 */
if(extractId3v2Metadata(fp, metadata) != 0)
extractId3v1Metadata(fp, metadata);
break;
case FILE_TYPE_VORBIS:
case FILE_TYPE_OPUS:
extractVorbisComment(fp, metadata);
break;
case FILE_TYPE_FLAC:
extractFlacMetadata(fp, metadata);
break;
case FILE_TYPE_WAV:
case FILE_TYPE_SID:
default:
/* No metadata support for these formats yet */
break;
}
fclose(fp);
/* If no metadata was found, try to parse filename */
if(!metadata->title[0] && !metadata->artist[0] && !metadata->album[0])
{
parseFilename(file, metadata);
}
return 0;
}
/**
* Clear metadata structure and free any allocated memory
*/
void clearMetadata(struct metadata_t* metadata)
{
if(!metadata)
return;
memset(metadata->title, 0, METADATA_TITLE_MAX);
memset(metadata->artist, 0, METADATA_ARTIST_MAX);
memset(metadata->album, 0, METADATA_ALBUM_MAX);
}
/**
* Display metadata on the top screen
* (Now uses GUI rendering - this is a wrapper for compatibility)
*/
void displayMetadata(struct metadata_t* metadata, const char* filename)
{
guiDisplayMetadata(metadata, filename);
}
/**
* Extract ID3v2 metadata from MP3 file
*/
static int extractId3v2Metadata(FILE* fp, struct metadata_t* metadata)
{
char header[10];
uint32_t tagSize;
uint8_t major, minor, flags;
if(!fp || !metadata)
return -1;
fseek(fp, 0, SEEK_SET);
if(fread(header, 1, 10, fp) != 10)
return -1;
/* Check ID3v2 header "ID3" */
if(memcmp(header, "ID3", 3) != 0)
return -1;
major = header[3];
minor = header[4];
flags = header[5];
/* Calculate tag size (synchsafe integer) */
tagSize = ((header[6] & 0x7F) << 21) |
((header[7] & 0x7F) << 14) |
((header[8] & 0x7F) << 7) |
(header[9] & 0x7F);
/* Skip extended header if present */
if(flags & 0x40)
{
uint32_t extSize;
if(fread(&extSize, 4, 1, fp) != 1)
return -1;
/* Convert from big-endian */
extSize = __builtin_bswap32(extSize);
fseek(fp, extSize - 4, SEEK_CUR);
}
long tagStart = ftell(fp);
long tagEnd = tagStart + tagSize;
/* Parse frames */
while(ftell(fp) < tagEnd - 10)
{
char frameId[5] = {0};
uint32_t frameSize;
uint16_t frameFlags;
if(fread(frameId, 1, 4, fp) != 4)
break;
/* Check for padding */
if(frameId[0] == 0)
break;
if(fread(&frameSize, 4, 1, fp) != 1)
break;
frameSize = __builtin_bswap32(frameSize);
if(fread(&frameFlags, 2, 1, fp) != 1)
break;
/* Handle text frames */
if(strncmp(frameId, "TIT2", 4) == 0 || /* Title */
strncmp(frameId, "TPE1", 4) == 0 || /* Artist */
strncmp(frameId, "TALB", 4) == 0) /* Album */
{
char* frameData = malloc(frameSize + 1);
if(!frameData)
{
fseek(fp, frameSize, SEEK_CUR);
continue;
}
if(fread(frameData, 1, frameSize, fp) == frameSize)
{
frameData[frameSize] = 0;
/* Text encoding byte:
* 0x00 = ISO-8859-1
* 0x01 = UTF-16 with BOM
* 0x02 = UTF-16BE without BOM
* 0x03 = UTF-8
*/
uint8_t encoding = frameData[0];
char* text = frameData + 1;
int textLen = frameSize - 1;
/* Skip UTF-16 BOM if present */
if(encoding == 0x01 && textLen >= 2)
{
if((uint8_t)text[0] == 0xFF && (uint8_t)text[1] == 0xFE)
{
text += 2;
textLen -= 2;
}
else if((uint8_t)text[0] == 0xFE && (uint8_t)text[1] == 0xFF)
{
text += 2;
textLen -= 2;
}
}
/* Copy to appropriate field */
char* dest = NULL;
size_t maxLen = 0;
if(strncmp(frameId, "TIT2", 4) == 0)
{
dest = metadata->title;
maxLen = METADATA_TITLE_MAX - 1;
}
else if(strncmp(frameId, "TPE1", 4) == 0)
{
dest = metadata->artist;
maxLen = METADATA_ARTIST_MAX - 1;
}
else if(strncmp(frameId, "TALB", 4) == 0)
{
dest = metadata->album;
maxLen = METADATA_ALBUM_MAX - 1;
}
if(dest)
{
/* Handle different encodings */
if(encoding == 0x01 || encoding == 0x02) /* UTF-16 */
{
/* Convert UTF-16 to ASCII (simplified - just take every other byte) */
int outPos = 0;
for(int i = 0; i < textLen && outPos < (int)maxLen; i += 2)
{
if(text[i] >= 0x20 && text[i] < 0x7F)
dest[outPos++] = text[i];
else if(text[i] == 0 && text[i+1] == 0)
break;
}
dest[outPos] = 0;
}
else /* ISO-8859-1 or UTF-8 */
{
/* Copy as-is, filtering out non-ASCII characters */
int outPos = 0;
for(int i = 0; i < textLen && outPos < (int)maxLen; i++)
{
if((uint8_t)text[i] >= 0x20 && (uint8_t)text[i] < 0x7F)
dest[outPos++] = text[i];
else if(text[i] == 0)
break;
}
dest[outPos] = 0;
}
trimWhitespace(dest);
}
} free(frameData);
}
else if(strncmp(frameId, "APIC", 4) == 0) /* Attached Picture */
{
/* Skip album art data */
fseek(fp, frameSize, SEEK_CUR);
}
else
{
/* Skip unknown frame */
fseek(fp, frameSize, SEEK_CUR);
}
}
return 0;
}
/**
* Extract ID3v1 metadata from MP3 file
*/
static int extractId3v1Metadata(FILE* fp, struct metadata_t* metadata)
{
char tag[128];
if(!fp || !metadata)
return -1;
/* Seek to last 128 bytes */
fseek(fp, -128, SEEK_END);
if(fread(tag, 1, 128, fp) != 128)
return -1;
/* Check for "TAG" signature */
if(memcmp(tag, "TAG", 3) != 0)
return -1;
/* Extract fields */
if(!metadata->title[0])
{
strncpy(metadata->title, tag + 3, 30);
metadata->title[30] = 0;
trimWhitespace(metadata->title);
}
if(!metadata->artist[0])
{
strncpy(metadata->artist, tag + 33, 30);
metadata->artist[30] = 0;
trimWhitespace(metadata->artist);
}
if(!metadata->album[0])
{
strncpy(metadata->album, tag + 63, 30);
metadata->album[30] = 0;
trimWhitespace(metadata->album);
}
return 0;
}
/**
* Extract Vorbis comment metadata (for OGG Vorbis/Opus)
*/
static int extractVorbisComment(FILE* fp, struct metadata_t* metadata)
{
/* This is a simplified implementation for Vorbis comments.
* A full implementation would require parsing the OGG container structure.
* For now, we'll try to find common Vorbis comment fields by searching for them.
*/
char buffer[4096];
size_t bytesRead;
if(!fp || !metadata)
return -1;
/* Reset to beginning */
fseek(fp, 0, SEEK_SET);
/* Read and search for Vorbis comment patterns */
while((bytesRead = fread(buffer, 1, sizeof(buffer) - 1, fp)) > 0)
{
buffer[bytesRead] = '\0';
/* Look for TITLE= */
char* title = strstr(buffer, "TITLE=");
if(title && !metadata->title[0])
{
title += 6; /* Skip "TITLE=" */
char* end = strchr(title, '\1'); /* Vorbis comments end with \1 or \0 */
if(!end) end = strchr(title, '\0');
if(end)
{
size_t len = end - title;
if(len >= METADATA_TITLE_MAX) len = METADATA_TITLE_MAX - 1;
strncpy(metadata->title, title, len);
metadata->title[len] = '\0';
trimWhitespace(metadata->title);
}
}
/* Look for ARTIST= or ALBUMARTIST= */
char* artist = strstr(buffer, "ARTIST=");
if(!artist)
artist = strstr(buffer, "ALBUMARTIST=");
if(artist && !metadata->artist[0])
{
if(strncmp(artist, "ALBUMARTIST=", 12) == 0)
artist += 12; /* Skip "ALBUMARTIST=" */
else
artist += 7; /* Skip "ARTIST=" */
char* end = strchr(artist, '\1');
if(!end) end = strchr(artist, '\0');
if(end)
{
size_t len = end - artist;
if(len >= METADATA_ARTIST_MAX) len = METADATA_ARTIST_MAX - 1;
strncpy(metadata->artist, artist, len);
metadata->artist[len] = '\0';
trimWhitespace(metadata->artist);
}
}
/* Look for ALBUM= */
char* album = strstr(buffer, "ALBUM=");
if(album && !metadata->album[0])
{
album += 6; /* Skip "ALBUM=" */
char* end = strchr(album, '\1');
if(!end) end = strchr(album, '\0');
if(end)
{
size_t len = end - album;
if(len >= METADATA_ALBUM_MAX) len = METADATA_ALBUM_MAX - 1;
strncpy(metadata->album, album, len);
metadata->album[len] = '\0';
trimWhitespace(metadata->album);
}
}
}
return 0;
}
/**
* Extract FLAC metadata
*/
static int extractFlacMetadata(FILE* fp, struct metadata_t* metadata)
{
char header[4];
if(!fp || !metadata)
return -1;
/* Check for FLAC signature */
fseek(fp, 0, SEEK_SET);
if(fread(header, 1, 4, fp) != 4 || memcmp(header, "fLaC", 4) != 0)
return -1;
/* FLAC metadata blocks follow the signature */
while(1)
{
uint8_t blockHeader[4];
uint32_t blockSize;
bool isLast;
uint8_t blockType;
if(fread(blockHeader, 1, 4, fp) != 4)
break;
isLast = (blockHeader[0] & 0x80) != 0;
blockType = blockHeader[0] & 0x7F;
blockSize = (blockHeader[1] << 16) | (blockHeader[2] << 8) | blockHeader[3];
if(blockType == 6) /* PICTURE */
{
/* Skip picture block */
fseek(fp, blockSize, SEEK_CUR);
}
else if(blockType == 4) /* VORBIS_COMMENT */
{
/* FLAC uses Vorbis comments for metadata */
/* This is a simplified implementation */
char* commentData = malloc(blockSize);
if(commentData)
{
if(fread(commentData, 1, blockSize, fp) == blockSize)
{
/* Skip vendor string length (4 bytes) and vendor string */
if(blockSize > 4)
{
uint32_t vendorLength = commentData[0] | (commentData[1] << 8) |
(commentData[2] << 16) | (commentData[3] << 24);
if(vendorLength + 8 < blockSize) /* 4 bytes vendor len + vendor + 4 bytes comment count */
{
uint32_t commentCount = commentData[4 + vendorLength] |
(commentData[5 + vendorLength] << 8) |
(commentData[6 + vendorLength] << 16) |
(commentData[7 + vendorLength] << 24);
uint32_t offset = 8 + vendorLength;
for(uint32_t i = 0; i < commentCount && offset < blockSize; i++)
{
if(offset + 4 > blockSize) break;
uint32_t commentLength = commentData[offset] | (commentData[offset + 1] << 8) |
(commentData[offset + 2] << 16) | (commentData[offset + 3] << 24);
offset += 4;
if(offset + commentLength > blockSize) break;
/* Parse comment */
char* comment = commentData + offset;
if(commentLength > 0 && commentLength < 1000) /* Reasonable size check */
{
char tempComment[1001];
strncpy(tempComment, comment, commentLength);
tempComment[commentLength] = '\0';
if(strncmp(tempComment, "TITLE=", 6) == 0 && !metadata->title[0])
{
strncpy(metadata->title, tempComment + 6, METADATA_TITLE_MAX - 1);
metadata->title[METADATA_TITLE_MAX - 1] = '\0';
trimWhitespace(metadata->title);
}
else if(strncmp(tempComment, "ARTIST=", 7) == 0 && !metadata->artist[0])
{
strncpy(metadata->artist, tempComment + 7, METADATA_ARTIST_MAX - 1);
metadata->artist[METADATA_ARTIST_MAX - 1] = '\0';
trimWhitespace(metadata->artist);
}
else if(strncmp(tempComment, "ALBUMARTIST=", 12) == 0 && !metadata->artist[0])
{
strncpy(metadata->artist, tempComment + 12, METADATA_ARTIST_MAX - 1);
metadata->artist[METADATA_ARTIST_MAX - 1] = '\0';
trimWhitespace(metadata->artist);
}
else if(strncmp(tempComment, "ALBUM=", 6) == 0 && !metadata->album[0])
{
strncpy(metadata->album, tempComment + 6, METADATA_ALBUM_MAX - 1);
metadata->album[METADATA_ALBUM_MAX - 1] = '\0';
trimWhitespace(metadata->album);
}
}
offset += commentLength;
}
}
}
}
free(commentData);
}
}
else
{
/* Skip other block types */
fseek(fp, blockSize, SEEK_CUR);
}
if(isLast)
break;
}
return 0;
}
/**
* Trim leading and trailing whitespace from string
*/
static void trimWhitespace(char* str)
{
char* start = str;
char* end;
if(!str || *str == 0)
return;
/* Trim leading space */
while(isspace((unsigned char)*start))
start++;
if(*start == 0) /* All spaces? */
{
*str = '\0';
return;
}
/* Trim trailing space */
end = start + strlen(start) - 1;
while(end > start && isspace((unsigned char)*end))
end--;
/* Write new null terminator */
end[1] = '\0';
/* Move trimmed string to beginning if needed */
if(start != str)
{
memmove(str, start, strlen(start) + 1);
}
}
/**
* Parse filename for metadata when tags are not available
* Handles formats like "Artist - Album - Track - Title.ext"
*/
static void parseFilename(const char* file, struct metadata_t* metadata)
{
if(!file || !metadata)
return;
/* Extract just the filename without path */
const char* basename = strrchr(file, '/');
if(!basename)
basename = file;
else
basename++; /* Skip the '/' */
/* Remove file extension */
char filename[256];
strncpy(filename, basename, sizeof(filename) - 1);
filename[sizeof(filename) - 1] = '\0';
char* dot = strrchr(filename, '.');
if(dot) *dot = '\0';
/* Look for pattern: "Artist - Album - Track - Title" */
char* parts[4] = {NULL, NULL, NULL, NULL};
int partCount = 0;
char* token = strtok(filename, " - ");
while(token && partCount < 4)
{
parts[partCount] = token;
partCount++;
token = strtok(NULL, " - ");
}
/* Assign parts based on count */
if(partCount >= 4)
{
/* Artist - Album - Track - Title */
strncpy(metadata->artist, parts[0], METADATA_ARTIST_MAX - 1);
strncpy(metadata->album, parts[1], METADATA_ALBUM_MAX - 1);
strncpy(metadata->title, parts[3], METADATA_TITLE_MAX - 1);
}
else if(partCount == 3)
{
/* Artist - Album - Title */
strncpy(metadata->artist, parts[0], METADATA_ARTIST_MAX - 1);
strncpy(metadata->album, parts[1], METADATA_ALBUM_MAX - 1);
strncpy(metadata->title, parts[2], METADATA_TITLE_MAX - 1);
}
else if(partCount == 2)
{
/* Artist - Title */
strncpy(metadata->artist, parts[0], METADATA_ARTIST_MAX - 1);
strncpy(metadata->title, parts[1], METADATA_TITLE_MAX - 1);
}
else if(partCount == 1)
{
/* Just Title */
strncpy(metadata->title, parts[0], METADATA_TITLE_MAX - 1);
}
/* Ensure null termination */
metadata->artist[METADATA_ARTIST_MAX - 1] = '\0';
metadata->album[METADATA_ALBUM_MAX - 1] = '\0';
metadata->title[METADATA_TITLE_MAX - 1] = '\0';
/* Trim whitespace */
trimWhitespace(metadata->artist);
trimWhitespace(metadata->album);
trimWhitespace(metadata->title);
}

View File

@@ -7,6 +7,7 @@
#include "error.h"
#include "file.h"
#include "flac.h"
#include "m4a.h"
#include "mp3.h"
#include "opus.h"
#include "playback.h"
@@ -44,6 +45,16 @@ bool isPlaying(void)
return !stop;
}
/**
* Returns whether playback is currently paused.
*/
bool isPaused(void)
{
if(stop)
return false;
return ndspChnIsPaused(CHANNEL);
}
/**
* Should only be called from a new thread only, and have only one playback
* thread at time. This function has not been written for more than one
@@ -88,15 +99,18 @@ void playFile(void* infoIn)
setVorbis(&decoder);
break;
case FILE_TYPE_SID:
setSid(&decoder);
break;
case FILE_TYPE_SID:
setSid(&decoder);
break;
case FILE_TYPE_M4A:
case FILE_TYPE_AAC:
setM4a(&decoder);
break;
default:
goto err;
}
if(ndspInit() < 0)
default:
goto err;
} if(ndspInit() < 0)
{
errno = NDSP_INIT_FAIL;
goto err;

View File

@@ -18,7 +18,7 @@ static int channels = SIDEMU_STEREO;
static int selectedSong = 0;
static size_t buffSize = 0.5*frequency*channels; // 0.5 seconds
// don't change anything below - only 16 bit/sigend PCM is supported my ctrmus
// don't change anything below - only 16 bit/sigend PCM is supported my mice
static int sampleFormat = SIDEMU_SIGNED_PCM;
static int bitsPerSample = SIDEMU_16BIT;

View File

@@ -14,7 +14,7 @@
#include "wav.h"
/**
* Test the various decoder modules in ctrmus.
* Test the various decoder modules in mice.
*/
int main(int argc, char *argv[])
{

44
unistore/mice.unistore Normal file
View File

@@ -0,0 +1,44 @@
{
"storeInfo": {
"title": "mice unistore",
"author": "sillyangel",
"description": "A music player for the Nintendo 3DS",
"url": "https://git.sillyangel.dev/angel/mice-3ds/raw/branch/master/unistore/mice.unistore",
"file": "mice.unistore",
"sheetURL": "https://git.sillyangel.dev/angel/mice-3ds/raw/branch/master/unistore/spritesheet/sheet.t3x",
"sheet": "sheet.t3x",
"version": 4,
"revision": 0
},
"storeContent": [
{
"info": {
"title": "mice",
"author": "sillyangel",
"description": "A music player for the Nintendo 3DS. Plays PCM WAV, AIFF, FLAC, Opus, Vorbis and MP3 files.",
"category": ["utility", "music"],
"console": ["3DS"],
"icon_index": 0,
"sheet_index": 0,
"last_updated": "2025-12-07 at 00:00 (UTC)",
"license": "gpl-3.0",
"version": "dev63"
}
"mice.cia": [
{
"type": "downloadFile",
"file": "https://git.sillyangel.dev/angel/mice-3ds/releases/download/latest/mice.cia",
"output": "sdmc:/mice.cia"
},
{
"type": "installCia",
"file": "/mice.cia"
},
{
"type": "deleteFile",
"file": "sdmc:/mice.cia"
}
],
}
]
}

BIN
unistore/spritesheet/0.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

View File

@@ -0,0 +1,3 @@
--atlas -f rgba -z auto
0.png

Binary file not shown.