├── .vscode └── settings.json ├── .husky └── pre-commit ├── src ├── app │ ├── favicon.ico │ ├── api │ │ ├── config │ │ │ └── route.ts │ │ ├── socket │ │ │ └── route.ts │ │ ├── albums │ │ │ ├── route.ts │ │ │ └── [id] │ │ │ │ └── songs │ │ │ │ └── route.ts │ │ ├── debug │ │ │ ├── stream-test │ │ │ │ └── route.ts │ │ │ ├── route.ts │ │ │ └── jellyfin-lyrics │ │ │ │ └── route.ts │ │ ├── playlists │ │ │ ├── [playlistId] │ │ │ │ └── items │ │ │ │ │ └── route.ts │ │ │ └── route.ts │ │ ├── artists │ │ │ ├── [artistId] │ │ │ │ └── songs │ │ │ │ │ └── route.ts │ │ │ └── route.ts │ │ ├── songs │ │ │ ├── artist │ │ │ │ └── route.ts │ │ │ ├── title │ │ │ │ └── route.ts │ │ │ └── route.ts │ │ ├── lyrics │ │ │ └── [songId] │ │ │ │ └── route.ts │ │ └── stream │ │ │ └── [itemId] │ │ │ └── route.ts │ ├── globals.css │ ├── admin │ │ └── page.tsx │ ├── layout.tsx │ └── page.tsx ├── components │ ├── mobile │ │ ├── search-interface-components │ │ │ ├── LoadingSpinner.tsx │ │ │ ├── index.ts │ │ │ ├── BackButton.tsx │ │ │ ├── LoadMoreIndicator.tsx │ │ │ ├── SearchTabs.tsx │ │ │ ├── EmptyState.tsx │ │ │ ├── PlaylistResults.tsx │ │ │ ├── LoadMoreButton.tsx │ │ │ ├── ArtistResults.tsx │ │ │ ├── AlbumResults.tsx │ │ │ ├── NoMoreResults.tsx │ │ │ └── SongResults.tsx │ │ ├── NavigationTabs.tsx │ │ ├── UserSetup.tsx │ │ └── ConfirmationDialog.tsx │ ├── tv │ │ ├── QRCode.tsx │ │ ├── ApplausePlayer.tsx │ │ ├── NextUpSidebar.tsx │ │ ├── QueuePreview.tsx │ │ └── NextSongSplash.tsx │ └── LyricsIndicator.tsx ├── lib │ ├── config.ts │ └── ratingGenerator.ts ├── contexts │ └── ConfigContext.tsx └── hooks │ ├── useLyrics.ts │ └── useServiceWorker.ts ├── public ├── favicon-16x16.png ├── favicon-32x32.png ├── apple-touch-icon.png ├── icons │ ├── icon-48x48.png │ ├── icon-72x72.png │ ├── icon-96x96.png │ ├── icon-128x128.png │ ├── icon-144x144.png │ ├── icon-152x152.png │ ├── icon-167x167.png │ ├── icon-192x192.png │ ├── icon-256x256.png │ ├── icon-384x384.png │ ├── icon-512x512.png │ └── icon-1024x1024.png ├── sounds │ ├── applause-1.mp3 │ ├── applause-2.mp3 │ ├── applause-3.mp3 │ ├── applause-crowd.mp3 │ ├── applause-crowd-2.mp3 │ └── README.md ├── vercel.svg ├── browserconfig.xml ├── window.svg ├── file.svg ├── globe.svg ├── next.svg └── manifest.json ├── jest.setup.js ├── postcss.config.mjs ├── screenshots ├── mobile-3-manage-queue.png ├── tv-4-next-up-countdown.png ├── mobile-2-add-song-to-queue.png ├── tv-1-autoplay-when-song-added.png ├── tv-2-sing-along-with-lyrics.png ├── tv-3-graded-singing-performance.png └── mobile-1-search-artists-playlists-songs.png ├── .prettierignore ├── .prettierrc ├── next.config.ts ├── jest.config.js ├── eslint.config.mjs ├── tsconfig.json ├── .gitignore ├── .dockerignore ├── cypress ├── fixtures │ ├── queue.json │ ├── albums.json │ ├── artists.json │ ├── playlists.json │ └── songs.json └── support │ ├── component.ts │ └── e2e.ts ├── cypress.config.ts ├── docker-compose.yml ├── lyrics └── sample.lrc ├── .env.example ├── .env.docker.example ├── .env.local.example ├── .github └── workflows │ ├── docker-publish.yml │ └── cypress.yml ├── scripts └── twa-manifest-template.json ├── package.json ├── Dockerfile ├── test-queue-sync.js ├── debug-queue.js ├── __tests__ ├── websocket │ └── connection.test.ts └── components │ ├── ConfirmationDialog.test.tsx │ └── AudioPlayer.test.tsx ├── .kiro └── specs │ └── self-hosted-karaoke │ ├── tasks.md │ └── requirements.md ├── README-DOCKERHUB.md └── GITHUB-ACTIONS-SETUP.md /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | npm run lint 2 | -------------------------------------------------------------------------------- /src/app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/johnpc/karaoke-for-jellyfin/HEAD/src/app/favicon.ico -------------------------------------------------------------------------------- /public/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/johnpc/karaoke-for-jellyfin/HEAD/public/favicon-16x16.png -------------------------------------------------------------------------------- /public/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/johnpc/karaoke-for-jellyfin/HEAD/public/favicon-32x32.png -------------------------------------------------------------------------------- /public/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/johnpc/karaoke-for-jellyfin/HEAD/public/apple-touch-icon.png -------------------------------------------------------------------------------- /public/icons/icon-48x48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/johnpc/karaoke-for-jellyfin/HEAD/public/icons/icon-48x48.png -------------------------------------------------------------------------------- /public/icons/icon-72x72.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/johnpc/karaoke-for-jellyfin/HEAD/public/icons/icon-72x72.png -------------------------------------------------------------------------------- /public/icons/icon-96x96.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/johnpc/karaoke-for-jellyfin/HEAD/public/icons/icon-96x96.png -------------------------------------------------------------------------------- /jest.setup.js: -------------------------------------------------------------------------------- 1 | // Jest setup file 2 | import "@testing-library/jest-dom"; 3 | 4 | // Add any global test setup here 5 | -------------------------------------------------------------------------------- /postcss.config.mjs: -------------------------------------------------------------------------------- 1 | const config = { 2 | plugins: ["@tailwindcss/postcss"], 3 | }; 4 | 5 | export default config; 6 | -------------------------------------------------------------------------------- /public/icons/icon-128x128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/johnpc/karaoke-for-jellyfin/HEAD/public/icons/icon-128x128.png -------------------------------------------------------------------------------- /public/icons/icon-144x144.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/johnpc/karaoke-for-jellyfin/HEAD/public/icons/icon-144x144.png -------------------------------------------------------------------------------- /public/icons/icon-152x152.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/johnpc/karaoke-for-jellyfin/HEAD/public/icons/icon-152x152.png -------------------------------------------------------------------------------- /public/icons/icon-167x167.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/johnpc/karaoke-for-jellyfin/HEAD/public/icons/icon-167x167.png -------------------------------------------------------------------------------- /public/icons/icon-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/johnpc/karaoke-for-jellyfin/HEAD/public/icons/icon-192x192.png -------------------------------------------------------------------------------- /public/icons/icon-256x256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/johnpc/karaoke-for-jellyfin/HEAD/public/icons/icon-256x256.png -------------------------------------------------------------------------------- /public/icons/icon-384x384.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/johnpc/karaoke-for-jellyfin/HEAD/public/icons/icon-384x384.png -------------------------------------------------------------------------------- /public/icons/icon-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/johnpc/karaoke-for-jellyfin/HEAD/public/icons/icon-512x512.png -------------------------------------------------------------------------------- /public/sounds/applause-1.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/johnpc/karaoke-for-jellyfin/HEAD/public/sounds/applause-1.mp3 -------------------------------------------------------------------------------- /public/sounds/applause-2.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/johnpc/karaoke-for-jellyfin/HEAD/public/sounds/applause-2.mp3 -------------------------------------------------------------------------------- /public/sounds/applause-3.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/johnpc/karaoke-for-jellyfin/HEAD/public/sounds/applause-3.mp3 -------------------------------------------------------------------------------- /public/icons/icon-1024x1024.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/johnpc/karaoke-for-jellyfin/HEAD/public/icons/icon-1024x1024.png -------------------------------------------------------------------------------- /public/sounds/applause-crowd.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/johnpc/karaoke-for-jellyfin/HEAD/public/sounds/applause-crowd.mp3 -------------------------------------------------------------------------------- /public/sounds/applause-crowd-2.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/johnpc/karaoke-for-jellyfin/HEAD/public/sounds/applause-crowd-2.mp3 -------------------------------------------------------------------------------- /screenshots/mobile-3-manage-queue.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/johnpc/karaoke-for-jellyfin/HEAD/screenshots/mobile-3-manage-queue.png -------------------------------------------------------------------------------- /screenshots/tv-4-next-up-countdown.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/johnpc/karaoke-for-jellyfin/HEAD/screenshots/tv-4-next-up-countdown.png -------------------------------------------------------------------------------- /public/vercel.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /screenshots/mobile-2-add-song-to-queue.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/johnpc/karaoke-for-jellyfin/HEAD/screenshots/mobile-2-add-song-to-queue.png -------------------------------------------------------------------------------- /screenshots/tv-1-autoplay-when-song-added.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/johnpc/karaoke-for-jellyfin/HEAD/screenshots/tv-1-autoplay-when-song-added.png -------------------------------------------------------------------------------- /screenshots/tv-2-sing-along-with-lyrics.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/johnpc/karaoke-for-jellyfin/HEAD/screenshots/tv-2-sing-along-with-lyrics.png -------------------------------------------------------------------------------- /screenshots/tv-3-graded-singing-performance.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/johnpc/karaoke-for-jellyfin/HEAD/screenshots/tv-3-graded-singing-performance.png -------------------------------------------------------------------------------- /screenshots/mobile-1-search-artists-playlists-songs.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/johnpc/karaoke-for-jellyfin/HEAD/screenshots/mobile-1-search-artists-playlists-songs.png -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | .prettierignore 2 | .dockerignore 3 | Dockerfile 4 | .gitignore 5 | *.svg 6 | *.png 7 | *.jpg 8 | *.lrc 9 | *.ico 10 | *.log 11 | *.xml 12 | *.sh 13 | *.mp3 14 | .env* 15 | .husky/ 16 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": true, 3 | "trailingComma": "es5", 4 | "singleQuote": false, 5 | "printWidth": 80, 6 | "tabWidth": 2, 7 | "useTabs": false, 8 | "bracketSpacing": true, 9 | "arrowParens": "avoid", 10 | "endOfLine": "lf" 11 | } 12 | -------------------------------------------------------------------------------- /public/browserconfig.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | #8B5CF6 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /next.config.ts: -------------------------------------------------------------------------------- 1 | import type { NextConfig } from "next"; 2 | 3 | const nextConfig: NextConfig = { 4 | // Enable standalone output for Docker optimization 5 | output: "standalone", 6 | 7 | // Enable output file tracing for smaller Docker images 8 | outputFileTracingRoot: process.cwd(), 9 | }; 10 | 11 | export default nextConfig; 12 | -------------------------------------------------------------------------------- /public/window.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/file.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/app/api/config/route.ts: -------------------------------------------------------------------------------- 1 | import { NextResponse } from "next/server"; 2 | import { getServerConfig } from "@/lib/config"; 3 | 4 | export async function GET() { 5 | try { 6 | const config = getServerConfig(); 7 | return NextResponse.json(config); 8 | } catch (error) { 9 | console.error("Error getting server config:", error); 10 | return NextResponse.json( 11 | { error: "Failed to get configuration" }, 12 | { status: 500 } 13 | ); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/components/mobile/search-interface-components/LoadingSpinner.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | interface LoadingSpinnerProps { 4 | message: string; 5 | } 6 | 7 | export function LoadingSpinner({ message }: LoadingSpinnerProps) { 8 | return ( 9 |
13 |
14 | {message} 15 |
16 | ); 17 | } 18 | -------------------------------------------------------------------------------- /src/components/mobile/search-interface-components/index.ts: -------------------------------------------------------------------------------- 1 | export { SearchTabs } from "./SearchTabs"; 2 | export { BackButton } from "./BackButton"; 3 | export { LoadingSpinner } from "./LoadingSpinner"; 4 | export { EmptyState } from "./EmptyState"; 5 | export { ArtistResults } from "./ArtistResults"; 6 | export { AlbumResults } from "./AlbumResults"; 7 | export { SongResults } from "./SongResults"; 8 | export { PlaylistResults } from "./PlaylistResults"; 9 | export { LoadMoreIndicator } from "./LoadMoreIndicator"; 10 | export { LoadMoreButton } from "./LoadMoreButton"; 11 | export { NoMoreResults } from "./NoMoreResults"; 12 | export { SearchContent } from "./SearchContent"; 13 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | const nextJest = require("next/jest"); 2 | 3 | const createJestConfig = nextJest({ 4 | // Provide the path to your Next.js app to load next.config.js and .env files 5 | dir: "./", 6 | }); 7 | 8 | // Add any custom config to be passed to Jest 9 | const customJestConfig = { 10 | setupFilesAfterEnv: ["/jest.setup.js"], 11 | testEnvironment: "jsdom", 12 | testMatch: ["**/__tests__/**/*.test.ts", "**/__tests__/**/*.test.tsx"], 13 | moduleNameMapping: { 14 | "^@/(.*)$": "/src/$1", 15 | }, 16 | }; 17 | 18 | // createJestConfig is exported this way to ensure that next/jest can load the Next.js config which is async 19 | module.exports = createJestConfig(customJestConfig); 20 | -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import { dirname } from "path"; 2 | import { fileURLToPath } from "url"; 3 | import { FlatCompat } from "@eslint/eslintrc"; 4 | 5 | const __filename = fileURLToPath(import.meta.url); 6 | const __dirname = dirname(__filename); 7 | 8 | const compat = new FlatCompat({ 9 | baseDirectory: __dirname, 10 | }); 11 | 12 | const eslintConfig = [ 13 | ...compat.extends("next/core-web-vitals", "next/typescript"), 14 | { 15 | rules: { 16 | "@typescript-eslint/no-unsafe-function-type": "off", 17 | "@typescript-eslint/no-unused-vars": "off", 18 | "@typescript-eslint/no-explicit-any": "off", 19 | "react-hooks/exhaustive-deps": "off", 20 | }, 21 | }, 22 | ]; 23 | 24 | export default eslintConfig; 25 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2017", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": true, 8 | "noEmit": true, 9 | "esModuleInterop": true, 10 | "module": "esnext", 11 | "moduleResolution": "bundler", 12 | "resolveJsonModule": true, 13 | "isolatedModules": true, 14 | "jsx": "preserve", 15 | "incremental": true, 16 | "plugins": [ 17 | { 18 | "name": "next" 19 | } 20 | ], 21 | "paths": { 22 | "@/*": ["./src/*"] 23 | } 24 | }, 25 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], 26 | "exclude": ["node_modules", "cypress/**/*"] 27 | } 28 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.* 7 | .yarn/* 8 | !.yarn/patches 9 | !.yarn/plugins 10 | !.yarn/releases 11 | !.yarn/versions 12 | 13 | # testing 14 | /coverage 15 | 16 | # next.js 17 | /.next/ 18 | /out/ 19 | 20 | # production 21 | /build 22 | 23 | # misc 24 | .DS_Store 25 | *.pem 26 | 27 | # debug 28 | npm-debug.log* 29 | yarn-debug.log* 30 | yarn-error.log* 31 | .pnpm-debug.log* 32 | 33 | # env files (can opt-in for committing if needed) 34 | .env* 35 | !.env.*example 36 | # vercel 37 | .vercel 38 | 39 | # typescript 40 | *.tsbuildinfo 41 | next-env.d.ts 42 | *.log 43 | *.apk 44 | android-tv-build/ 45 | cypress/screenshots -------------------------------------------------------------------------------- /src/components/mobile/search-interface-components/BackButton.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { ArrowLeftIcon } from "@heroicons/react/24/outline"; 4 | 5 | interface BackButtonProps { 6 | onBack: () => void; 7 | label?: string; 8 | } 9 | 10 | export function BackButton({ 11 | onBack, 12 | label = "Back to Search", 13 | }: BackButtonProps) { 14 | return ( 15 |
16 | 24 |
25 | ); 26 | } 27 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | # Dependencies 2 | node_modules 3 | npm-debug.log* 4 | yarn-debug.log* 5 | yarn-error.log* 6 | 7 | # Next.js build output 8 | .next 9 | out 10 | 11 | # Environment variables 12 | .env 13 | .env.local 14 | .env.development.local 15 | .env.test.local 16 | .env.production.local 17 | 18 | # Testing 19 | coverage 20 | __tests__ 21 | *.test.js 22 | *.test.ts 23 | *.test.tsx 24 | jest.config.js 25 | jest.setup.js 26 | 27 | # Development files 28 | .vscode 29 | .kiro 30 | dev.log 31 | *.log 32 | 33 | # Git 34 | .git 35 | .gitignore 36 | 37 | # Docker 38 | Dockerfile 39 | .dockerignore 40 | docker-compose.yml 41 | 42 | # Documentation 43 | README.md 44 | *.md 45 | 46 | # Misc 47 | .DS_Store 48 | *.tgz 49 | *.tar.gz 50 | 51 | # IDE 52 | .idea 53 | *.swp 54 | *.swo 55 | 56 | # Temporary files 57 | tmp 58 | temp 59 | -------------------------------------------------------------------------------- /cypress/fixtures/queue.json: -------------------------------------------------------------------------------- 1 | { 2 | "success": true, 3 | "data": { 4 | "queue": [], 5 | "currentSong": { 6 | "id": "queue-item-1", 7 | "mediaItem": { 8 | "id": "jellyfin_song-1", 9 | "title": "Hey Jude", 10 | "artist": "The Beatles", 11 | "album": "The Beatles 1967-1970", 12 | "duration": 431, 13 | "jellyfinId": "song-1", 14 | "streamUrl": "/api/stream/song-1", 15 | "hasLyrics": true 16 | }, 17 | "addedBy": "Test User", 18 | "addedAt": "2024-07-30T21:00:00.000Z", 19 | "status": "playing" 20 | }, 21 | "playbackState": { 22 | "isPlaying": true, 23 | "currentTime": 0, 24 | "volume": 1.0 25 | }, 26 | "stats": { 27 | "totalSongs": 0, 28 | "totalDuration": 0 29 | } 30 | }, 31 | "timestamp": "2024-01-01T00:00:00.000Z" 32 | } 33 | -------------------------------------------------------------------------------- /src/lib/config.ts: -------------------------------------------------------------------------------- 1 | // Server-side configuration 2 | export interface AppConfig { 3 | autoplayDelay: number; 4 | queueAutoplayDelay: number; 5 | controlsAutoHideDelay: number; 6 | timeUpdateInterval: number; 7 | ratingAnimationDuration: number; 8 | nextSongDuration: number; 9 | } 10 | 11 | export function getServerConfig(): AppConfig { 12 | return { 13 | autoplayDelay: parseInt(process.env.AUTOPLAY_DELAY || "500"), 14 | queueAutoplayDelay: parseInt(process.env.QUEUE_AUTOPLAY_DELAY || "1000"), 15 | controlsAutoHideDelay: parseInt( 16 | process.env.CONTROLS_AUTO_HIDE_DELAY || "10000" 17 | ), 18 | timeUpdateInterval: parseInt(process.env.TIME_UPDATE_INTERVAL || "2000"), 19 | ratingAnimationDuration: parseInt( 20 | process.env.RATING_ANIMATION_DURATION || "15000" 21 | ), 22 | nextSongDuration: parseInt(process.env.NEXT_SONG_DURATION || "15000"), 23 | }; 24 | } 25 | -------------------------------------------------------------------------------- /public/sounds/README.md: -------------------------------------------------------------------------------- 1 | # Applause Sound Files 2 | 3 | This directory contains applause sound effects that play between karaoke songs. 4 | 5 | ## Required Files 6 | 7 | The following files should be added to this directory: 8 | 9 | - `applause-1.mp3` - General applause sound 10 | - `applause-2.mp3` - Enthusiastic applause 11 | - `applause-3.mp3` - Crowd cheering 12 | - `applause-crowd.mp3` - Large crowd applause 13 | 14 | ## File Requirements 15 | 16 | - Format: MP3 or WAV 17 | - Duration: 3-5 seconds recommended 18 | - Volume: Normalized to prevent sudden loud sounds 19 | - Quality: 44.1kHz, 16-bit minimum 20 | 21 | ## Sources 22 | 23 | You can find royalty-free applause sounds from: 24 | 25 | - Freesound.org 26 | - Zapsplat.com 27 | - Adobe Stock Audio 28 | - YouTube Audio Library 29 | 30 | ## Fallback 31 | 32 | If no sound files are present, the ApplausePlayer component will generate a synthetic applause-like sound using the Web Audio API. 33 | -------------------------------------------------------------------------------- /cypress.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "cypress"; 2 | 3 | export default defineConfig({ 4 | e2e: { 5 | baseUrl: "http://localhost:3000", 6 | viewportWidth: 1280, 7 | viewportHeight: 720, 8 | video: false, 9 | screenshotOnRunFailure: true, 10 | defaultCommandTimeout: 10000, 11 | requestTimeout: 10000, 12 | responseTimeout: 10000, 13 | setupNodeEvents(on, config) { 14 | // implement node event listeners here 15 | }, 16 | env: { 17 | // Test environment variables 18 | JELLYFIN_SERVER_URL: "http://localhost:8096", 19 | TEST_USERNAME: "cypress-test-user", 20 | }, 21 | specPattern: "cypress/e2e/**/*.cy.{js,jsx,ts,tsx}", 22 | supportFile: "cypress/support/e2e.ts", 23 | }, 24 | component: { 25 | devServer: { 26 | framework: "next", 27 | bundler: "webpack", 28 | }, 29 | specPattern: "cypress/component/**/*.cy.{js,jsx,ts,tsx}", 30 | supportFile: "cypress/support/component.ts", 31 | }, 32 | }); 33 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3.8" 2 | services: 3 | karaoke-app: 4 | image: mrorbitman/karaoke-for-jellyfin:latest 5 | ports: 6 | - 3967:3000 7 | environment: 8 | # Jellyfin Configuration 9 | - JELLYFIN_SERVER_URL=${JELLYFIN_SERVER_URL:-http://host.docker.internal:8096} 10 | - JELLYFIN_API_KEY=${JELLYFIN_API_KEY} 11 | - JELLYFIN_USERNAME=${JELLYFIN_USERNAME} 12 | 13 | # TV Display Timing Configuration (in milliseconds) 14 | - RATING_ANIMATION_DURATION=${RATING_ANIMATION_DURATION:-15000} 15 | - NEXT_SONG_DURATION=${NEXT_SONG_DURATION:-15000} 16 | - CONTROLS_AUTO_HIDE_DELAY=${CONTROLS_AUTO_HIDE_DELAY:-10000} 17 | - AUTOPLAY_DELAY=${AUTOPLAY_DELAY:-500} 18 | - QUEUE_AUTOPLAY_DELAY=${QUEUE_AUTOPLAY_DELAY:-1000} 19 | - TIME_UPDATE_INTERVAL=${TIME_UPDATE_INTERVAL:-2000} 20 | 21 | # System Configuration 22 | - NODE_ENV=production 23 | - PORT=3000 24 | - HOSTNAME=0.0.0.0 25 | restart: always 26 | networks: {} 27 | -------------------------------------------------------------------------------- /public/globe.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /lyrics/sample.lrc: -------------------------------------------------------------------------------- 1 | [ti:Sample Karaoke Song] 2 | [ar:Test Artist] 3 | [al:Test Album] 4 | [length:03:30] 5 | [offset:0] 6 | 7 | [00:00.00]♪ Instrumental Intro ♪ 8 | [00:10.50]Welcome to our karaoke night 9 | [00:15.00]Sing along with all your might 10 | [00:20.25]Every voice deserves to shine 11 | [00:25.75]In this moment, yours and mine 12 | [00:30.00]♪ Chorus ♪ 13 | [00:32.50]Sing it loud, sing it proud 14 | [00:37.25]Let your voice rise above the crowd 15 | [00:42.00]Every note and every word 16 | [00:46.75]Makes sure that you are heard 17 | [00:50.00]♪ Verse 2 ♪ 18 | [00:52.50]Music brings us all together 19 | [00:57.25]Through any kind of weather 20 | [01:02.00]Harmony in every tone 21 | [01:06.75]No one has to sing alone 22 | [01:10.00]♪ Instrumental Break ♪ 23 | [01:30.00]♪ Final Chorus ♪ 24 | [01:32.50]Sing it loud, sing it proud 25 | [01:37.25]Let your voice rise above the crowd 26 | [01:42.00]Every note and every word 27 | [01:46.75]Makes sure that you are heard 28 | [01:50.00]♪ Outro ♪ 29 | [01:55.00]Thank you for this karaoke night 30 | [02:00.00]♪ End ♪ -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | # Jellyfin Configuration 2 | JELLYFIN_SERVER_URL=http(s)://your.server.url 3 | JELLYFIN_API_KEY=apikey 4 | JELLYFIN_USERNAME=YourAdminUsername 5 | 6 | # Android TV APK Build Configuration 7 | # KARAOKE_SERVER_URL: The URL where your Karaoke server will be accessible from Android TV devices 8 | # This should be the IP address and port where your karaoke server runs on your local network 9 | # The Android TV app will connect to this URL to access the karaoke interface 10 | # Examples: 11 | # - Local network: http://192.168.1.100:3967 (replace with your server's IP) 12 | # - Docker host: http://192.168.1.50:3967 (if running in Docker) 13 | # - Different port: http://192.168.1.100:8080 (if using a different port) 14 | # Note: Use your server's actual IP address, not localhost, so Android TV can reach it 15 | KARAOKE_SERVER_URL=http://192.168.1.100:3967 16 | 17 | # TV Display Timing Configuration (in milliseconds) 18 | RATING_ANIMATION_DURATION=15000 19 | NEXT_SONG_DURATION=15000 20 | CONTROLS_AUTO_HIDE_DELAY=10000 21 | AUTOPLAY_DELAY=500 22 | QUEUE_AUTOPLAY_DELAY=1000 23 | TIME_UPDATE_INTERVAL=2000 -------------------------------------------------------------------------------- /cypress/fixtures/albums.json: -------------------------------------------------------------------------------- 1 | { 2 | "success": true, 3 | "data": [ 4 | { 5 | "id": "jellyfin_album_album-1", 6 | "name": "Abbey Road", 7 | "artist": "The Beatles", 8 | "jellyfinId": "album-1", 9 | "imageUrl": "/api/items/album-1/images/primary", 10 | "trackCount": 17 11 | }, 12 | { 13 | "id": "jellyfin_album_album-2", 14 | "name": "A Night at the Opera", 15 | "artist": "Queen", 16 | "jellyfinId": "album-2", 17 | "imageUrl": "/api/items/album-2/images/primary", 18 | "trackCount": 12 19 | }, 20 | { 21 | "id": "jellyfin_album_album-3", 22 | "name": "Led Zeppelin IV", 23 | "artist": "Led Zeppelin", 24 | "jellyfinId": "album-3", 25 | "imageUrl": "/api/items/album-3/images/primary", 26 | "trackCount": 8 27 | }, 28 | { 29 | "id": "jellyfin_album_album-4", 30 | "name": "The Dark Side of the Moon", 31 | "artist": "Pink Floyd", 32 | "jellyfinId": "album-4", 33 | "imageUrl": "/api/items/album-4/images/primary", 34 | "trackCount": 10 35 | } 36 | ], 37 | "timestamp": "2024-01-01T00:00:00.000Z" 38 | } 39 | -------------------------------------------------------------------------------- /cypress/fixtures/artists.json: -------------------------------------------------------------------------------- 1 | { 2 | "success": true, 3 | "data": [ 4 | { 5 | "id": "jellyfin_artist_1", 6 | "name": "The Beatles", 7 | "jellyfinId": "artist-1", 8 | "imageUrl": "https://example.com/beatles.jpg" 9 | }, 10 | { 11 | "id": "jellyfin_artist_2", 12 | "name": "Queen", 13 | "jellyfinId": "artist-2", 14 | "imageUrl": "https://example.com/queen.jpg" 15 | }, 16 | { 17 | "id": "jellyfin_artist_3", 18 | "name": "Led Zeppelin", 19 | "jellyfinId": "artist-3", 20 | "imageUrl": "https://example.com/ledzeppelin.jpg" 21 | }, 22 | { 23 | "id": "jellyfin_artist_4", 24 | "name": "Pink Floyd", 25 | "jellyfinId": "artist-4", 26 | "imageUrl": "https://example.com/pinkfloyd.jpg" 27 | }, 28 | { 29 | "id": "jellyfin_artist_5", 30 | "name": "The Rolling Stones", 31 | "jellyfinId": "artist-5", 32 | "imageUrl": "https://example.com/rollingstones.jpg" 33 | } 34 | ], 35 | "pagination": { 36 | "page": 1, 37 | "limit": 50, 38 | "total": 5, 39 | "hasNext": false, 40 | "hasPrev": false 41 | }, 42 | "timestamp": "2025-07-30T22:24:28.079Z" 43 | } 44 | -------------------------------------------------------------------------------- /cypress/support/component.ts: -------------------------------------------------------------------------------- 1 | // *********************************************************** 2 | // This example support/component.ts is processed and 3 | // loaded automatically before your test files. 4 | // 5 | // This is a great place to put global configuration and 6 | // behavior that modifies Cypress. 7 | // 8 | // You can change the location of this file or turn off 9 | // automatically serving support files with the 10 | // 'supportFile' configuration option. 11 | // 12 | // You can read more here: 13 | // https://on.cypress.io/configuration 14 | // *********************************************************** 15 | 16 | // Import commands.js using ES2015 syntax: 17 | import "./commands"; 18 | 19 | // Alternatively you can use CommonJS syntax: 20 | // require('./commands') 21 | 22 | import { mount } from "cypress/react18"; 23 | 24 | // Augment the Cypress namespace to include type definitions for 25 | // your custom command. 26 | // Alternatively, can be defined in cypress/support/component.d.ts 27 | // with a at the top of your spec. 28 | declare global { 29 | namespace Cypress { 30 | interface Chainable { 31 | mount: typeof mount; 32 | } 33 | } 34 | } 35 | 36 | Cypress.Commands.add("mount", mount); 37 | 38 | // Example use: 39 | // cy.mount() 40 | -------------------------------------------------------------------------------- /src/app/globals.css: -------------------------------------------------------------------------------- 1 | @import "tailwindcss"; 2 | 3 | :root { 4 | --background: #ffffff; 5 | --foreground: #171717; 6 | } 7 | 8 | @theme inline { 9 | --color-background: var(--background); 10 | --color-foreground: var(--foreground); 11 | --font-sans: var(--font-geist-sans); 12 | --font-mono: var(--font-geist-mono); 13 | } 14 | 15 | @media (prefers-color-scheme: dark) { 16 | :root { 17 | --background: #0a0a0a; 18 | --foreground: #ededed; 19 | } 20 | } 21 | 22 | body { 23 | background: var(--background); 24 | color: var(--foreground); 25 | font-family: Arial, Helvetica, sans-serif; 26 | } 27 | 28 | /* Enhanced input styling for better text contrast */ 29 | input[type="text"], 30 | input[type="search"], 31 | input[type="email"], 32 | input[type="password"] { 33 | color: #111827 !important; /* Dark gray text */ 34 | } 35 | 36 | input[type="text"]::placeholder, 37 | input[type="search"]::placeholder, 38 | input[type="email"]::placeholder, 39 | input[type="password"]::placeholder { 40 | color: #6b7280 !important; /* Medium gray placeholder */ 41 | opacity: 1; 42 | } 43 | 44 | /* Ensure focus states maintain good contrast */ 45 | input[type="text"]:focus, 46 | input[type="search"]:focus, 47 | input[type="email"]:focus, 48 | input[type="password"]:focus { 49 | color: #111827 !important; 50 | } 51 | -------------------------------------------------------------------------------- /.env.docker.example: -------------------------------------------------------------------------------- 1 | # Docker Compose Environment Variables 2 | # Copy this file to .env and customize for your deployment 3 | 4 | # Jellyfin Configuration (REQUIRED) 5 | JELLYFIN_SERVER_URL=http://host.docker.internal:8096 6 | JELLYFIN_API_KEY=your_jellyfin_api_key_here 7 | JELLYFIN_USERNAME=your_jellyfin_username_here 8 | 9 | # TV Display Timing Configuration (in milliseconds) 10 | # Uncomment and customize these values to change karaoke timing behavior 11 | 12 | # Rating screen display duration (default: 15 seconds) 13 | #RATING_ANIMATION_DURATION=15000 14 | 15 | # Next song splash screen duration (default: 15 seconds) 16 | #NEXT_SONG_DURATION=15000 17 | 18 | # Auto-hide TV controls after inactivity (default: 10 seconds) 19 | #CONTROLS_AUTO_HIDE_DELAY=10000 20 | 21 | # Initial autoplay delay (default: 500ms) 22 | #AUTOPLAY_DELAY=500 23 | 24 | # Queue autoplay delay (default: 1 second) 25 | #QUEUE_AUTOPLAY_DELAY=1000 26 | 27 | # Time update sync interval (default: 2 seconds) 28 | #TIME_UPDATE_INTERVAL=2000 29 | 30 | # Example Configurations: 31 | # 32 | # Fast-Paced Party Setup: 33 | # RATING_ANIMATION_DURATION=8000 34 | # NEXT_SONG_DURATION=5000 35 | # CONTROLS_AUTO_HIDE_DELAY=5000 36 | # 37 | # Relaxed Home Setup: 38 | # RATING_ANIMATION_DURATION=20000 39 | # NEXT_SONG_DURATION=10000 40 | # CONTROLS_AUTO_HIDE_DELAY=15000 41 | -------------------------------------------------------------------------------- /src/components/mobile/search-interface-components/LoadMoreIndicator.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | interface LoadMoreIndicatorProps { 4 | isLoadingMore: boolean; 5 | activeTab: "search" | "playlist"; 6 | artistViewMode: "artists" | "songs"; 7 | playlistViewMode: "playlists" | "songs"; 8 | } 9 | 10 | function getLoadingMessage( 11 | activeTab: "search" | "playlist", 12 | artistViewMode: "artists" | "songs", 13 | playlistViewMode: "playlists" | "songs" 14 | ): string { 15 | if (activeTab === "search" && artistViewMode === "artists") { 16 | return "Loading more results..."; 17 | } 18 | if (activeTab === "playlist" && playlistViewMode === "playlists") { 19 | return "Loading more playlists..."; 20 | } 21 | return "Loading more songs..."; 22 | } 23 | 24 | export function LoadMoreIndicator({ 25 | isLoadingMore, 26 | activeTab, 27 | artistViewMode, 28 | playlistViewMode, 29 | }: LoadMoreIndicatorProps) { 30 | if (!isLoadingMore) return null; 31 | 32 | return ( 33 |
34 |
35 | 36 | {getLoadingMessage(activeTab, artistViewMode, playlistViewMode)} 37 | 38 |
39 | ); 40 | } 41 | -------------------------------------------------------------------------------- /cypress/fixtures/playlists.json: -------------------------------------------------------------------------------- 1 | { 2 | "success": true, 3 | "data": [ 4 | { 5 | "id": "jellyfin_playlist_playlist-1", 6 | "name": "Classic Rock Hits", 7 | "jellyfinId": "playlist-1", 8 | "imageUrl": "/api/items/playlist-1/images/primary", 9 | "trackCount": 25, 10 | "description": "The best classic rock songs of all time" 11 | }, 12 | { 13 | "id": "jellyfin_playlist_playlist-2", 14 | "name": "Karaoke Favorites", 15 | "jellyfinId": "playlist-2", 16 | "imageUrl": "/api/items/playlist-2/images/primary", 17 | "trackCount": 50, 18 | "description": "Popular karaoke songs everyone loves to sing" 19 | }, 20 | { 21 | "id": "jellyfin_playlist_playlist-3", 22 | "name": "80s Greatest Hits", 23 | "jellyfinId": "playlist-3", 24 | "imageUrl": "/api/items/playlist-3/images/primary", 25 | "trackCount": 30, 26 | "description": "The biggest hits from the 1980s" 27 | }, 28 | { 29 | "id": "jellyfin_playlist_playlist-4", 30 | "name": "Pop Anthems", 31 | "jellyfinId": "playlist-4", 32 | "imageUrl": "/api/items/playlist-4/images/primary", 33 | "trackCount": 40, 34 | "description": "Modern pop anthems that get everyone singing" 35 | } 36 | ], 37 | "timestamp": "2024-01-01T00:00:00.000Z" 38 | } 39 | -------------------------------------------------------------------------------- /public/next.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/components/mobile/search-interface-components/SearchTabs.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | interface SearchTabsProps { 4 | activeTab: "search" | "playlist"; 5 | onTabChange: (tab: "search" | "playlist") => void; 6 | } 7 | 8 | export function SearchTabs({ activeTab, onTabChange }: SearchTabsProps) { 9 | return ( 10 |
11 |
12 | 22 | 33 |
34 |
35 | ); 36 | } 37 | -------------------------------------------------------------------------------- /.env.local.example: -------------------------------------------------------------------------------- 1 | # Jellyfin Configuration 2 | JELLYFIN_SERVER_URL=http://localhost:8096 3 | JELLYFIN_API_KEY=your_jellyfin_api_key_here 4 | JELLYFIN_USERNAME=your_jellyfin_username_here 5 | 6 | # Android TV APK Build Configuration 7 | # KARAOKE_SERVER_URL: The URL where your Karaoke server will be accessible from Android TV devices 8 | # This should be the IP address and port where your karaoke server runs on your local network 9 | # The Android TV app will connect to this URL to access the karaoke interface 10 | # Examples: 11 | # - Local network: http://192.168.1.100:3967 (replace with your server's IP) 12 | # - Docker host: http://192.168.1.50:3967 (if running in Docker) 13 | # - Different port: http://192.168.1.100:8080 (if using a different port) 14 | # - Reverse proxy: https://your.hostname.com (if using reverse proxy) 15 | # Note: Use your server's actual IP address, not localhost, so Android TV can reach it 16 | KARAOKE_SERVER_URL=http://192.168.1.100:3967 17 | 18 | # Playlist Filtering (optional) 19 | # Only show playlists whose names match this regex pattern (case-insensitive) 20 | # Example: PLAYLIST_FILTER_REGEX=^(Karaoke|Sing).* 21 | # PLAYLIST_FILTER_REGEX= 22 | 23 | # TV Display Timing Configuration (in milliseconds) 24 | RATING_ANIMATION_DURATION=15000 25 | NEXT_SONG_DURATION=15000 26 | CONTROLS_AUTO_HIDE_DELAY=10000 27 | AUTOPLAY_DELAY=500 28 | QUEUE_AUTOPLAY_DELAY=1000 29 | TIME_UPDATE_INTERVAL=2000 30 | PLAYLIST_FILTER_REGEX=karaoke 31 | -------------------------------------------------------------------------------- /src/components/tv/QRCode.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useEffect, useState } from "react"; 4 | import Image from "next/image"; 5 | import QRCodeLib from "qrcode"; 6 | 7 | interface QRCodeProps { 8 | url: string; 9 | size?: number; 10 | className?: string; 11 | } 12 | 13 | export function QRCode({ url, size = 80, className = "" }: QRCodeProps) { 14 | const [qrCodeDataUrl, setQrCodeDataUrl] = useState(""); 15 | 16 | useEffect(() => { 17 | const generateQRCode = async () => { 18 | try { 19 | const qrCodeUrl = await QRCodeLib.toDataURL(url, { 20 | width: size, 21 | margin: 1, 22 | color: { 23 | dark: "#FFFFFF", // White QR code 24 | light: "#00000000", // Transparent background 25 | }, 26 | errorCorrectionLevel: "M", 27 | }); 28 | setQrCodeDataUrl(qrCodeUrl); 29 | } catch (error) { 30 | console.error("Error generating QR code:", error); 31 | } 32 | }; 33 | 34 | generateQRCode(); 35 | }, [url, size]); 36 | 37 | if (!qrCodeDataUrl) { 38 | return null; 39 | } 40 | 41 | return ( 42 |
43 | {`QR 51 |
52 | ); 53 | } 54 | -------------------------------------------------------------------------------- /src/app/api/socket/route.ts: -------------------------------------------------------------------------------- 1 | // WebSocket API route for Next.js 2 | import { NextRequest } from "next/server"; 3 | import { Server as HTTPServer } from "http"; 4 | import { initializeWebSocket } from "@/lib/websocket"; 5 | 6 | export async function GET(request: NextRequest) { 7 | // In Next.js, we need to handle WebSocket upgrade differently 8 | // This endpoint provides information about WebSocket connection 9 | const protocol = request.headers.get("x-forwarded-proto") || "http"; 10 | const host = request.headers.get("host") || "localhost:3000"; 11 | 12 | return Response.json({ 13 | message: "WebSocket server is running", 14 | websocketUrl: `${protocol === "https" ? "wss" : "ws"}://${host}`, 15 | endpoints: { 16 | "join-session": "Join a karaoke session", 17 | "add-song": "Add a song to the queue", 18 | "remove-song": "Remove a song from the queue", 19 | "reorder-queue": "Reorder the song queue", 20 | "playback-control": "Control playback (play/pause/volume/seek)", 21 | "skip-song": "Skip the current song", 22 | "user-heartbeat": "Keep connection alive", 23 | }, 24 | events: { 25 | "session-updated": "Session state changed", 26 | "queue-updated": "Queue was modified", 27 | "song-started": "New song started playing", 28 | "song-ended": "Song finished playing", 29 | "user-joined": "User joined the session", 30 | "user-left": "User left the session", 31 | "playback-state-changed": "Playback state updated", 32 | "lyrics-sync": "Lyrics synchronization data", 33 | error: "Error occurred", 34 | }, 35 | }); 36 | } 37 | -------------------------------------------------------------------------------- /cypress/fixtures/songs.json: -------------------------------------------------------------------------------- 1 | { 2 | "success": true, 3 | "data": [ 4 | { 5 | "id": "jellyfin_song-1", 6 | "title": "Hey Jude", 7 | "artist": "The Beatles", 8 | "album": "The Beatles 1967-1970", 9 | "duration": 431, 10 | "jellyfinId": "song-1", 11 | "streamUrl": "/api/stream/song-1", 12 | "hasLyrics": true 13 | }, 14 | { 15 | "id": "jellyfin_song-2", 16 | "title": "Bohemian Rhapsody", 17 | "artist": "Queen", 18 | "album": "A Night at the Opera", 19 | "duration": 355, 20 | "jellyfinId": "song-2", 21 | "streamUrl": "/api/stream/song-2", 22 | "hasLyrics": true 23 | }, 24 | { 25 | "id": "jellyfin_song-3", 26 | "title": "Stairway to Heaven", 27 | "artist": "Led Zeppelin", 28 | "album": "Led Zeppelin IV", 29 | "duration": 482, 30 | "jellyfinId": "song-3", 31 | "streamUrl": "/api/stream/song-3", 32 | "hasLyrics": true 33 | }, 34 | { 35 | "id": "jellyfin_song-4", 36 | "title": "Wish You Were Here", 37 | "artist": "Pink Floyd", 38 | "album": "Wish You Were Here", 39 | "duration": 334, 40 | "jellyfinId": "song-4", 41 | "streamUrl": "/api/stream/song-4", 42 | "hasLyrics": false 43 | }, 44 | { 45 | "id": "jellyfin_song-5", 46 | "title": "Paint It Black", 47 | "artist": "The Rolling Stones", 48 | "album": "Aftermath", 49 | "duration": 222, 50 | "jellyfinId": "song-5", 51 | "streamUrl": "/api/stream/song-5", 52 | "hasLyrics": true 53 | } 54 | ], 55 | "timestamp": "2024-01-01T00:00:00.000Z" 56 | } 57 | -------------------------------------------------------------------------------- /src/app/api/albums/route.ts: -------------------------------------------------------------------------------- 1 | import { NextRequest, NextResponse } from "next/server"; 2 | import { getJellyfinService } from "@/services/jellyfin"; 3 | import { Album } from "@/types"; 4 | 5 | export async function GET(request: NextRequest) { 6 | try { 7 | const { searchParams } = new URL(request.url); 8 | const query = searchParams.get("q") || ""; 9 | const limit = parseInt(searchParams.get("limit") || "50"); 10 | const startIndex = parseInt(searchParams.get("startIndex") || "0"); 11 | 12 | console.log( 13 | `Albums API: query="${query}", limit=${limit}, startIndex=${startIndex}` 14 | ); 15 | 16 | const jellyfinService = getJellyfinService(); 17 | 18 | let albums: Album[]; 19 | if (query.trim()) { 20 | // Search for albums by name 21 | albums = await jellyfinService.searchAlbums(query, limit, startIndex); 22 | } else { 23 | // For now, return empty array when no query is provided 24 | // In the future, we could implement browsing all albums 25 | albums = []; 26 | } 27 | 28 | console.log(`Albums API: returning ${albums.length} albums`); 29 | 30 | return NextResponse.json({ 31 | success: true, 32 | data: albums, 33 | timestamp: new Date(), 34 | }); 35 | } catch (error) { 36 | console.error("Albums API error:", error); 37 | return NextResponse.json( 38 | { 39 | success: false, 40 | error: { 41 | code: "ALBUMS_SEARCH_FAILED", 42 | message: 43 | error instanceof Error ? error.message : "Failed to search albums", 44 | }, 45 | timestamp: new Date(), 46 | }, 47 | { status: 500 } 48 | ); 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /cypress/support/e2e.ts: -------------------------------------------------------------------------------- 1 | // *********************************************************** 2 | // This example support/e2e.ts is processed and 3 | // loaded automatically before your test files. 4 | // 5 | // This is a great place to put global configuration and 6 | // behavior that modifies Cypress. 7 | // 8 | // You can change the location of this file or turn off 9 | // automatically serving support files with the 10 | // 'supportFile' configuration option. 11 | // 12 | // You can read more here: 13 | // https://on.cypress.io/configuration 14 | // *********************************************************** 15 | 16 | // Import commands.js using ES2015 syntax: 17 | import "./commands"; 18 | import "cypress-real-events"; 19 | 20 | // Alternatively you can use CommonJS syntax: 21 | // require('./commands') 22 | 23 | // Hide fetch/XHR requests from command log to reduce noise 24 | const app = window.top; 25 | if (!app.document.head.querySelector("[data-hide-command-log-request]")) { 26 | const style = app.document.createElement("style"); 27 | style.innerHTML = 28 | ".command-name-request, .command-name-xhr { display: none }"; 29 | style.setAttribute("data-hide-command-log-request", ""); 30 | app.document.head.appendChild(style); 31 | } 32 | 33 | // Global error handling 34 | Cypress.on("uncaught:exception", (err, runnable) => { 35 | // Ignore WebSocket connection errors during tests 36 | if (err.message.includes("WebSocket") || err.message.includes("socket.io")) { 37 | return false; 38 | } 39 | // Don't fail tests on unhandled promise rejections from third-party code 40 | if (err.message.includes("ResizeObserver loop limit exceeded")) { 41 | return false; 42 | } 43 | return true; 44 | }); 45 | -------------------------------------------------------------------------------- /src/app/api/albums/[id]/songs/route.ts: -------------------------------------------------------------------------------- 1 | import { NextRequest, NextResponse } from "next/server"; 2 | import { getJellyfinService } from "@/services/jellyfin"; 3 | 4 | interface RouteParams { 5 | params: Promise<{ id: string }>; 6 | } 7 | 8 | export async function GET(request: NextRequest, { params }: RouteParams) { 9 | try { 10 | const { searchParams } = new URL(request.url); 11 | const limit = parseInt(searchParams.get("limit") || "50"); 12 | const startIndex = parseInt(searchParams.get("startIndex") || "0"); 13 | 14 | const resolvedParams = await params; 15 | const albumId = resolvedParams.id; 16 | 17 | // Extract the Jellyfin ID from our prefixed ID 18 | const jellyfinId = albumId.startsWith("jellyfin_album_") 19 | ? albumId.replace("jellyfin_album_", "") 20 | : albumId; 21 | 22 | console.log( 23 | `Album songs API: albumId="${albumId}", jellyfinId="${jellyfinId}", limit=${limit}, startIndex=${startIndex}` 24 | ); 25 | 26 | const jellyfinService = getJellyfinService(); 27 | const songs = await jellyfinService.getSongsByAlbumId( 28 | jellyfinId, 29 | limit, 30 | startIndex 31 | ); 32 | 33 | console.log( 34 | `Album songs API: returning ${songs.length} songs for album ${albumId}` 35 | ); 36 | 37 | return NextResponse.json({ 38 | success: true, 39 | data: songs, 40 | timestamp: new Date(), 41 | }); 42 | } catch (error) { 43 | console.error("Album songs API error:", error); 44 | return NextResponse.json( 45 | { 46 | success: false, 47 | error: { 48 | code: "ALBUM_SONGS_FETCH_FAILED", 49 | message: 50 | error instanceof Error 51 | ? error.message 52 | : "Failed to get songs by album", 53 | }, 54 | timestamp: new Date(), 55 | }, 56 | { status: 500 } 57 | ); 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /.github/workflows/docker-publish.yml: -------------------------------------------------------------------------------- 1 | name: Build and Push Docker Image 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | - master 8 | tags: 9 | - "v*" 10 | pull_request: 11 | branches: 12 | - main 13 | - master 14 | workflow_dispatch: 15 | 16 | env: 17 | REGISTRY: docker.io 18 | IMAGE_NAME: mrorbitman/karaoke-for-jellyfin 19 | 20 | jobs: 21 | build-and-push: 22 | runs-on: ubuntu-latest 23 | permissions: 24 | contents: read 25 | packages: write 26 | 27 | steps: 28 | - name: Checkout repository 29 | uses: actions/checkout@v4 30 | 31 | - name: Set up Docker Buildx 32 | uses: docker/setup-buildx-action@v3 33 | 34 | - name: Log in to Docker Hub 35 | if: github.event_name != 'pull_request' 36 | uses: docker/login-action@v3 37 | with: 38 | registry: ${{ env.REGISTRY }} 39 | username: ${{ secrets.DOCKERHUB_USERNAME }} 40 | password: ${{ secrets.DOCKERHUB_TOKEN }} 41 | 42 | - name: Extract metadata 43 | id: meta 44 | uses: docker/metadata-action@v5 45 | with: 46 | images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} 47 | tags: | 48 | # set latest tag for default branch 49 | type=ref,event=branch 50 | type=ref,event=pr 51 | type=semver,pattern={{version}} 52 | type=semver,pattern={{major}}.{{minor}} 53 | type=semver,pattern={{major}} 54 | type=raw,value=latest,enable={{is_default_branch}} 55 | 56 | - name: Build and push Docker image 57 | uses: docker/build-push-action@v5 58 | with: 59 | context: . 60 | platforms: linux/amd64,linux/arm64 61 | push: ${{ github.event_name != 'pull_request' }} 62 | tags: ${{ steps.meta.outputs.tags }} 63 | labels: ${{ steps.meta.outputs.labels }} 64 | cache-from: type=gha 65 | cache-to: type=gha,mode=max 66 | -------------------------------------------------------------------------------- /src/components/mobile/NavigationTabs.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { 4 | MagnifyingGlassIcon, 5 | QueueListIcon, 6 | } from "@heroicons/react/24/outline"; 7 | 8 | interface NavigationTabsProps { 9 | activeTab: "search" | "queue"; 10 | onTabChange: (tab: "search" | "queue") => void; 11 | queueCount: number; 12 | } 13 | 14 | export function NavigationTabs({ 15 | activeTab, 16 | onTabChange, 17 | queueCount, 18 | }: NavigationTabsProps) { 19 | return ( 20 |
21 |
22 | 34 | 35 | 52 |
53 |
54 | ); 55 | } 56 | -------------------------------------------------------------------------------- /src/components/tv/ApplausePlayer.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useEffect, useRef } from "react"; 4 | 5 | interface ApplausePlayerProps { 6 | isPlaying: boolean; 7 | volume?: number; // 0-100 8 | } 9 | 10 | export function ApplausePlayer({ 11 | isPlaying, 12 | volume = 70, 13 | }: ApplausePlayerProps) { 14 | const audioRef = useRef(null); 15 | 16 | // Array of applause sound variations 17 | const applauseSounds = [ 18 | "/sounds/applause-1.mp3", 19 | "/sounds/applause-2.mp3", 20 | "/sounds/applause-3.mp3", 21 | "/sounds/applause-crowd.mp3", 22 | "/sounds/applause-crowd-2.mp3", 23 | ]; 24 | 25 | useEffect(() => { 26 | if (isPlaying && audioRef.current) { 27 | console.log("🎵 Applause triggered - playing audio"); 28 | 29 | // Randomly select an applause sound 30 | const randomSound = 31 | applauseSounds[Math.floor(Math.random() * applauseSounds.length)]; 32 | console.log("🎵 Selected applause sound:", randomSound); 33 | 34 | // Use actual applause audio files from public/sounds directory 35 | audioRef.current.src = randomSound; 36 | audioRef.current.volume = volume / 100; 37 | 38 | // Play applause - this should work because it's triggered by a socket event 39 | // which is considered user interaction by the browser 40 | audioRef.current 41 | .play() 42 | .then(() => { 43 | console.log("🎵 Applause playing successfully via socket trigger"); 44 | }) 45 | .catch(error => { 46 | console.log("🎵 Applause playback failed:", error.message); 47 | // This is expected on first load before user interaction 48 | }); 49 | } else if (isPlaying) { 50 | console.log("🎵 Applause triggered but no audio ref available"); 51 | } 52 | }, [isPlaying, volume]); 53 | 54 | return ( 55 |