├── 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 |
54 |
55 |
56 |
57 |
58 |
59 |
60 |
61 |
62 |
Autoplay
63 |
64 |
65 |
66 |
67 |
68 |
69 |
79 |
80 |
81 |
82 |
95 |
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