diff --git a/src/components/MusicPlayer.tsx b/src/components/MusicPlayer.tsx index 73dc4bf..dfde91d 100644 --- a/src/components/MusicPlayer.tsx +++ b/src/components/MusicPlayer.tsx @@ -1,6 +1,19 @@ -import { useState } from "react"; import { useTerminal } from "~/context/TerminalContext"; -import { Text } from "./Text"; +import { TerminalCanvas } from "~/utils/terminal/canvas"; +import { TerminalBoxElement } from "~/utils/terminal/elements/box"; + +const theme = { + black: "#45475a", + red: "#f38ba8", + green: "#a6e3a1", + yellow: "#f9e2af", + blue: "#89bafa", + magenta: "#f5c2e7", + cyan: "#94e2d5", + white: "#bac2de", + grey: "#585B70", + lightGrey: "#a6adc8", +}; const formatDurationMSS = (duration: number) => { const minutes = Math.floor(duration / 60); @@ -16,91 +29,50 @@ export const MusicPlayer = (props: { duration: number; played: number; }) => { - const terminal = useTerminal(); + props; + formatDurationMSS; - const [isPlaying, setIsPlaying] = useState(false); - setIsPlaying; + const { cols } = useTerminal(); + const canvas = new TerminalCanvas(cols, 5); - const innerWidth = terminal.cols - 2; - - const timeString = `${formatDurationMSS(props.played)}/${formatDurationMSS( - props.duration, - )}`; - const barSize = Math.max( - 1, - Math.floor((props.played / props.duration) * innerWidth), - ); - - const beforeText_pos = innerWidth / 2 - timeString.length / 2; - const beforeText_filled = Math.min(barSize, beforeText_pos); - const beforeText_empty = beforeText_pos - beforeText_filled; - - const afterText_pos = innerWidth / 2 + timeString.length / 2; - const afterText_filled = Math.max( + canvas.writeElement( + new TerminalBoxElement(canvas.width, canvas.height), + 0, 0, - Math.min(barSize, innerWidth - timeString.length) - afterText_pos, ); - const afterText_empty = - innerWidth - afterText_pos - afterText_filled + 1 - (innerWidth % 2); - return ( -

- {/* Top bar */} - <> - - Playback - {"─".repeat(Math.max(0, innerWidth - 8))}┐ -
- + canvas.write(1, 0, "Playback".substring(0, Math.min(8, canvas.width - 2)), { + foreground: theme.magenta, + }); - <> - - - {isPlaying ? "\udb81\udc0a" : "\udb80\udfe4"} {props.title} -{" "} - {props.artist} - - - {" ".repeat( - Math.max( - 0, - innerWidth - props.title.length - props.artist.length - 5, - ), - )} - │ - -
- + const inner = new TerminalCanvas(canvas.width - 2, canvas.height - 2); + // Title and Artist + inner.write(2, 0, "Last Tango in Kyoto · Floating Bits", { + foreground: theme.cyan, + fontWeight: 800, + }); + inner.apply(0, 0, { + char: "\udb81\udc0a", + foreground: theme.cyan, + fontWeight: 800, + }); - <> - - - {props.album} - {" ".repeat(innerWidth - props.album.length)} - - -
- + // Album + inner.write(0, 1, "Last Tango in Kyoto", { foreground: theme.yellow }); - <> - - {" ".repeat(beforeText_filled)} - {" ".repeat(beforeText_empty)} - {timeString.split("").map((x, i) => ( - - {x} - - ))} - {" ".repeat(afterText_filled)} - {" ".repeat(afterText_empty)} - -
- + // Bar + const percentage = 45; + inner.write(0, 2, " ".repeat(inner.width), { + foreground: theme.green, + background: theme.black, + }); + inner.write(0, 2, " ".repeat((inner.width / 100) * percentage), { + foreground: theme.black, + background: theme.green, + }); + const time = "1:10/1:51"; + inner.write(inner.width / 2 - time.length / 2, 2, time); - └{"─".repeat(innerWidth)}┘ -

- ); + canvas.writeElement(inner, 1, 1); + return

{canvas.render()}

; }; diff --git a/src/components/Terminal.tsx b/src/components/Terminal.tsx index 71a05fb..7abc9ba 100644 --- a/src/components/Terminal.tsx +++ b/src/components/Terminal.tsx @@ -53,7 +53,7 @@ export const Terminal = (props: {
+ Math.min(Math.max(min, v), max); + +export const clamp01 = (v: number): number => clamp(v, 0, 1); + +export const floorAll = (...xs: Array): Array => + xs.map(Math.floor); diff --git a/src/utils/terminal/canvas.tsx b/src/utils/terminal/canvas.tsx new file mode 100644 index 0000000..180e0a7 --- /dev/null +++ b/src/utils/terminal/canvas.tsx @@ -0,0 +1,124 @@ +import { type ReactNode } from "react"; +import { floorAll } from "../math"; +import { type CellStyle, type Cell } from "./cell"; +import { type TerminalElement } from "./element"; + +export class TerminalCanvas implements TerminalElement { + public readonly data: Array>; + + constructor( + public readonly width: number, + public readonly height: number, + public readonly defaultStyle: CellStyle = {}, + ) { + [this.width, this.height] = floorAll(this.width, this.height); + + this.data = new Array(this.height).fill(0).map(() => + new Array(this.width).fill({ + char: " ", + ...defaultStyle, + }), + ); + } + + apply(x: number, y: number, cell: Partial): void { + [x, y] = floorAll(x, y); + + if (x < 0 || x >= this.width || y < 0 || y >= this.height) return; + + this.data[y][x] = { + ...this.data[y][x], + ...cell, + }; + } + + write(x: number, y: number, text: string, style: CellStyle = {}): void { + [x, y] = floorAll(x, y); + + for (let i = 0; i < text.length; i++) { + this.apply(x + i, y, { + char: text[i], + ...style, + }); + } + } + + writeFilter( + x: number, + y: number, + text: string, + filter: (cell: Cell) => Cell, + ): void { + [x, y] = floorAll(x, y); + + for (let i = 0; i < text.length; i++) { + this.apply(x + i, y, { + ...filter(this.data[y][x + i]), + char: text[i], + }); + } + } + + writeElement(canvas: TerminalElement, dx: number, dy: number): void { + [dx, dy] = floorAll(dx, dy); + + for (let y = 0; y < canvas.height; y++) { + for (let x = 0; x < canvas.width; x++) { + this.apply(dx + x, dy + y, canvas.data[y][x]); + } + } + } + + subCanvas( + x: number, + y: number, + width: number, + height: number, + ): TerminalCanvas { + [x, y, width, height] = floorAll(x, y, width, height); + + const canvas = new TerminalCanvas(width, height); + for (let cy = 0; cy < height; cy++) { + for (let cx = 0; cx < width; cx++) { + canvas.apply(cx, cy, this.data[y + cy][x + cx]); + } + } + + return canvas; + } + + render(): Array { + const nodes: Array = []; + + for (let y = 0; y < this.height; y++) { + for (let x = 0; x < this.width; x++) { + const cell = this.data[y][x]; + /* + const span = document.createElement("span"); + span.innerHTML = cell.char; + span.style.color = cell.foreground ?? "unset"; + span.style.background = cell.background ?? "unset"; + span.style.fontWeight = String(cell.fontWeight ?? "unset"); + + target.appendChild(span); + */ + nodes.push( + + {cell.char} + , + ); + } + + nodes.push(
); + } + + return nodes; + } +} diff --git a/src/utils/terminal/cell.ts b/src/utils/terminal/cell.ts new file mode 100644 index 0000000..f2984cc --- /dev/null +++ b/src/utils/terminal/cell.ts @@ -0,0 +1,9 @@ +export type Cell = { + char: string; +} & CellStyle; + +export type CellStyle = Partial<{ + foreground: string; + background: string; + fontWeight: number; +}>; diff --git a/src/utils/terminal/element.ts b/src/utils/terminal/element.ts new file mode 100644 index 0000000..2768d78 --- /dev/null +++ b/src/utils/terminal/element.ts @@ -0,0 +1,7 @@ +import { type Cell } from "./cell"; + +export interface TerminalElement { + readonly data: Array>; + readonly width: number; + readonly height: number; +} diff --git a/src/utils/terminal/elements/box.ts b/src/utils/terminal/elements/box.ts new file mode 100644 index 0000000..1183c4d --- /dev/null +++ b/src/utils/terminal/elements/box.ts @@ -0,0 +1,38 @@ +import { type Cell, type CellStyle } from "../cell"; +import { TerminalCanvas } from "../canvas"; +import { type TerminalElement } from "../element"; + +export class TerminalBoxElement implements TerminalElement { + public readonly data: Array>; + + constructor( + public readonly width: number, + public readonly height: number, + style: CellStyle = {}, + ) { + const canvas = new TerminalCanvas(width, height, style); + + if (width == 1 && height > 1) { + for (let y = 0; y < height - 1; y++) { + canvas.write(0, y, "│"); + } + } else if (height == 1 && width > 1) { + canvas.write(0, 0, "─".repeat(width - 2)); + } else { + canvas.write(0, 0, "┌"); + canvas.write(width - 1, 0, "┐"); + canvas.write(0, height - 1, "└"); + canvas.write(width - 1, height - 1, "┘"); + + canvas.write(1, 0, "─".repeat(width - 2)); + canvas.write(1, height - 1, "─".repeat(width - 2)); + + for (let y = 1; y < height - 1; y++) { + canvas.write(0, y, "│"); + canvas.write(width - 1, y, "│"); + } + } + + this.data = canvas.data; + } +} diff --git a/src/utils/terminal/theme.ts b/src/utils/terminal/theme.ts new file mode 100644 index 0000000..24a741b --- /dev/null +++ b/src/utils/terminal/theme.ts @@ -0,0 +1,12 @@ +export const theme = { + black: "#45475a", + red: "#f38ba8", + green: "#a6e3a1", + yellow: "#f9e2af", + blue: "#89bafa", + magenta: "#f5c2e7", + cyan: "#94e2d5", + white: "#bac2de", + grey: "#585B70", + lightGrey: "#a6adc8", +};