@anandaramr/obscura 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,3 @@
1
+ # Obscura Media Server
2
+
3
+ Core documentation and CLI guides will be published with the upcoming stable release.
@@ -0,0 +1,7 @@
1
+ export declare const DEFAULT_PORT = 4963;
2
+ export declare const DEFAULT_ADDRESS = "0.0.0.0";
3
+ export declare const DEFAULT_DIRECTORY = "./";
4
+ export declare const DEFAULT_THUMB_THRESHOLD: number;
5
+ export declare const DEFAULT_THUMB_SIZE = 400;
6
+ export declare const DEFAULT_THUMB_LIMIT = 3;
7
+ //# sourceMappingURL=defaults.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"defaults.d.ts","sourceRoot":"","sources":["../src/defaults.ts"],"names":[],"mappings":"AAAA,eAAO,MAAM,YAAY,OAAO,CAAA;AAChC,eAAO,MAAM,eAAe,YAAY,CAAA;AACxC,eAAO,MAAM,iBAAiB,OAAO,CAAA;AAErC,eAAO,MAAM,uBAAuB,QAAc,CAAA;AAClD,eAAO,MAAM,kBAAkB,MAAM,CAAA;AACrC,eAAO,MAAM,mBAAmB,IAAI,CAAA"}
@@ -0,0 +1,7 @@
1
+ export const DEFAULT_PORT = 4963;
2
+ export const DEFAULT_ADDRESS = '0.0.0.0';
3
+ export const DEFAULT_DIRECTORY = './';
4
+ export const DEFAULT_THUMB_THRESHOLD = 1024 * 1024;
5
+ export const DEFAULT_THUMB_SIZE = 400;
6
+ export const DEFAULT_THUMB_LIMIT = 3;
7
+ //# sourceMappingURL=defaults.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"defaults.js","sourceRoot":"","sources":["../src/defaults.ts"],"names":[],"mappings":"AAAA,MAAM,CAAC,MAAM,YAAY,GAAG,IAAI,CAAA;AAChC,MAAM,CAAC,MAAM,eAAe,GAAG,SAAS,CAAA;AACxC,MAAM,CAAC,MAAM,iBAAiB,GAAG,IAAI,CAAA;AAErC,MAAM,CAAC,MAAM,uBAAuB,GAAG,IAAI,GAAG,IAAI,CAAA;AAClD,MAAM,CAAC,MAAM,kBAAkB,GAAG,GAAG,CAAA;AACrC,MAAM,CAAC,MAAM,mBAAmB,GAAG,CAAC,CAAA"}
package/dist/file.d.ts ADDED
@@ -0,0 +1,5 @@
1
+ import type { Result, FileMetaData } from "./types.js";
2
+ export declare function validateDirectory(absoluteDirPath: string): Result<void, string>;
3
+ export declare function parseFileMetadata(filePath: string): Promise<FileMetaData | null>;
4
+ export declare function generateHash(fullPath: string): string;
5
+ //# sourceMappingURL=file.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"file.d.ts","sourceRoot":"","sources":["../src/file.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,MAAM,EAAE,YAAY,EAAE,MAAM,YAAY,CAAA;AAkCtD,wBAAgB,iBAAiB,CAAC,eAAe,EAAE,MAAM,GAAG,MAAM,CAAC,IAAI,EAAE,MAAM,CAAC,CAc/E;AAED,wBAAsB,iBAAiB,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,YAAY,GAAG,IAAI,CAAC,CAqBtF;AAED,wBAAgB,YAAY,CAAC,QAAQ,EAAE,MAAM,UAE5C"}
package/dist/file.js ADDED
@@ -0,0 +1,76 @@
1
+ import path from "path";
2
+ import fs from "fs";
3
+ import crypto from "crypto";
4
+ import sharp from "sharp";
5
+ const IMAGE_EXTS = [
6
+ ".jpg",
7
+ ".jpeg",
8
+ ".png",
9
+ ".gif",
10
+ ".webp",
11
+ ".heic",
12
+ ".heif",
13
+ ".tiff",
14
+ ".tif",
15
+ ".avif",
16
+ ];
17
+ const VIDEO_EXTS = [
18
+ ".mp4",
19
+ ".mov",
20
+ ".mkv",
21
+ ".webm",
22
+ ".m4v",
23
+ ".ts",
24
+ ".mts",
25
+ ".m2ts",
26
+ ];
27
+ const ALWAYS_ANIMATED_EXTS = new Set([".gif"]);
28
+ const SOMETIMES_ANIMATED = new Set([".webp", ".avif", ".png"]);
29
+ export function validateDirectory(absoluteDirPath) {
30
+ if (!path.isAbsolute(absoluteDirPath)) {
31
+ return { error: `Directory validation failed: not an absolute path` };
32
+ }
33
+ if (!fs.existsSync(absoluteDirPath)) {
34
+ return { error: `Directory validation failed: directory not found` };
35
+ }
36
+ const stats = fs.statSync(absoluteDirPath);
37
+ if (!stats.isDirectory()) {
38
+ return { error: `Directory validation failed: not a directory` };
39
+ }
40
+ return {};
41
+ }
42
+ export async function parseFileMetadata(filePath) {
43
+ const basename = path.basename(filePath);
44
+ const ext = path.extname(basename).toLowerCase();
45
+ if (![...IMAGE_EXTS, ...VIDEO_EXTS].includes(ext))
46
+ return null;
47
+ const stat = fs.statSync(filePath);
48
+ let animated = false;
49
+ if (ALWAYS_ANIMATED_EXTS.has(ext))
50
+ animated = true;
51
+ else if (SOMETIMES_ANIMATED.has(ext))
52
+ animated = await isAnimated(filePath);
53
+ return {
54
+ id: generateHash(filePath),
55
+ name: basename,
56
+ path: filePath,
57
+ type: IMAGE_EXTS.includes(ext) ? "image" : "video",
58
+ date: stat.mtime,
59
+ size: stat.size,
60
+ ext: ext,
61
+ isAnimated: animated,
62
+ };
63
+ }
64
+ export function generateHash(fullPath) {
65
+ return crypto.createHash("md5").update(fullPath).digest("hex");
66
+ }
67
+ async function isAnimated(filePath) {
68
+ try {
69
+ const metadata = await sharp(filePath).metadata();
70
+ return (metadata.pages ?? 1) > 1;
71
+ }
72
+ catch (_) {
73
+ return false;
74
+ }
75
+ }
76
+ //# sourceMappingURL=file.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"file.js","sourceRoot":"","sources":["../src/file.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,3 @@
1
+ declare const _default: () => (req: import("node:http").IncomingMessage, res: import("node:http").ServerResponse<import("node:http").IncomingMessage>, callback: (err?: Error) => void) => void;
2
+ export default _default;
3
+ //# sourceMappingURL=logger.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"logger.d.ts","sourceRoot":"","sources":["../src/logger.ts"],"names":[],"mappings":"qKAWkB,CAAC;AA2CnB,wBACwG"}
package/dist/logger.js ADDED
@@ -0,0 +1,49 @@
1
+ import morgan from "morgan";
2
+ const colors = {
3
+ reset: "\x1b[0m",
4
+ green: "\x1b[32m",
5
+ cyan: "\x1b[36m",
6
+ yellow: "\x1b[33m",
7
+ red: "\x1b[31m",
8
+ gray: "\x1b[90m",
9
+ };
10
+ morgan.token("colored-method", (req) => {
11
+ const method = req.method;
12
+ const color = method ? {
13
+ GET: colors.green,
14
+ POST: colors.cyan,
15
+ PUT: colors.yellow,
16
+ PATCH: colors.yellow,
17
+ DELETE: colors.red,
18
+ }[method] : colors.reset;
19
+ return `${color}${method}${colors.reset}`;
20
+ });
21
+ morgan.token("colored-status", (_, res) => {
22
+ const status = res.statusCode;
23
+ const color = status >= 500 ? colors.red : status >= 400 ? colors.yellow : status >= 300 ? colors.cyan : colors.green;
24
+ return `${color}${status}${colors.reset}`;
25
+ });
26
+ morgan.token("client-ip", (req) => {
27
+ return `${colors.gray}${req.headers["x-forwarded-for"] || req.socket.remoteAddress}${colors.reset}`;
28
+ });
29
+ morgan.token("device", (req) => {
30
+ const ua = req.headers["user-agent"];
31
+ if (!ua)
32
+ return "Unknown";
33
+ const name = ua.includes("Android")
34
+ ? "Android"
35
+ : ua.includes("iPhone")
36
+ ? "iPhone"
37
+ : ua.includes("iPad")
38
+ ? "iPad"
39
+ : ua.includes("Windows")
40
+ ? "Windows"
41
+ : ua.includes("Macintosh")
42
+ ? "Mac"
43
+ : ua.includes("Linux")
44
+ ? "Linux"
45
+ : "Unknown";
46
+ return name;
47
+ });
48
+ export default () => morgan(":date[iso] - :colored-method :url :colored-status :response-time ms — :client-ip [:device]");
49
+ //# sourceMappingURL=logger.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"logger.js","sourceRoot":"","sources":["../src/logger.ts"],"names":[],"mappings":"AAAA,OAAO,MAAM,MAAM,QAAQ,CAAA;AAE3B,MAAM,MAAM,GAAG;IACX,KAAK,EAAE,SAAS;IAChB,KAAK,EAAE,UAAU;IACjB,IAAI,EAAE,UAAU;IAChB,MAAM,EAAE,UAAU;IAClB,GAAG,EAAE,UAAU;IACf,IAAI,EAAE,UAAU;CACnB,CAAA;AAED,MAAM,CAAC,KAAK,CAAC,gBAAgB,EAAE,CAAC,GAAG,EAAE,EAAE;IACnC,MAAM,MAAM,GAAG,GAAG,CAAC,MAAM,CAAA;IACzB,MAAM,KAAK,GAAG,MAAM,CAAC,CAAC,CAAC;QACzB,GAAG,EAAE,MAAM,CAAC,KAAK;QACjB,IAAI,EAAE,MAAM,CAAC,IAAI;QACjB,GAAG,EAAE,MAAM,CAAC,MAAM;QAClB,KAAK,EAAE,MAAM,CAAC,MAAM;QACpB,MAAM,EAAE,MAAM,CAAC,GAAG;KAClB,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC,MAAM,CAAC,KAAK,CAAA;IACrB,OAAO,GAAG,KAAK,GAAG,MAAM,GAAG,MAAM,CAAC,KAAK,EAAE,CAAA;AAC7C,CAAC,CAAC,CAAA;AAEF,MAAM,CAAC,KAAK,CAAC,gBAAgB,EAAE,CAAC,CAAC,EAAE,GAAG,EAAE,EAAE;IACtC,MAAM,MAAM,GAAG,GAAG,CAAC,UAAU,CAAA;IAC7B,MAAM,KAAK,GACP,MAAM,IAAI,GAAG,CAAC,CAAC,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,CAAC,MAAM,IAAI,GAAG,CAAC,CAAC,CAAC,MAAM,CAAC,MAAM,CAAC,CAAC,CAAC,MAAM,IAAI,GAAG,CAAC,CAAC,CAAC,MAAM,CAAC,IAAI,CAAC,CAAC,CAAC,MAAM,CAAC,KAAK,CAAA;IAC3G,OAAO,GAAG,KAAK,GAAG,MAAM,GAAG,MAAM,CAAC,KAAK,EAAE,CAAA;AAC7C,CAAC,CAAC,CAAA;AAEF,MAAM,CAAC,KAAK,CAAC,WAAW,EAAE,CAAC,GAAG,EAAE,EAAE;IAC9B,OAAO,GAAG,MAAM,CAAC,IAAI,GAAG,GAAG,CAAC,OAAO,CAAC,iBAAiB,CAAC,IAAI,GAAG,CAAC,MAAM,CAAC,aAAa,GAAG,MAAM,CAAC,KAAK,EAAE,CAAA;AACvG,CAAC,CAAC,CAAA;AAEF,MAAM,CAAC,KAAK,CAAC,QAAQ,EAAE,CAAC,GAAG,EAAE,EAAE;IAC3B,MAAM,EAAE,GAAG,GAAG,CAAC,OAAO,CAAC,YAAY,CAAC,CAAA;IACpC,IAAI,CAAC,EAAE;QAAE,OAAO,SAAS,CAAA;IAEzB,MAAM,IAAI,GAAG,EAAE,CAAC,QAAQ,CAAC,SAAS,CAAC;QAC/B,CAAC,CAAC,SAAS;QACX,CAAC,CAAC,EAAE,CAAC,QAAQ,CAAC,QAAQ,CAAC;YACrB,CAAC,CAAC,QAAQ;YACV,CAAC,CAAC,EAAE,CAAC,QAAQ,CAAC,MAAM,CAAC;gBACnB,CAAC,CAAC,MAAM;gBACR,CAAC,CAAC,EAAE,CAAC,QAAQ,CAAC,SAAS,CAAC;oBACtB,CAAC,CAAC,SAAS;oBACX,CAAC,CAAC,EAAE,CAAC,QAAQ,CAAC,WAAW,CAAC;wBACxB,CAAC,CAAC,KAAK;wBACP,CAAC,CAAC,EAAE,CAAC,QAAQ,CAAC,OAAO,CAAC;4BACpB,CAAC,CAAC,OAAO;4BACT,CAAC,CAAC,SAAS,CAAA;IACzB,OAAO,IAAI,CAAA;AACf,CAAC,CAAC,CAAA;AAEF,eAAe,GAAG,EAAE,CAChB,MAAM,CAAC,4FAA4F,CAAC,CAAA"}
@@ -0,0 +1,3 @@
1
+ #!/usr/bin/env node
2
+ export {};
3
+ //# sourceMappingURL=server.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"server.d.ts","sourceRoot":"","sources":["../src/server.ts"],"names":[],"mappings":""}
package/dist/server.js ADDED
@@ -0,0 +1,148 @@
1
+ #!/usr/bin/env node
2
+ import express from "express";
3
+ import os from "os";
4
+ import chokidar from "chokidar";
5
+ import pLimit from "p-limit";
6
+ import dotenv from "dotenv";
7
+ dotenv.config({ quiet: true });
8
+ import path from "path";
9
+ import fs from "fs";
10
+ import sharp from "sharp";
11
+ sharp.cache(false);
12
+ import { validateDirectory, parseFileMetadata, generateHash } from "./file.js";
13
+ import { DEFAULT_PORT, DEFAULT_ADDRESS, DEFAULT_DIRECTORY, DEFAULT_THUMB_LIMIT, DEFAULT_THUMB_SIZE, DEFAULT_THUMB_THRESHOLD, } from "./defaults.js";
14
+ import logger from "./logger.js";
15
+ import { generateImageThumbnail, generateVideoThumbnail } from "./thumbnail.js";
16
+ import { insertSorted } from "./utils.js";
17
+ import { fileURLToPath } from 'url';
18
+ const __filename = fileURLToPath(import.meta.url);
19
+ const PROJECT_ROOT = path.resolve(path.dirname(__filename), "..");
20
+ const app = express();
21
+ app.use(express.json());
22
+ app.use(logger());
23
+ app.use(express.static(path.join(PROJECT_ROOT, "public")));
24
+ const GALLERY_DIR = path.resolve(process.cwd(), process.argv[2] || process.env.GALLERY_DIR || DEFAULT_DIRECTORY);
25
+ const ADDRESS = process.argv[3] || process.env.ADDRESS || DEFAULT_ADDRESS;
26
+ const PORT = parseInt(process.argv[4] || "") || parseInt(process.env.PORT || "") || DEFAULT_PORT;
27
+ const THUMB_THRESHOLD = parseInt(process.env.THUMB_THRESHOLD || "") || DEFAULT_THUMB_THRESHOLD;
28
+ const THUMB_SIZE = parseInt(process.env.THUMB_SIZE || "") || DEFAULT_THUMB_SIZE;
29
+ const THUMB_LIMIT = parseInt(process.env.THUMB_LIMIT || "") || DEFAULT_THUMB_LIMIT;
30
+ const THUMBS_DIR = path.join(PROJECT_ROOT, "thumbs");
31
+ if (!fs.existsSync(THUMBS_DIR))
32
+ fs.mkdirSync(THUMBS_DIR, { recursive: true });
33
+ const limit = pLimit(THUMB_LIMIT);
34
+ const { error } = validateDirectory(GALLERY_DIR);
35
+ if (error) {
36
+ console.error(`\x1b[31m[Obscura Startup Error]\x1b[0m ${error}`);
37
+ console.error(`Please provide a valid media directory path.`);
38
+ process.exit(1);
39
+ }
40
+ let filesMap = new Map();
41
+ let sortedFiles = [];
42
+ const watcher = chokidar.watch(GALLERY_DIR, {
43
+ ignored: /(^|[\/\\])\../,
44
+ persistent: true,
45
+ ignoreInitial: false,
46
+ });
47
+ let clients = [];
48
+ let isBooting = true;
49
+ function broadcastToUsers(action, fileData) {
50
+ clients.forEach((client) => {
51
+ client.res.write(`data: ${JSON.stringify({ action, file: fileData })}\n\n`);
52
+ });
53
+ }
54
+ watcher.on("add", async (filePath) => {
55
+ const fileData = await parseFileMetadata(filePath);
56
+ if (!fileData)
57
+ return;
58
+ const isExisting = filesMap.has(fileData.id);
59
+ filesMap.set(fileData.id, fileData);
60
+ if (isExisting)
61
+ return;
62
+ const { id, name, type, date, size, isAnimated } = fileData;
63
+ const clientFileData = { id, name, type, date, size, isAnimated };
64
+ insertSorted(sortedFiles, clientFileData, (file) => new Date(file.date).getTime(), (a, b) => b - a);
65
+ if (isBooting)
66
+ return;
67
+ broadcastToUsers("add", clientFileData);
68
+ });
69
+ watcher.on("unlink", (filePath) => {
70
+ const fileId = generateHash(filePath);
71
+ if (filesMap.has(fileId)) {
72
+ filesMap.delete(fileId);
73
+ sortedFiles = sortedFiles.filter((file) => file.id !== fileId);
74
+ broadcastToUsers("remove", { id: fileId });
75
+ const thumbPath = path.join(THUMBS_DIR, `${fileId}.jpg`);
76
+ fs.unlink(thumbPath, () => { });
77
+ }
78
+ });
79
+ watcher.on("ready", () => {
80
+ isBooting = false;
81
+ });
82
+ app.get("/api/files", (req, res) => {
83
+ res.status(200).json(sortedFiles);
84
+ });
85
+ app.get("/api/files/:id", (req, res) => {
86
+ const file = filesMap.get(req.params.id);
87
+ if (!file)
88
+ return res.sendStatus(404);
89
+ res.sendFile(file.path);
90
+ });
91
+ app.get("/api/events", (req, res) => {
92
+ res.setHeader("Content-Type", "text/event-stream");
93
+ res.setHeader("Cache-Control", "no-cache");
94
+ res.setHeader("Connection", "keep-alive");
95
+ const clientId = Date.now();
96
+ clients.push({ id: clientId, res });
97
+ req.on("close", () => {
98
+ clients = clients.filter((client) => client.id !== clientId);
99
+ });
100
+ });
101
+ app.get("/api/thumb/:id", async (req, res) => {
102
+ const file = filesMap.get(req.params.id);
103
+ if (!file)
104
+ return res.sendStatus(404);
105
+ if (shouldAvoidCaching(file)) {
106
+ return res.sendFile(file.path);
107
+ }
108
+ const thumbPath = path.join(THUMBS_DIR, `${file.id}.jpg`);
109
+ try {
110
+ await limit(async () => {
111
+ // check again after waiting in queue
112
+ if (fs.existsSync(thumbPath))
113
+ return;
114
+ if (file.type === "image") {
115
+ await generateImageThumbnail(file, thumbPath, THUMB_SIZE);
116
+ }
117
+ else {
118
+ await generateVideoThumbnail(file, thumbPath, THUMB_SIZE);
119
+ }
120
+ });
121
+ res.sendFile(path.resolve(thumbPath));
122
+ }
123
+ catch (err) {
124
+ console.error(`Error while creating thumbnail: ${err}`);
125
+ res.sendStatus(500);
126
+ }
127
+ });
128
+ app.listen(PORT, ADDRESS, (error) => {
129
+ if (error) {
130
+ console.error(`\x1b[31m[Obscura Startup Error]\x1b[0m ${error.message}`);
131
+ process.exit(1);
132
+ }
133
+ console.log(`Obscura running at ${ADDRESS}:${PORT}`);
134
+ console.log(`Serving media from \x1b[36m${GALLERY_DIR}\x1b[0m\n`);
135
+ const interfaces = os.networkInterfaces();
136
+ Object.entries(interfaces).forEach(([name, addresses]) => {
137
+ addresses
138
+ ?.filter((addr) => addr.family === "IPv4")
139
+ .forEach((addr) => {
140
+ console.log(`- \x1b[36mhttp://${addr.address}:${PORT}\x1b[0m \t [${name}]`);
141
+ });
142
+ });
143
+ console.log("\n");
144
+ });
145
+ function shouldAvoidCaching(file) {
146
+ return file.type == "image" && (file.size < THUMB_THRESHOLD && !file.isAnimated);
147
+ }
148
+ //# sourceMappingURL=server.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"server.js","sourceRoot":"","sources":["../src/server.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,4 @@
1
+ import type { FileMetaData } from "./types.js";
2
+ export declare function generateVideoThumbnail(file: FileMetaData, thumbPath: string, thumbSize: number): Promise<void>;
3
+ export declare function generateImageThumbnail(file: FileMetaData, thumbPath: string, thumbSize: number): Promise<void>;
4
+ //# sourceMappingURL=thumbnail.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"thumbnail.d.ts","sourceRoot":"","sources":["../src/thumbnail.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,YAAY,CAAA;AAQ9C,wBAAgB,sBAAsB,CAAC,IAAI,EAAE,YAAY,EAAE,SAAS,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,iBAuB9F;AAED,wBAAsB,sBAAsB,CAAC,IAAI,EAAE,YAAY,EAAE,SAAS,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,iBAKpG"}
@@ -0,0 +1,31 @@
1
+ import { execFile } from "child_process";
2
+ import sharp from "sharp";
3
+ import ffmpeg from "ffmpeg-static";
4
+ const ffmpegPath = typeof ffmpeg === "string" ? ffmpeg : ffmpeg.default;
5
+ export function generateVideoThumbnail(file, thumbPath, thumbSize) {
6
+ return new Promise((resolve, reject) => {
7
+ execFile(ffmpegPath, [
8
+ "-i",
9
+ file.path,
10
+ "-frames:v",
11
+ "1",
12
+ "-vf",
13
+ `scale=${thumbSize}:${thumbSize}:force_original_aspect_ratio=increase,crop=${thumbSize}:${thumbSize}:(iw-${thumbSize})/2:(ih-${thumbSize})/2`,
14
+ "-q:v",
15
+ "2",
16
+ thumbPath,
17
+ ], (err, _stdout, _stderr) => {
18
+ if (err) {
19
+ return reject(new Error(`ffmpeg error: ${err.message}`));
20
+ }
21
+ resolve();
22
+ });
23
+ });
24
+ }
25
+ export async function generateImageThumbnail(file, thumbPath, thumbSize) {
26
+ await sharp(file.path, { animated: false, page: 0 })
27
+ .resize(thumbSize, thumbSize, { fit: "cover" })
28
+ .jpeg({ quality: 80 })
29
+ .toFile(thumbPath);
30
+ }
31
+ //# sourceMappingURL=thumbnail.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"thumbnail.js","sourceRoot":"","sources":["../src/thumbnail.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,QAAQ,EAAE,MAAM,eAAe,CAAA;AACxC,OAAO,KAAK,MAAM,OAAO,CAAA;AACzB,OAAO,MAAM,MAAM,eAAe,CAAA;AAElC,MAAM,UAAU,GAAG,OAAO,MAAM,KAAK,QAAQ,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAE,MAAc,CAAC,OAAO,CAAA;AAEhF,MAAM,UAAU,sBAAsB,CAAC,IAAkB,EAAE,SAAiB,EAAE,SAAiB;IAC3F,OAAO,IAAI,OAAO,CAAO,CAAC,OAAO,EAAE,MAAM,EAAE,EAAE;QACzC,QAAQ,CACJ,UAAU,EACV;YACI,IAAI;YACJ,IAAI,CAAC,IAAI;YACT,WAAW;YACX,GAAG;YACH,KAAK;YACL,SAAS,SAAS,IAAI,SAAS,8CAA8C,SAAS,IAAI,SAAS,QAAQ,SAAS,WAAW,SAAS,KAAK;YAC7I,MAAM;YACN,GAAG;YACH,SAAS;SACZ,EACD,CAAC,GAAG,EAAE,OAAO,EAAE,OAAO,EAAE,EAAE;YACtB,IAAI,GAAG,EAAE,CAAC;gBACN,OAAO,MAAM,CAAC,IAAI,KAAK,CAAC,iBAAiB,GAAG,CAAC,OAAO,EAAE,CAAC,CAAC,CAAA;YAC5D,CAAC;YACD,OAAO,EAAE,CAAA;QACb,CAAC,CACJ,CAAA;IACL,CAAC,CAAC,CAAA;AACN,CAAC;AAED,MAAM,CAAC,KAAK,UAAU,sBAAsB,CAAC,IAAkB,EAAE,SAAiB,EAAE,SAAiB;IACjG,MAAM,KAAK,CAAC,IAAI,CAAC,IAAI,EAAE,EAAE,QAAQ,EAAE,KAAK,EAAE,IAAI,EAAE,CAAC,EAAE,CAAC;SAC/C,MAAM,CAAC,SAAS,EAAE,SAAS,EAAE,EAAE,GAAG,EAAE,OAAO,EAAE,CAAC;SAC9C,IAAI,CAAC,EAAE,OAAO,EAAE,EAAE,EAAE,CAAC;SACrB,MAAM,CAAC,SAAS,CAAC,CAAA;AAC1B,CAAC"}
@@ -0,0 +1,21 @@
1
+ import type { Response } from "express";
2
+ export interface FileMetaData {
3
+ id: string;
4
+ name: string;
5
+ path: string;
6
+ type: "image" | "video";
7
+ date: Date;
8
+ size: number;
9
+ ext: string;
10
+ isAnimated: boolean;
11
+ }
12
+ export type ClientFileMetadata = Pick<FileMetaData, "id" | "name" | "type" | "date" | "size" | "isAnimated">;
13
+ export interface SseClient {
14
+ id: number;
15
+ res: Response;
16
+ }
17
+ export interface Result<T, S> {
18
+ result?: T;
19
+ error?: S;
20
+ }
21
+ //# sourceMappingURL=types.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,SAAS,CAAA;AAEvC,MAAM,WAAW,YAAY;IACzB,EAAE,EAAE,MAAM,CAAA;IACV,IAAI,EAAE,MAAM,CAAA;IACZ,IAAI,EAAE,MAAM,CAAA;IACZ,IAAI,EAAE,OAAO,GAAG,OAAO,CAAA;IACvB,IAAI,EAAE,IAAI,CAAA;IACV,IAAI,EAAE,MAAM,CAAA;IACZ,GAAG,EAAE,MAAM,CAAA;IACX,UAAU,EAAE,OAAO,CAAA;CACtB;AAED,MAAM,MAAM,kBAAkB,GAAG,IAAI,CAAC,YAAY,EAAE,IAAI,GAAG,MAAM,GAAG,MAAM,GAAG,MAAM,GAAG,MAAM,GAAG,YAAY,CAAC,CAAA;AAE5G,MAAM,WAAW,SAAS;IACtB,EAAE,EAAE,MAAM,CAAA;IACV,GAAG,EAAE,QAAQ,CAAA;CAChB;AAED,MAAM,WAAW,MAAM,CAAC,CAAC,EAAE,CAAC;IACxB,MAAM,CAAC,EAAE,CAAC,CAAA;IACV,KAAK,CAAC,EAAE,CAAC,CAAA;CACZ"}
package/dist/types.js ADDED
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=types.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.js","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":""}
@@ -0,0 +1,2 @@
1
+ export declare function insertSorted<T>(arr: T[], element: T, key: (element: T) => number, comparator?: (a: number, b: number) => number): void;
2
+ //# sourceMappingURL=utils.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"utils.d.ts","sourceRoot":"","sources":["../src/utils.ts"],"names":[],"mappings":"AAAA,wBAAgB,YAAY,CAAC,CAAC,EAC1B,GAAG,EAAE,CAAC,EAAE,EACR,OAAO,EAAE,CAAC,EACV,GAAG,EAAE,CAAC,OAAO,EAAE,CAAC,KAAK,MAAM,EAC3B,UAAU,IAAI,GAAG,MAAM,EAAE,GAAG,MAAM,WAAU,QAkB/C"}
package/dist/utils.js ADDED
@@ -0,0 +1,17 @@
1
+ export function insertSorted(arr, element, key, comparator = (a, b) => a - b) {
2
+ let low = 0;
3
+ let high = arr.length;
4
+ const targetValue = key(element);
5
+ while (low < high) {
6
+ const mid = (low + high) >>> 1;
7
+ const midValue = key(arr[mid]);
8
+ if (comparator(targetValue, midValue) > 0) {
9
+ low = mid + 1;
10
+ }
11
+ else {
12
+ high = mid;
13
+ }
14
+ }
15
+ arr.splice(low, 0, element);
16
+ }
17
+ //# sourceMappingURL=utils.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"utils.js","sourceRoot":"","sources":["../src/utils.ts"],"names":[],"mappings":"AAAA,MAAM,UAAU,YAAY,CACxB,GAAQ,EACR,OAAU,EACV,GAA2B,EAC3B,aAAa,CAAC,CAAS,EAAE,CAAS,EAAE,EAAE,CAAC,CAAC,GAAG,CAAC;IAE5C,IAAI,GAAG,GAAG,CAAC,CAAA;IACX,IAAI,IAAI,GAAG,GAAG,CAAC,MAAM,CAAA;IACrB,MAAM,WAAW,GAAG,GAAG,CAAC,OAAO,CAAC,CAAA;IAEhC,OAAO,GAAG,GAAG,IAAI,EAAE,CAAC;QAChB,MAAM,GAAG,GAAG,CAAC,GAAG,GAAG,IAAI,CAAC,KAAK,CAAC,CAAA;QAC9B,MAAM,QAAQ,GAAG,GAAG,CAAC,GAAG,CAAC,GAAG,CAAM,CAAC,CAAA;QAEnC,IAAI,UAAU,CAAC,WAAW,EAAE,QAAQ,CAAC,GAAG,CAAC,EAAE,CAAC;YACxC,GAAG,GAAG,GAAG,GAAG,CAAC,CAAA;QACjB,CAAC;aAAM,CAAC;YACJ,IAAI,GAAG,GAAG,CAAA;QACd,CAAC;IACL,CAAC;IAED,GAAG,CAAC,MAAM,CAAC,GAAG,EAAE,CAAC,EAAE,OAAO,CAAC,CAAA;AAC/B,CAAC"}
package/package.json ADDED
@@ -0,0 +1,37 @@
1
+ {
2
+ "name": "@anandaramr/obscura",
3
+ "version": "1.0.0",
4
+ "description": "A lightweight local media streaming server",
5
+ "main": "dist/server.js",
6
+ "type": "module",
7
+ "bin": {
8
+ "obscura": "dist/server.js"
9
+ },
10
+ "files": [
11
+ "dist/**/*",
12
+ "public/**/*"
13
+ ],
14
+ "scripts": {
15
+ "dev": "tsx watch src/server.ts",
16
+ "build": "tsc",
17
+ "start": "node dist/server.js",
18
+ "prepublishOnly": "npm run build"
19
+ },
20
+ "dependencies": {
21
+ "chokidar": "^5.0.0",
22
+ "dotenv": "^17.4.2",
23
+ "express": "^5.2.1",
24
+ "ffmpeg-static": "^5.3.0",
25
+ "morgan": "^1.10.1",
26
+ "p-limit": "^7.3.0",
27
+ "sharp": "^0.34.5"
28
+ },
29
+ "devDependencies": {
30
+ "@types/express": "^5.0.6",
31
+ "@types/morgan": "^1.9.10",
32
+ "@types/node": "^25.9.1",
33
+ "@types/sharp": "^0.31.1",
34
+ "tsx": "^4.22.3",
35
+ "typescript": "^6.0.3"
36
+ }
37
+ }
package/public/app.js ADDED
@@ -0,0 +1,288 @@
1
+ let files = []
2
+ let isShuffled = false
3
+ let elementMap = new Map()
4
+
5
+ function onVideoPlay(fileId) {
6
+ clearTimeout(cacheTTLMap.get(fileId))
7
+ cacheTTLMap.delete(fileId)
8
+ }
9
+
10
+ function onVideoPause(fileId, vid) {
11
+ const timer = setTimeout(() => {
12
+ vid.removeAttribute('src')
13
+ vid.load()
14
+ cacheTTLMap.delete(fileId)
15
+ }, VIDEO_CACHE_TTL)
16
+
17
+ cacheTTLMap.set(fileId, timer)
18
+ }
19
+
20
+ init()
21
+
22
+ async function init() {
23
+ const res = await fetch('/api/files')
24
+ files = await res.json()
25
+
26
+ params = new URLSearchParams(window.location.search)
27
+ isShuffled = params.has('shuffle')
28
+ const fileList = isShuffled ? shuffleArray(files) : files
29
+
30
+ renderGrids(fileList)
31
+ updateShuffledState(isShuffled)
32
+ }
33
+
34
+ function renderGrids(files) {
35
+ const grid = document.getElementById('grid')
36
+
37
+ for (let i = 0; i < files.length; i++) {
38
+ const file = files[i]
39
+ insertGridItem(file, grid)
40
+ }
41
+ }
42
+
43
+ const eventSource = new EventSource('/api/events')
44
+
45
+ eventSource.onmessage = evt => {
46
+ const data = JSON.parse(evt.data)
47
+ const grid = document.getElementById('grid')
48
+
49
+ if (data.action === 'add') {
50
+ insertGridItem(data.file, grid, true)
51
+ } else if (data.action === 'remove') {
52
+ removeGridItem(data.file.id)
53
+ }
54
+ }
55
+
56
+ let previewWindow = []
57
+ const observer = new IntersectionObserver(
58
+ entries => {
59
+ entries.forEach(entry => {
60
+ if (entry.isIntersecting) {
61
+ onVisible(entry.target)
62
+ } else {
63
+ if (previewWindow.includes(entry.target)) {
64
+ onEndOfVisibility(entry.target)
65
+ }
66
+ }
67
+ })
68
+ },
69
+ { threshold: 1 }
70
+ )
71
+
72
+ let currentPreview = null
73
+ function onVisible(preview) {
74
+ const vid = preview.getElementsByClassName('video-preview')[0]
75
+ previewWindow.push(preview)
76
+ previewWindow.sort((a, b) => a.getBoundingClientRect().top - b.getBoundingClientRect().top)
77
+
78
+ vid.onended = evt => {
79
+ const idx = previewWindow.indexOf(preview)
80
+ stopMobilePreview(idx)
81
+ startMobilePreview((idx + 1) % previewWindow.length)
82
+ }
83
+
84
+ if (!currentPreview) startMobilePreview(0)
85
+ }
86
+
87
+ function onEndOfVisibility(preview) {
88
+ const idx = previewWindow.indexOf(preview)
89
+ const isCurrentlyPlaying = currentPreview == preview
90
+
91
+ stopMobilePreview(idx)
92
+ previewWindow.splice(idx, 1)
93
+ if (isCurrentlyPlaying && previewWindow.length) startMobilePreview(idx % previewWindow.length)
94
+ }
95
+
96
+ function insertGridItem(file, grid, prepend = false) {
97
+ const preview = document.createElement('a')
98
+
99
+ preview.className = 'grid-item'
100
+ preview.href = `/api/files/${file.id}`
101
+ preview.id = file.id
102
+
103
+ const media = document.createElement('img')
104
+ media.src = `/api/thumb/${file.id}`
105
+ media.loading = 'lazy'
106
+ media.className = file.type === 'image' ? 'img' : 'video'
107
+ preview.appendChild(media)
108
+
109
+ if (file.type != 'image') {
110
+ const icon = document.createElement('span')
111
+ icon.className = 'play-icon'
112
+
113
+ const iconImg = document.createElement('img')
114
+ iconImg.src = '/play_icon.svg'
115
+
116
+ icon.appendChild(iconImg)
117
+ preview.appendChild(icon)
118
+
119
+ const vid = document.createElement('video')
120
+ vid.preload = 'none'
121
+ vid.classList.add('video-preview', 'fade')
122
+ vid.muted = true
123
+ vid.loop = !isMobileDevice()
124
+
125
+ preview.appendChild(vid)
126
+
127
+ preview.onmouseenter = () => {
128
+ if (!isMobileDevice()) {
129
+ startVideoPreview(vid, file.id, media, icon)
130
+ }
131
+ }
132
+
133
+ preview.onmouseleave = () => {
134
+ if (!isMobileDevice()) {
135
+ stopVideoPreview(vid, file.id, media, icon)
136
+ }
137
+ }
138
+ } else if (file.isAnimated) {
139
+ const icon = document.createElement('span')
140
+ icon.className = 'live-indicator'
141
+ icon.innerText = "LIVE"
142
+ preview.appendChild(icon)
143
+
144
+ preview.onmouseenter = () => {
145
+ if (!isMobileDevice()) {
146
+ media.src = `/api/files/${file.id}`
147
+ }
148
+ }
149
+
150
+ preview.onmouseleave = () => {
151
+ if (!isMobileDevice()) {
152
+ media.src = `/api/thumb/${file.id}`
153
+ }
154
+ }
155
+ }
156
+
157
+ if (prepend) {
158
+ grid.prepend(preview)
159
+ } else {
160
+ grid.appendChild(preview)
161
+ }
162
+
163
+ if (isMobileDevice() && file.type === 'video') observer.observe(preview)
164
+ elementMap.set(file.id, preview)
165
+ }
166
+
167
+ const VIDEO_CACHE_TTL = 60 * 1000
168
+ let cacheTTLMap = new Map()
169
+
170
+ function stopMobilePreview(idx) {
171
+ const preview = previewWindow[idx]
172
+ if (currentPreview != preview) return
173
+
174
+ const vid = preview.getElementsByClassName('video-preview')[0]
175
+ const media = preview.getElementsByClassName('video')[0]
176
+ const icon = preview.getElementsByClassName('play-icon')[0]
177
+
178
+ stopVideoPreview(vid, preview.id, media, icon)
179
+ currentPreview = null
180
+ }
181
+
182
+ function startMobilePreview(idx) {
183
+ const preview = previewWindow[idx]
184
+ currentPreview = preview
185
+
186
+ const vid = preview.getElementsByClassName('video-preview')[0]
187
+ if (!vid) return
188
+
189
+ const media = preview.getElementsByTagName('img')[0]
190
+ const icon = preview.getElementsByTagName('span')[0]
191
+ startVideoPreview(vid, preview.id, media, icon)
192
+ }
193
+
194
+ function stopVideoPreview(vid, id, media, icon) {
195
+ vid.pause()
196
+ onVideoPause(id, vid)
197
+
198
+ vid.classList.add('fade')
199
+ media.classList.remove('fade')
200
+ icon.classList.remove('blink')
201
+ icon.classList.remove('fade')
202
+ }
203
+
204
+ function startVideoPreview(vid, id, media, icon) {
205
+ icon.classList.add('blink')
206
+ vid.addEventListener(
207
+ 'playing',
208
+ () => {
209
+ icon.classList.remove('blink')
210
+ icon.classList.add('fade')
211
+ },
212
+ { once: true }
213
+ )
214
+
215
+ if (!vid.src) {
216
+ vid.src = `/api/files/${id}`
217
+ vid.load()
218
+ }
219
+
220
+ vid.classList.remove('fade')
221
+ media.classList.add('fade')
222
+
223
+ vid.currentTime = 0
224
+ onVideoPlay(id)
225
+ vid.play().catch(err => console.log('Play interrupted:', err))
226
+ }
227
+
228
+ function isMobileDevice() {
229
+ return !window.matchMedia('(min-width: 769px)').matches
230
+ }
231
+
232
+ function removeGridItem(fileId) {
233
+ const child = elementMap.get(fileId)
234
+ if (child) {
235
+ const grid = document.getElementById('grid')
236
+ grid.removeChild(child)
237
+ elementMap.delete(fileId)
238
+ files = files.filter(f => f.id !== fileId)
239
+ }
240
+ }
241
+
242
+ function shuffleGrid() {
243
+ const shuffledIds = shuffleArray(files.map(f => f.id))
244
+ const grid = document.getElementById('grid')
245
+ grid.replaceChildren(...shuffledIds.map(id => elementMap.get(id)).filter(Boolean))
246
+
247
+ updateShuffledState(true)
248
+ }
249
+
250
+ function unShuffleGrid() {
251
+ const grid = document.getElementById('grid')
252
+ grid.replaceChildren(...files.map(f => elementMap.get(f.id)).filter(Boolean))
253
+ updateShuffledState(false)
254
+ }
255
+
256
+ function updateShuffledState(newState) {
257
+ isShuffled = newState
258
+ const button = document.getElementById('shuffle-btn')
259
+ const url = new URL(window.location)
260
+
261
+ if (newState) {
262
+ button.classList.add('active')
263
+ button.classList.remove('inactive')
264
+ url.searchParams.set('shuffle', '1')
265
+ } else {
266
+ button.classList.remove('active')
267
+ button.classList.add('inactive')
268
+ url.searchParams.delete('shuffle')
269
+ }
270
+ history.replaceState({ shuffle: newState }, '', url)
271
+ }
272
+
273
+ function toggleShuffle() {
274
+ if (isShuffled) {
275
+ unShuffleGrid()
276
+ } else {
277
+ shuffleGrid()
278
+ }
279
+ }
280
+
281
+ function shuffleArray(array) {
282
+ let copy = [...array]
283
+ for (let i = array.length - 1; i > 0; i--) {
284
+ const j = Math.floor(Math.random() * (i + 1))
285
+ ;[copy[i], copy[j]] = [copy[j], copy[i]]
286
+ }
287
+ return copy
288
+ }
@@ -0,0 +1,28 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+
4
+ <head>
5
+ <meta charset="UTF-8">
6
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
7
+ <title>Obscura</title>
8
+ <link rel="stylesheet" href="style.css">
9
+ </head>
10
+
11
+ <body>
12
+ <div id="app">
13
+ <div class="grid" id="grid"></div>
14
+ <button id="shuffle-btn" onclick="toggleShuffle()">
15
+ <svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" class="bi bi-shuffle"
16
+ viewBox="0 0 16 16">
17
+ <path fill-rule="evenodd"
18
+ d="M0 3.5A.5.5 0 0 1 .5 3H1c2.202 0 3.827 1.24 4.874 2.418.49.552.865 1.102 1.126 1.532.26-.43.636-.98 1.126-1.532C9.173 4.24 10.798 3 13 3v1c-1.798 0-3.173 1.01-4.126 2.082A9.6 9.6 0 0 0 7.556 8a9.6 9.6 0 0 0 1.317 1.918C9.828 10.99 11.204 12 13 12v1c-2.202 0-3.827-1.24-4.874-2.418A10.6 10.6 0 0 1 7 9.05c-.26.43-.636.98-1.126 1.532C4.827 11.76 3.202 13 1 13H.5a.5.5 0 0 1 0-1H1c1.798 0 3.173-1.01 4.126-2.082A9.6 9.6 0 0 0 6.444 8a9.6 9.6 0 0 0-1.317-1.918C4.172 5.01 2.796 4 1 4H.5a.5.5 0 0 1-.5-.5" />
19
+ <path
20
+ d="M13 5.466V1.534a.25.25 0 0 1 .41-.192l2.36 1.966c.12.1.12.284 0 .384l-2.36 1.966a.25.25 0 0 1-.41-.192m0 9v-3.932a.25.25 0 0 1 .41-.192l2.36 1.966c.12.1.12.284 0 .384l-2.36 1.966a.25.25 0 0 1-.41-.192" />
21
+ </svg>
22
+ </button>
23
+ </div>
24
+
25
+ <script src="app.js"></script>
26
+ </body>
27
+
28
+ </html>
@@ -0,0 +1 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#e3e3e3"><path d="M320-200v-560l440 280-440 280Zm80-280Zm0 134 210-134-210-134v268Z"/></svg>
@@ -0,0 +1,169 @@
1
+ * {
2
+ margin: 0;
3
+ padding: 0;
4
+ box-sizing: border-box;
5
+ }
6
+
7
+ body {
8
+ background: #000;
9
+ color: #fff;
10
+ font-family: Arial, sans-serif;
11
+ }
12
+
13
+ @media (prefers-color-scheme: light) {
14
+ body {
15
+ background-color: floralwhite;
16
+ }
17
+ }
18
+
19
+ .grid {
20
+ display: grid;
21
+ grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
22
+ gap: 15px;
23
+ padding: 20px;
24
+ }
25
+
26
+ .grid-item {
27
+ position: relative;
28
+ background-color: #000;
29
+ overflow: hidden;
30
+ border-radius: 12px;
31
+ transition:
32
+ transform 0.3s ease,
33
+ opacity 0.7s ease;
34
+ height: 300px;
35
+ -webkit-tap-highlight-color: transparent;
36
+ opacity: 1;
37
+ }
38
+
39
+ .img,
40
+ .video {
41
+ width: 100%;
42
+ height: 100%;
43
+ object-fit: cover;
44
+ object-position: center;
45
+ display: block;
46
+ position: absolute;
47
+ transition: transform 0.4s ease, opacity 0.2s ease-in;
48
+ }
49
+
50
+ .video {
51
+ filter: brightness(0.8);
52
+ }
53
+
54
+ .video-preview {
55
+ width: 100%;
56
+ height: 100%;
57
+ object-fit: contain;
58
+ position: absolute;
59
+ transition: opacity 0.2s ease-in;
60
+ }
61
+
62
+ .fade {
63
+ opacity: 0;
64
+ }
65
+
66
+ @media (hover: hover) and (pointer: fine) {
67
+ .grid-item:hover {
68
+ transform: scale(1.03);
69
+ }
70
+
71
+ .grid-item:hover .img{
72
+ transform: scale(1.08);
73
+ }
74
+ }
75
+
76
+ .play-icon {
77
+ color: white;
78
+ position: absolute;
79
+ right: 3%;
80
+ top: 3%;
81
+ font-size: x-large;
82
+ user-select: none;
83
+ pointer-events: none;
84
+ transition: transform none;
85
+ filter: drop-shadow(0 0 10px #000);
86
+ }
87
+
88
+ .play-icon img {
89
+ width: 42px;
90
+ height: auto;
91
+ }
92
+
93
+ .blink {
94
+ animation: pulse 1s ease-in-out infinite;
95
+ animation-delay: 0.2s;
96
+ }
97
+
98
+ @keyframes pulse {
99
+ 0%, 100% {
100
+ opacity: 0.8;
101
+ }
102
+
103
+ 50% {
104
+ opacity: 0.4;
105
+ }
106
+ }
107
+
108
+ .live-indicator {
109
+ color: white;
110
+ position: absolute;
111
+ right: 3%;
112
+ top: 3%;
113
+ font-size: medium;
114
+ font-weight: 700;
115
+ font-family: monospace;
116
+ text-shadow: #000 0 0 8px;
117
+ user-select: none;
118
+ pointer-events: none;
119
+ transition: transform 0.4s ease;
120
+ opacity: 0.8;
121
+ }
122
+
123
+ #shuffle-btn {
124
+ position: fixed;
125
+ bottom: 40px;
126
+ right: 40px;
127
+ width: 52px;
128
+ height: 52px;
129
+ border-radius: 50%;
130
+ border: none;
131
+
132
+ visibility: hidden;
133
+
134
+ font-size: 1.5rem;
135
+ backdrop-filter: blur(8px);
136
+ transition: all 0.5s ease;
137
+ background-color: transparent;
138
+
139
+ cursor: pointer;
140
+ user-select: none;
141
+ z-index: 10;
142
+
143
+ display: flex;
144
+ justify-content: center;
145
+ align-items: center;
146
+ line-height: 1;
147
+
148
+ -webkit-tap-highlight-color: transparent;
149
+ }
150
+
151
+ #shuffle-btn.active {
152
+ visibility: visible;
153
+ background: rgba(255, 255, 255, 0.3);
154
+ color: #000;
155
+ box-shadow: rgba(255, 255, 255, 0.3) 0px 0px 15px 5px;
156
+ }
157
+
158
+ #shuffle-btn.inactive {
159
+ visibility: visible;
160
+ background: rgba(45, 43, 43, 0.15);
161
+ color: #fff;
162
+ box-shadow: rgba(45, 43, 43, 0.15) 0px 0px 15px 5px;
163
+ }
164
+
165
+ @starting-style {
166
+ .grid-item {
167
+ opacity: 0.2;
168
+ }
169
+ }