├── logo.png ├── icons ├── icon.webp ├── favicon.ico ├── icon-96x96.png ├── icon-180x180.png └── icon-192x192.png ├── manifest.json ├── imagebuild ├── Dockerfile └── default.conf ├── README.md ├── .github └── workflows │ └── generate-docker-image.yml ├── .gitignore ├── index.html ├── style.css ├── app.js └── LICENSE /logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/andygruber/songseeker/HEAD/logo.png -------------------------------------------------------------------------------- /icons/icon.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/andygruber/songseeker/HEAD/icons/icon.webp -------------------------------------------------------------------------------- /icons/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/andygruber/songseeker/HEAD/icons/favicon.ico -------------------------------------------------------------------------------- /icons/icon-96x96.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/andygruber/songseeker/HEAD/icons/icon-96x96.png -------------------------------------------------------------------------------- /icons/icon-180x180.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/andygruber/songseeker/HEAD/icons/icon-180x180.png -------------------------------------------------------------------------------- /icons/icon-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/andygruber/songseeker/HEAD/icons/icon-192x192.png -------------------------------------------------------------------------------- /manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "SongSeeker", 3 | "icons": [ 4 | { 5 | "src": "\/icon-96x96.png", 6 | "sizes": "96x96", 7 | "type": "image\/png", 8 | "density": "2.0" 9 | }, 10 | { 11 | "src": "\/icon-192x192.png", 12 | "sizes": "192x192", 13 | "type": "image\/png", 14 | "density": "4.0" 15 | } 16 | ] 17 | } 18 | -------------------------------------------------------------------------------- /imagebuild/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM alpine:latest AS unzipper 2 | RUN apk add --no-cache wget unzip 3 | RUN mkdir /tmp/playlists && \ 4 | wget -O /tmp/hitster-youtube-playlists.zip https://github.com/andygruber/songseeker-hitster-playlists/releases/latest/download/hitster-youtube-playlists.zip && \ 5 | unzip /tmp/hitster-youtube-playlists.zip -d /tmp/playlists/ 6 | 7 | FROM nginx 8 | COPY imagebuild/default.conf /etc/nginx/conf.d/default.conf 9 | 10 | RUN rm -rf /usr/share/nginx/html/* 11 | COPY *.js *.json *.csv *.html *.css *.png icons/* /usr/share/nginx/html/ 12 | RUN mkdir -m 755 -p /usr/share/nginx/html/playlists 13 | COPY --chmod=444 --from=unzipper /tmp/playlists/ /usr/share/nginx/html/playlists/ 14 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # songseeker 2 | A Music Guessing game called SongSeeker, available as Website. 3 | 4 | Just provide the contents on some https page and you should be good to go (camera access via http is often not allowed). 5 | 6 | Example is available at https://songseeker.grub3r.io/ 7 | 8 | Inspired from https://hitstergame.com/de-de/ and https://rockster.brettspiel.digital/ 9 | 10 | Youtube links from the original Hitster cards get be downloaded from https://github.com/andygruber/songseeker-hitster-playlists 11 | 12 | qr-scanner from https://github.com/nimiq/qr-scanner 13 | 14 | ### Generating QR-Code Gamecards 15 | 16 | https://github.com/andygruber/songseeker-card-generator 17 | 18 | ### Docker image build 19 | 20 | ``` 21 | docker build -t songseeker -f .\imagebuild\Dockerfile . 22 | ``` -------------------------------------------------------------------------------- /imagebuild/default.conf: -------------------------------------------------------------------------------- 1 | server { 2 | listen 80; 3 | server_name localhost; 4 | 5 | #access_log /var/log/nginx/host.access.log main; 6 | 7 | location / { 8 | root /usr/share/nginx/html; 9 | index index.html index.htm; 10 | } 11 | 12 | #error_page 404 /404.html; 13 | 14 | # redirect server error pages to the static page /50x.html 15 | # 16 | error_page 500 502 503 504 /50x.html; 17 | location = /50x.html { 18 | root /usr/share/nginx/html; 19 | } 20 | 21 | # proxy the PHP scripts to Apache listening on 127.0.0.1:80 22 | # 23 | #location ~ \.php$ { 24 | # proxy_pass http://127.0.0.1; 25 | #} 26 | 27 | # pass the PHP scripts to FastCGI server listening on 127.0.0.1:9000 28 | # 29 | #location ~ \.php$ { 30 | # root html; 31 | # fastcgi_pass 127.0.0.1:9000; 32 | # fastcgi_index index.php; 33 | # fastcgi_param SCRIPT_FILENAME /scripts$fastcgi_script_name; 34 | # include fastcgi_params; 35 | #} 36 | 37 | # deny access to .htaccess files, if Apache's document root 38 | # concurs with nginx's one 39 | # 40 | #location ~ /\.ht { 41 | # deny all; 42 | #} 43 | location ~* /\.(html|js|css)$ { 44 | expires 0; 45 | add_header Cache-Control "public, no-cache, no-store, must-revalidate, proxy-revalidate"; 46 | add_header Pragma "no-cache"; 47 | 48 | } 49 | 50 | } 51 | -------------------------------------------------------------------------------- /.github/workflows/generate-docker-image.yml: -------------------------------------------------------------------------------- 1 | name: Generate and upload SongSeeker image 2 | 3 | on: 4 | push: 5 | branches: [ "main", "release/*", "develop", "develop/*" ] 6 | pull_request: 7 | branches: [ "main", "release/*", "develop", "develop/*" ] 8 | release: 9 | types: [published] 10 | workflow_dispatch: 11 | 12 | jobs: 13 | get_environment: 14 | runs-on: ubuntu-latest 15 | 16 | steps: 17 | - name: Define the used environment 18 | id: env_check 19 | run: | 20 | ENV_NAME=staging 21 | if [[ "${{ github.event_name }}" == "release" ]]; then 22 | ENV_NAME=production 23 | elif [[ "${{ github.event_name }}" == "pull_request" ]]; then 24 | ENV_NAME=staging 25 | else 26 | ENV_NAME=staging 27 | fi 28 | 29 | echo "Chosen environment: ${ENV_NAME}" 30 | 31 | echo "env_name=${ENV_NAME}" >> $GITHUB_OUTPUT 32 | 33 | outputs: 34 | env_name: ${{ steps.env_check.outputs.env_name }} 35 | 36 | generate-songseeker-image: 37 | needs: [get_environment] 38 | runs-on: ubuntu-latest 39 | environment: 40 | name: ${{ needs.get_environment.outputs.env_name }} 41 | 42 | steps: 43 | - name: Set up Git repository 44 | uses: actions/checkout@v4 45 | 46 | - name: Login to DockerHub 47 | uses: docker/login-action@v3 48 | with: 49 | username: ${{ secrets.DOCKER_USER }} 50 | password: ${{ secrets.DOCKER_PASSWORD }} 51 | 52 | - name: Docker build preparation image 53 | run: | 54 | docker buildx create --name container --driver=docker-container 55 | docker buildx build --platform linux/amd64,linux/arm64 -f imagebuild/Dockerfile -t ${{ vars.DOCKER_IMAGE }} --builder container --push . 56 | docker buildx rm container 57 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | .pnpm-debug.log* 9 | 10 | # Diagnostic reports (https://nodejs.org/api/report.html) 11 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 12 | 13 | # Runtime data 14 | pids 15 | *.pid 16 | *.seed 17 | *.pid.lock 18 | 19 | # Directory for instrumented libs generated by jscoverage/JSCover 20 | lib-cov 21 | 22 | # Coverage directory used by tools like istanbul 23 | coverage 24 | *.lcov 25 | 26 | # nyc test coverage 27 | .nyc_output 28 | 29 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 30 | .grunt 31 | 32 | # Bower dependency directory (https://bower.io/) 33 | bower_components 34 | 35 | # node-waf configuration 36 | .lock-wscript 37 | 38 | # Compiled binary addons (https://nodejs.org/api/addons.html) 39 | build/Release 40 | 41 | # Dependency directories 42 | node_modules/ 43 | jspm_packages/ 44 | 45 | # Snowpack dependency directory (https://snowpack.dev/) 46 | web_modules/ 47 | 48 | # TypeScript cache 49 | *.tsbuildinfo 50 | 51 | # Optional npm cache directory 52 | .npm 53 | 54 | # Optional eslint cache 55 | .eslintcache 56 | 57 | # Optional stylelint cache 58 | .stylelintcache 59 | 60 | # Microbundle cache 61 | .rpt2_cache/ 62 | .rts2_cache_cjs/ 63 | .rts2_cache_es/ 64 | .rts2_cache_umd/ 65 | 66 | # Optional REPL history 67 | .node_repl_history 68 | 69 | # Output of 'npm pack' 70 | *.tgz 71 | 72 | # Yarn Integrity file 73 | .yarn-integrity 74 | 75 | # dotenv environment variable files 76 | .env 77 | .env.development.local 78 | .env.test.local 79 | .env.production.local 80 | .env.local 81 | 82 | # parcel-bundler cache (https://parceljs.org/) 83 | .cache 84 | .parcel-cache 85 | 86 | # Next.js build output 87 | .next 88 | out 89 | 90 | # Nuxt.js build / generate output 91 | .nuxt 92 | dist 93 | 94 | # Gatsby files 95 | .cache/ 96 | # Comment in the public line in if your project uses Gatsby and not Next.js 97 | # https://nextjs.org/blog/next-9-1#public-directory-support 98 | # public 99 | 100 | # vuepress build output 101 | .vuepress/dist 102 | 103 | # vuepress v2.x temp and cache directory 104 | .temp 105 | .cache 106 | 107 | # Docusaurus cache and generated files 108 | .docusaurus 109 | 110 | # Serverless directories 111 | .serverless/ 112 | 113 | # FuseBox cache 114 | .fusebox/ 115 | 116 | # DynamoDB Local files 117 | .dynamodb/ 118 | 119 | # TernJS port file 120 | .tern-port 121 | 122 | # Stores VSCode versions used for testing VSCode extensions 123 | .vscode-test 124 | 125 | # yarn v2 126 | .yarn/cache 127 | .yarn/unplugged 128 | .yarn/build-state.yml 129 | .yarn/install-state.gz 130 | .pnp.* 131 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | SongSeeker 6 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 |

SongSeeker

32 | 33 |
34 | 35 | 36 |
37 | 38 | 39 |
40 | 41 | 42 |
Video ID:
43 |
Video Title:
44 |
45 | Video Duration: 46 |
47 |
Start Time:
48 | 49 |
50 |
51 |
52 | View the SongSeeker project on GitHub 53 |
54 |
55 | 56 |
57 | 58 | 59 |
60 | 96 | 97 |
98 | 99 | 100 | 101 | 102 | 103 | -------------------------------------------------------------------------------- /style.css: -------------------------------------------------------------------------------- 1 | /* Modern Music Guessing Game Style */ 2 | 3 | /* Reset & Base */ 4 | 5 | * { 6 | margin: 0; 7 | padding: 0; 8 | box-sizing: border-box; 9 | } 10 | 11 | body { 12 | font-family: "Montserrat", "Segoe UI", Tahoma, Geneva, Verdana, sans-serif; 13 | background: linear-gradient(135deg, #1e3c72 0%, #2a5298 100%); 14 | color: #fff; 15 | min-height: 100vh; 16 | text-align: center; 17 | padding: 0; 18 | display: flex; 19 | flex-direction: column; 20 | align-items: center; 21 | } 22 | 23 | h2 { 24 | font-size: 2.8rem; 25 | font-weight: 800; 26 | letter-spacing: 2px; 27 | margin-top: 2.5rem; 28 | margin-bottom: 1.2rem; 29 | background: linear-gradient(90deg, #ff512f 0%, #dd2476 100%); 30 | -webkit-background-clip: text; 31 | -webkit-text-fill-color: transparent; 32 | } 33 | 34 | a { 35 | color: #ff512f; 36 | text-decoration: none; 37 | font-weight: 600; 38 | transition: color 0.2s; 39 | } 40 | 41 | a:hover { 42 | color: #fff; 43 | text-shadow: 0 0 0.5rem #ff512f; 44 | } 45 | 46 | hr { 47 | border: none; 48 | height: 0.15rem; 49 | background: linear-gradient(90deg, #ff512f 0%, #dd2476 100%); 50 | margin: 2rem 0; 51 | border-radius: 1rem; 52 | } 53 | 54 | .button, 55 | .button_startscan, 56 | .button_startstop { 57 | display: inline-block; 58 | background: linear-gradient(90deg, #ff512f 0%, #dd2476 100%); 59 | color: #fff; 60 | font-size: 1.1rem; 61 | font-weight: 700; 62 | padding: 0.6rem 1.5rem; 63 | margin: 0.7rem 0.5rem; 64 | border: none; 65 | border-radius: 1.5rem; 66 | box-shadow: 0 0.25rem 1rem rgba(221, 36, 118, 0.15); 67 | cursor: pointer; 68 | transition: background 0.3s, transform 0.2s; 69 | } 70 | 71 | .button:hover, 72 | .button_startscan:hover, 73 | .button_startstop:hover { 74 | background: linear-gradient(90deg, #dd2476 0%, #ff512f 100%); 75 | transform: scale(1.07); 76 | } 77 | 78 | .button_wrapper { 79 | display: flex; 80 | flex-direction: column; 81 | align-items: center; 82 | } 83 | 84 | .button_startscan { 85 | width: 8rem; 86 | height: 2.5rem; 87 | font-size: 1.1rem; 88 | padding: 0.5rem 1rem; 89 | } 90 | 91 | .button_startstop { 92 | width: 6rem; 93 | height: 2.2rem; 94 | font-size: 1rem; 95 | padding: 0.4rem 0.8rem; 96 | display: flex; 97 | align-items: center; 98 | justify-content: center; 99 | } 100 | 101 | .button_startstop::before { 102 | content: ""; 103 | display: inline-block; 104 | width: 1.2rem; 105 | height: 1.2rem; 106 | margin-right: 0.5rem; 107 | background: url('data:image/svg+xml;utf8,') 108 | no-repeat center center; 109 | background-size: contain; 110 | } 111 | 112 | #qr-reader { 113 | position: relative; 114 | width: 100%; 115 | max-width: 25rem; 116 | margin: 2rem auto 1.2rem auto; 117 | background: rgba(255, 255, 255, 0.07); 118 | border-radius: 1.2rem; 119 | box-shadow: 0 0.4rem 1.5rem rgba(30, 60, 114, 0.15); 120 | padding: 1.2rem 0; 121 | } 122 | 123 | #qr-video { 124 | width: 90%; 125 | border-radius: 0.8rem; 126 | box-shadow: 0 0.15rem 0.5rem rgba(30, 60, 114, 0.15); 127 | } 128 | 129 | #cancelScanButton { 130 | position: absolute; 131 | top: 0.6rem; 132 | right: 0.6rem; 133 | display: none; 134 | background: linear-gradient(90deg, #e95d5d 0%, #ff512f 100%); 135 | color: #fff; 136 | font-weight: 700; 137 | border-radius: 1.2rem; 138 | padding: 0.5rem 1.2rem; 139 | z-index: 4; 140 | border: none; 141 | box-shadow: 0 0.15rem 0.5rem rgba(233, 93, 93, 0.15); 142 | } 143 | 144 | #videoid, 145 | #videotitle, 146 | #videoduration, 147 | #videostart { 148 | display: none; 149 | margin: 0.6rem auto; 150 | font-size: 1.1rem; 151 | background: rgba(255, 255, 255, 0.08); 152 | border-radius: 0.6rem; 153 | padding: 0.5rem 0; 154 | width: 80%; 155 | color: #fff; 156 | } 157 | 158 | .settings_div { 159 | width: 100%; 160 | max-width: 32rem; 161 | margin: 2rem auto 0 auto; 162 | background: rgba(255, 255, 255, 0.09); 163 | border-radius: 1.1rem; 164 | box-shadow: 0 0.15rem 0.8rem rgba(30, 60, 114, 0.12); 165 | padding: 1.5rem 0 1rem 0; 166 | position: static; 167 | display: none; 168 | flex-direction: column; 169 | } 170 | 171 | .text-block { 172 | position: relative; 173 | margin-top: 1.1rem; 174 | margin-bottom: 1.1rem; 175 | padding: 1rem 0.8rem 0.8rem 0.8rem; 176 | text-align: center; 177 | border: 1px solid #ff512f; 178 | border-radius: 0.8rem; 179 | background: rgba(255, 255, 255, 0.07); 180 | box-shadow: 0 0.15rem 0.5rem rgba(221, 36, 118, 0.07); 181 | } 182 | 183 | .text-block .heading { 184 | position: absolute; 185 | top: -1.1rem; 186 | left: 50%; 187 | transform: translateX(-50%); 188 | padding: 0 0.7rem; 189 | font-size: 1.1rem; 190 | font-weight: 700; 191 | background: linear-gradient(90deg, #ff512f 0%, #dd2476 100%); 192 | color: #fff; 193 | border-radius: 0.6rem; 194 | box-shadow: 0 0.15rem 0.5rem rgba(221, 36, 118, 0.07); 195 | } 196 | 197 | input[type="checkbox"], 198 | input[type="number"] { 199 | accent-color: #ff512f; 200 | margin: 0 0.5rem; 201 | font-size: 1.1rem; 202 | border-radius: 0.4rem; 203 | border: none; 204 | } 205 | 206 | label { 207 | font-size: 1.1rem; 208 | color: #fff; 209 | margin: 0 0.4rem; 210 | } 211 | 212 | #show_hide_settings { 213 | display: flex; 214 | flex-direction: row; 215 | } 216 | 217 | #playback-duration { 218 | width: 3.5rem; 219 | font-size: 1.1rem; 220 | padding: 0.3rem 0.6rem; 221 | border-radius: 0.5rem; 222 | border: 1px solid #ff512f; 223 | background: rgba(255, 255, 255, 0.12); 224 | color: #333; 225 | } 226 | 227 | #cookielist { 228 | display: none; 229 | } 230 | 231 | /* Animation for correct/incorrect guess feedback */ 232 | 233 | @keyframes correct { 234 | 0% { 235 | box-shadow: 0 0 0 0 #4caf50; 236 | } 237 | 70% { 238 | box-shadow: 0 0 0 10px rgba(76, 175, 80, 0); 239 | } 240 | 100% { 241 | box-shadow: 0 0 0 0 rgba(76, 175, 80, 0); 242 | } 243 | } 244 | 245 | @keyframes incorrect { 246 | 0% { 247 | box-shadow: 0 0 0 0 #e95d5d; 248 | } 249 | 70% { 250 | box-shadow: 0 0 0 10px rgba(233, 93, 93, 0); 251 | } 252 | 100% { 253 | box-shadow: 0 0 0 0 rgba(233, 93, 93, 0); 254 | } 255 | } 256 | 257 | .correct-guess { 258 | animation: correct 0.7s; 259 | } 260 | 261 | .incorrect-guess { 262 | animation: incorrect 0.7s; 263 | } 264 | 265 | @media screen and (max-width: 768px) { 266 | h2 { 267 | font-size: 2rem; 268 | } 269 | 270 | .settings_div { 271 | max-width: 98vw; 272 | padding: 0.7rem 0 0.5rem 0; 273 | } 274 | 275 | .button_startscan { 276 | width: 60vw; 277 | height: 2rem; 278 | font-size: 1rem; 279 | } 280 | 281 | .button_startstop { 282 | width: 40vw; 283 | height: 1.7rem; 284 | font-size: 0.95rem; 285 | } 286 | 287 | #qr-reader { 288 | max-width: 98vw; 289 | padding: 0.6rem 0; 290 | } 291 | } 292 | -------------------------------------------------------------------------------- /app.js: -------------------------------------------------------------------------------- 1 | import QrScanner from "https://unpkg.com/qr-scanner/qr-scanner.min.js"; 2 | 3 | let player; // Define player globally 4 | let playbackTimer; // hold the timer reference 5 | let playbackDuration = 30; // Default playback duration 6 | let qrScanner; 7 | let csvCache = {}; 8 | let lastDecodedText = ""; // Store the last decoded text 9 | let currentStartTime = 0; 10 | 11 | // Function to detect iOS devices 12 | function isIOS() { 13 | return /iPad|iPhone|iPod/.test(navigator.userAgent) && !window.MSStream; 14 | } 15 | 16 | document.addEventListener('DOMContentLoaded', function () { 17 | 18 | const video = document.getElementById('qr-video'); 19 | const resultContainer = document.getElementById("qr-reader-results"); 20 | 21 | // If the user is on an iOS device, uncheck and disable the autoplay checkbox 22 | if (isIOS()) { 23 | var autoplayCheckbox = document.getElementById('autoplay'); 24 | autoplayCheckbox.checked = false; 25 | autoplayCheckbox.disabled = true; 26 | } 27 | 28 | qrScanner = new QrScanner(video, result => { 29 | console.log('decoded qr code:', result); 30 | if (result.data !== lastDecodedText) { 31 | lastDecodedText = result.data; // Update the last decoded text 32 | handleScannedLink(result.data); 33 | } 34 | }, { 35 | highlightScanRegion: true, 36 | highlightCodeOutline: true, 37 | } 38 | ); 39 | 40 | } 41 | ); 42 | 43 | // Function to determine the type of link and act accordingly 44 | async function handleScannedLink(decodedText) { 45 | let youtubeURL = ""; 46 | if (isYoutubeLink(decodedText)) { 47 | youtubeURL = decodedText; 48 | } else if (isHitsterLink(decodedText)) { 49 | const hitsterData = parseHitsterUrl(decodedText); 50 | if (hitsterData) { 51 | console.log("Hitster data:", hitsterData.id, hitsterData.lang); 52 | try { 53 | const csvContent = await getCachedCsv(`/playlists/hitster-${hitsterData.lang}.csv`); 54 | const youtubeLink = lookupYoutubeLink(hitsterData.id, csvContent); 55 | if (youtubeLink) { 56 | // Handle YouTube link obtained from the CSV 57 | console.log(`YouTube Link from CSV: ${youtubeLink}`); 58 | youtubeURL = youtubeLink; 59 | // Example: player.cueVideoById(parseYoutubeLink(youtubeLink).videoId); 60 | } 61 | } catch (error) { 62 | console.error("Failed to fetch CSV:", error); 63 | } 64 | } 65 | else { 66 | console.log("Invalid Hitster URL:", decodedText); 67 | } 68 | } else if (isRockster(decodedText)){ 69 | try { 70 | const urlObj = new URL(decodedText); // Create URL object 71 | const ytCode = urlObj.searchParams.get("yt"); // Extract 'yt' parameter 72 | 73 | if (ytCode) { 74 | youtubeURL = `https://www.youtube.com/watch?v=${ytCode}`; 75 | } else { 76 | console.error("Rockster link is missing the 'yt' parameter:", decodedText); 77 | } 78 | } catch (error) { 79 | console.error("Invalid Rockster URL:", decodedText); 80 | } 81 | } 82 | 83 | console.log(`YouTube Video URL: ${youtubeURL}`); 84 | 85 | const youtubeLinkData = parseYoutubeLink(youtubeURL); 86 | if (youtubeLinkData) { 87 | qrScanner.stop(); // Stop scanning after a result is found 88 | document.getElementById('qr-reader').style.display = 'none'; // Hide the scanner after successful scan 89 | document.getElementById('cancelScanButton').style.display = 'none'; // Hide the cancel-button 90 | lastDecodedText = ""; // Reset the last decoded text 91 | 92 | document.getElementById('video-id').textContent = youtubeLinkData.videoId; 93 | 94 | console.log(youtubeLinkData.videoId); 95 | currentStartTime = youtubeLinkData.startTime || 0; 96 | player.cueVideoById(youtubeLinkData.videoId, currentStartTime); 97 | 98 | } 99 | 100 | } 101 | 102 | function isHitsterLink(url) { 103 | // Regular expression to match with or without "http://" or "https://" 104 | const regex = /^(?:http:\/\/|https:\/\/)?(www\.hitstergame|app\.hitsternordics)\.com\/.+/; 105 | return regex.test(url); 106 | } 107 | 108 | // Example implementation for isYoutubeLink 109 | function isYoutubeLink(url) { 110 | return url.startsWith("https://www.youtube.com") || url.startsWith("https://youtu.be") || url.startsWith("https://music.youtube.com/"); 111 | } 112 | function isRockster(url){ 113 | return url.startsWith("https://rockster.brettspiel.digital") 114 | } 115 | // Example implementation for parseHitsterUrl 116 | function parseHitsterUrl(url) { 117 | const regex = /^(?:http:\/\/|https:\/\/)?www\.hitstergame\.com\/(.+?)\/(\d+)$/; 118 | const match = url.match(regex); 119 | if (match) { 120 | // Hitster URL is in the format: https://www.hitstergame.com/{lang}/{id} 121 | // lang can be things like "en", "de", "pt", etc., but also "de/aaaa0007" 122 | const processedLang = match[1].replace(/\//g, "-"); 123 | return { lang: processedLang, id: match[2] }; 124 | } 125 | const regex_nordics = /^(?:http:\/\/|https:\/\/)?app.hitster(nordics).com\/resources\/songs\/(\d+)$/; 126 | const match_nordics = url.match(regex_nordics); 127 | if (match_nordics) { 128 | // Hitster URL can also be in the format: https://app.hitsternordics.com/resources/songs/{id} 129 | return { lang: match_nordics[1], id: match_nordics[2] }; 130 | } 131 | return null; 132 | } 133 | 134 | // Looks up the YouTube link in the CSV content based on the ID 135 | function lookupYoutubeLink(id, csvContent) { 136 | const headers = csvContent[0]; // Get the headers from the CSV content 137 | const cardIndex = headers.indexOf('Card#'); 138 | const urlIndex = headers.indexOf('URL'); 139 | 140 | const targetId = parseInt(id, 10); // Convert the incoming ID to an integer 141 | const lines = csvContent.slice(1); // Exclude the first row (headers) from the lines 142 | 143 | if (cardIndex === -1 || urlIndex === -1) { 144 | throw new Error('Card# or URL column not found'); 145 | } 146 | 147 | for (let row of lines) { 148 | const csvId = parseInt(row[cardIndex], 10); 149 | if (csvId === targetId) { 150 | return row[urlIndex].trim(); // Return the YouTube link 151 | } 152 | } 153 | return null; // If no matching ID is found 154 | 155 | } 156 | 157 | // Could also use external library, but for simplicity, we'll define it here 158 | function parseCSV(text) { 159 | const lines = text.split('\n'); 160 | return lines.map(line => { 161 | const result = []; 162 | let startValueIdx = 0; 163 | let inQuotes = false; 164 | for (let i = 0; i < line.length; i++) { 165 | if (line[i] === '"' && line[i-1] !== '\\') { 166 | inQuotes = !inQuotes; 167 | } else if (line[i] === ',' && !inQuotes) { 168 | result.push(line.substring(startValueIdx, i).trim().replace(/^"(.*)"$/, '$1')); 169 | startValueIdx = i + 1; 170 | } 171 | } 172 | result.push(line.substring(startValueIdx).trim().replace(/^"(.*)"$/, '$1')); // Push the last value 173 | return result; 174 | }); 175 | } 176 | 177 | async function getCachedCsv(url) { 178 | if (!csvCache[url]) { // Check if the URL is not in the cache 179 | console.log(`URL not cached, fetching CSV from URL: ${url}`); 180 | const response = await fetch(url); 181 | const data = await response.text(); 182 | csvCache[url] = parseCSV(data); // Cache the parsed CSV data using the URL as a key 183 | } 184 | return csvCache[url]; // Return the cached data for the URL 185 | } 186 | 187 | function parseYoutubeLink(url) { 188 | // First, ensure that the URL is decoded (handles encoded URLs) 189 | url = decodeURIComponent(url); 190 | 191 | const regex = /^https?:\/\/(www\.youtube\.com\/watch\?v=|youtu\.be\/|music\.youtube\.com\/watch\?v=)(.{11})(.*)/; 192 | const match = url.match(regex); 193 | if (match) { 194 | const queryParams = new URLSearchParams(match[3]); // Correctly capture and parse the query string part of the URL 195 | const videoId = match[2]; 196 | let startTime = queryParams.get('start') || queryParams.get('t'); 197 | const endTime = queryParams.get('end'); 198 | 199 | document.getElementById('video-start').textContent = startTime; 200 | // Normalize and parse 't' and 'start' parameters 201 | startTime = normalizeTimeParameter(startTime); 202 | const parsedEndTime = normalizeTimeParameter(endTime); 203 | 204 | return { videoId, startTime, endTime: parsedEndTime }; 205 | } 206 | return null; 207 | } 208 | 209 | function normalizeTimeParameter(timeValue) { 210 | if (!timeValue) return null; // Return null if timeValue is falsy 211 | 212 | // Handle time formats (e.g., 't=1m15s' or '75s') 213 | let seconds = 0; 214 | if (timeValue.endsWith('s')) { 215 | seconds = parseInt(timeValue, 10); 216 | } else { 217 | // Additional parsing can be added here for 'm', 'h' formats if needed 218 | seconds = parseInt(timeValue, 10); 219 | } 220 | 221 | return isNaN(seconds) ? null : seconds; 222 | } 223 | 224 | // This function creates an