4159d32f858c18db2bd7b7d2cc2df6a801af1ddc

Author
TheEdgeOfClaw <openclaw@theedgeofrage.com>
Committer
TheEdgeOfRage <git@theedgeofrage.com>
Date

Message

Add resolution picker modal for video downloads

Diff

This diff is truncated to protect this page.

  1diff --git a/assets/index.js b/assets/index.js
  2index aed890cdaf123fb44a475a9614133521bd10639e..2425c7604404a1fcdd58afa2556f157146d00ffb 100644
  3--- a/assets/index.js
  4+++ b/assets/index.js
  5@@ -2,14 +2,6 @@ document.addEventListener("DOMContentLoaded", function() {
  6 	const searchInput = document.getElementById("video-search");
  7 	if (!searchInput) return;
  8 
  9-	document.querySelectorAll('button[data-url]').forEach(function(btn) {
 10-		btn.addEventListener('click', function() {
 11-			const url = this.dataset.url;
 12-			const filename = this.dataset.filename;
 13-			downloadVideo(url, filename);
 14-		});
 15-	});
 16-
 17 	function performSearch() {
 18 		const videoCards = Array.from(document.querySelectorAll(".video-card"));
 19 		const query = searchInput.value.trim();
 20@@ -46,6 +38,36 @@ document.addEventListener("DOMContentLoaded", function() {
 21 	}
 22 });
 23 
 24+document.addEventListener('click', function(e) {
 25+	const btn = e.target.closest('button[data-url]');
 26+	if (!btn) return;
 27+	downloadVideo(btn.dataset.url, btn.dataset.filename);
 28+});
 29+
 30+var currentVideoID = '';
 31+
 32+function openResolutionModal(button) {
 33+	currentVideoID = button.dataset.videoId;
 34+	document.getElementById('resolution-modal-title').textContent = button.dataset.videoTitle;
 35+	new bootstrap.Modal(document.getElementById('resolution-modal')).show();
 36+}
 37+
 38+document.addEventListener('click', function(e) {
 39+	const btn = e.target.closest('.resolution-btn');
 40+	if (!btn || !currentVideoID) return;
 41+	const formData = new FormData();
 42+	formData.append('format', btn.dataset.height);
 43+	fetch('/videos/' + currentVideoID + '/download', {
 44+		method: 'POST',
 45+		body: formData,
 46+	}).then(function(resp) {
 47+		if (resp.ok) {
 48+			bootstrap.Modal.getInstance(document.getElementById('resolution-modal')).hide();
 49+			location.reload();
 50+		}
 51+	});
 52+});
 53+
 54 function addVideoHandler(event) {
 55 	if (event.detail.successful) {
 56 		bootstrap.Modal.getInstance(
 57diff --git a/go.mod b/go.mod
 58index b3efde3cd256dac5e866f7b5832c6f69d1aaf994..c31dd57bdce235b2e3cfce9c752a744fd5c3cff7 100644
 59--- a/go.mod
 60+++ b/go.mod
 61@@ -3,7 +3,7 @@ module github.com/TheEdgeOfRage/ytrssil-api
 62 go 1.25
 63 
 64 require (
 65-	github.com/a-h/templ v0.3.977
 66+	github.com/a-h/templ v0.3.1001
 67 	github.com/dustin/go-humanize v1.0.1
 68 	github.com/gin-gonic/gin v1.11.0
 69 	github.com/jackc/pgx/v5 v5.8.0
 70diff --git a/go.sum b/go.sum
 71index a9cad87ad8beafede3c72d577107bed1bc87ee92..963daa9a46793316c2d32d7a6fb090a5d31f0e50 100644
 72--- a/go.sum
 73+++ b/go.sum
 74@@ -1,5 +1,5 @@
 75-github.com/a-h/templ v0.3.977 h1:kiKAPXTZE2Iaf8JbtM21r54A8bCNsncrfnokZZSrSDg=
 76-github.com/a-h/templ v0.3.977/go.mod h1:oCZcnKRf5jjsGpf2yELzQfodLphd2mwecwG4Crk5HBo=
 77+github.com/a-h/templ v0.3.1001 h1:yHDTgexACdJttyiyamcTHXr2QkIeVF1MukLy44EAhMY=
 78+github.com/a-h/templ v0.3.1001/go.mod h1:oCZcnKRf5jjsGpf2yELzQfodLphd2mwecwG4Crk5HBo=
 79 github.com/bytedance/sonic v1.14.0 h1:/OfKt8HFw0kh2rj8N0F6C/qPGRESq0BbaNZgcNXXzQQ=
 80 github.com/bytedance/sonic v1.14.0/go.mod h1:WoEbx8WTcFJfzCe0hbmyTGrfjt8PzNEBdxlNUO24NhA=
 81 github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA=
 82diff --git a/handler/downloads.go b/handler/downloads.go
 83index 411740972e9e8e1a328836e991884eba1fe2a419..7cf2cedc77c427b74f0ba21a752fdd0f97c2fb5b 100644
 84--- a/handler/downloads.go
 85+++ b/handler/downloads.go
 86@@ -22,7 +22,7 @@ func sanitizeFilename(title string) string {
 87 	return title
 88 }
 89 
 90-func (h *handler) DownloadVideo(ctx context.Context, videoID string) error {
 91+func (h *handler) DownloadVideo(ctx context.Context, videoID string, resolution string) error {
 92 	exists, err := h.db.HasVideo(ctx, videoID)
 93 	if err != nil {
 94 		return fmt.Errorf("failed to check video existence: %w", err)
 95@@ -35,12 +35,12 @@ func (h *handler) DownloadVideo(ctx context.Context, videoID string) error {
 96 		return fmt.Errorf("failed to set download status: %w", err)
 97 	}
 98 
 99-	go h.performDownload(videoID)
100+	go h.performDownload(videoID, resolution)
101 
102 	return nil
103 }
104 
105-func (h *handler) performDownload(videoID string) {
106+func (h *handler) performDownload(videoID string, resolution string) {
107 	ctx := context.Background()
108 
109 	video, err := h.db.GetVideo(ctx, videoID)
110@@ -57,9 +57,9 @@ func (h *handler) performDownload(videoID string) {
111 		return
112 	}
113 
114-	h.log.Info("Starting video download", "video_id", videoID, "title", video.Title)
115+	h.log.Info("Starting video download", "video_id", videoID, "title", video.Title, "resolution", resolution)
116 
117-	filePath, err := h.downloader.Download(ctx, videoID, video.Title, h.config.DownloadsDir)
118+	filePath, err := h.downloader.Download(ctx, videoID, video.Title, h.config.DownloadsDir, resolution)
119 	if err != nil {
120 		h.log.Error("Video download failed", "video_id", videoID, "error", err)
121 		if dbErr := h.db.SetVideoDownloadFailed(ctx, videoID, err.Error()); dbErr != nil {
122diff --git a/handler/handler.go b/handler/handler.go
123index 10fcef86e3011578b308a065fe36b23bcdea6248..3989f8ab85847a1f55c168810ef89c86076dd72f 100644
124--- a/handler/handler.go
125+++ b/handler/handler.go
126@@ -25,7 +25,7 @@ type Handler interface {
127 	MarkVideoAsUnwatched(ctx context.Context, videoID string) error
128 	SetVideoProgress(ctx context.Context, videoID string, progressTime string) (*models.Video, error)
129 	AddCustomVideo(ctx context.Context, videoID string) error
130-	DownloadVideo(ctx context.Context, videoID string) error
131+	DownloadVideo(ctx context.Context, videoID string, resolution string) error
132 	ServeVideoFile(ctx context.Context, videoID string) (filePath string, filename string, err error)
133 	CleanupRoutine(ctx context.Context)
134 }
135diff --git a/httpserver/ytrssil/server.go b/httpserver/ytrssil/server.go
136index 23bb7d705210b7da8ce50916c21dd1193937665f..b10bca04f900a1dee3dccdc7bd1e1ff8981ef7cb 100644
137--- a/httpserver/ytrssil/server.go
138+++ b/httpserver/ytrssil/server.go
139@@ -97,6 +97,7 @@ func SetupGinRouter(
140 		api.GET("videos/watched", srv.GetWatchedVideosJSON)
141 		api.POST("videos/:video_id/watch", srv.MarkVideoAsWatchedJSON)
142 		api.POST("videos/:video_id/unwatch", srv.MarkVideoAsUnwatchedJSON)
143+		api.POST("videos/:video_id/download", srv.DownloadVideoJSON)
144 	}
145 
146 	return engine, nil
147diff --git a/httpserver/ytrssil/videos.go b/httpserver/ytrssil/videos.go
148index c81f088c8ca7017f434c9f9abccf781a159a2036..17eae03180d365697b73e75a1dbeb461fd3ebb9d 100644
149--- a/httpserver/ytrssil/videos.go
150+++ b/httpserver/ytrssil/videos.go
151@@ -55,3 +55,16 @@ func (srv *server) MarkVideoAsUnwatchedJSON(c *gin.Context) {
152 
153 	c.JSON(http.StatusOK, gin.H{"msg": "cleared video from watch history"})
154 }
155+
156+func (srv *server) DownloadVideoJSON(c *gin.Context) {
157+	videoID := c.Param("video_id")
158+	resolution := c.PostForm("format")
159+
160+	err := srv.handler.DownloadVideo(c.Request.Context(), videoID, resolution)
161+	if err != nil {
162+		c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
163+		return
164+	}
165+
166+	c.JSON(http.StatusOK, gin.H{"msg": "download started"})
167+}
168diff --git a/httpserver/ytrssil/videos_pages.go b/httpserver/ytrssil/videos_pages.go
169index 03f3f6a6f7fef321860dfc295a5906fe082114a1..4926aa32540247bbc9fef93ef310480f782afe82 100644
170--- a/httpserver/ytrssil/videos_pages.go
171+++ b/httpserver/ytrssil/videos_pages.go
172@@ -101,8 +101,9 @@ func (srv server) AddVideoPage(c *gin.Context) {
173 
174 func (srv server) DownloadVideoPage(c *gin.Context) {
175 	videoID := c.Param("video_id")
176+	resolution := c.PostForm("format")
177 
178-	err := srv.handler.DownloadVideo(c.Request.Context(), videoID)
179+	err := srv.handler.DownloadVideo(c.Request.Context(), videoID, resolution)
180 	if err != nil {
181 		returnErr(c, http.StatusInternalServerError, err)
182 		return
183diff --git a/lib/downloader/downloader.go b/lib/downloader/downloader.go
184index ef1f4e8f5e3d182fca8686078d8ab44027449d43..50be9a0e79056e9fc4aa9fcfac5921d69525f34c 100644
185--- a/lib/downloader/downloader.go
186+++ b/lib/downloader/downloader.go
187@@ -8,10 +8,10 @@ import (
188 	"path/filepath"
189 )
190 
191-const ytDlpFormat = "bestvideo[height<=1080][vcodec^=vp9]+bestaudio/bestvideo[height<=1080][vcodec^=av01]+bestaudio/bestvideo[height<=1080]+bestaudio/best[height<=1080]/best" // nolint:lll
192+const defaultResolution = "1080"
193 
194 type Downloader interface {
195-	Download(ctx context.Context, videoID string, title string, outputDir string) (string, error)
196+	Download(ctx context.Context, videoID string, title string, outputDir string, resolution string) (string, error)
197 	ValidateInstallation() error
198 }
199 
200@@ -31,17 +31,28 @@ func (d *ytdlpDownloader) ValidateInstallation() error {
201 	return nil
202 }
203 
204+func formatSelector(resolution string) string {
205+	return fmt.Sprintf(
206+		"bestvideo[height<=%s]+bestaudio/best[height<=%s]",
207+		resolution, resolution,
208+	)
209+}
210+
211 func (d *ytdlpDownloader) Download(
212 	ctx context.Context,
213 	videoID string,
214 	title string,
215 	outputDir string,
216+	resolution string,
217 ) (string, error) {
218+	if resolution == "" {
219+		resolution = defaultResolution
220+	}
221 	outputTemplate := filepath.Join(outputDir, fmt.Sprintf("%s.%%(ext)s", videoID))
222 
223 	cmd := exec.CommandContext(ctx,
224 		"yt-dlp",
225-		"--format", ytDlpFormat,
226+		"--format", formatSelector(resolution),
227 		"--output", outputTemplate,
228 		"--no-playlist",
229 		"--no-warnings",
230diff --git a/pages/main.templ b/pages/main.templ
231index 43d062efd53549bbd5e341ebeb4efcb9bef4fe43..2d676d1aed1c08ae2266073438c917d7bf7c3e89 100644
232--- a/pages/main.templ
233+++ b/pages/main.templ
234@@ -28,6 +28,7 @@ templ BaseLayout(title string, route string) {
235 		<body>
236 			@subscriptionModal()
237 			@addVideoModal()
238+			@resolutionModal()
239 			@navbar(route)
240 			<div class="container-fluid">
241 				{ children... }
242diff --git a/pages/main_templ.go b/pages/main_templ.go
243index 7261b696e0843d0c37b8e59929ef89de592013d3..2443fcbb99560ed893f80092bbbbd9a2df64581b 100644
244--- a/pages/main_templ.go
245+++ b/pages/main_templ.go
246@@ -54,6 +54,10 @@ func BaseLayout(title string, route string) templ.Component {
247 		if templ_7745c5c3_Err != nil {
248 			return templ_7745c5c3_Err
249 		}
250+		templ_7745c5c3_Err = resolutionModal().Render(ctx, templ_7745c5c3_Buffer)
251+		if templ_7745c5c3_Err != nil {
252+			return templ_7745c5c3_Err
253+		}
254 		templ_7745c5c3_Err = navbar(route).Render(ctx, templ_7745c5c3_Buffer)
255 		if templ_7745c5c3_Err != nil {
256 			return templ_7745c5c3_Err
257diff --git a/pages/new_videos.templ b/pages/new_videos.templ
258index 438644480aba067f868b5fa721d7dd3a99816831..b15bc9194983eb53fd3fef6bf13620f1dae8b15b 100644
259--- a/pages/new_videos.templ
260+++ b/pages/new_videos.templ
261@@ -109,22 +109,24 @@ templ VideoCard(video models.Video) {
262 								<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
263 							</button>
264 						} else if video.DownloadFailed() {
265-							<button
266-								class="btn btn-danger"
267-								title={ fmt.Sprintf("Download failed: %s", *video.DownloadError) }
268-								hx-post={ fmt.Sprintf("/videos/%s/download", video.ID) }
269-								hx-target="closest .video-card"
270-								hx-swap="outerHTML"
271-							>
272+						<button
273+							type="button"
274+							class="btn btn-danger"
275+							title={ fmt.Sprintf("Download failed: %s", *video.DownloadError) }
276+							data-video-id={ video.ID }
277+							data-video-title={ video.Title }
278+							onclick="openResolutionModal(this)"
279+						>
280 								<i class="bi bi-exclamation-triangle"></i>
281 							</button>
282 						} else {
283-							<button
284-								hx-post={ fmt.Sprintf("/videos/%s/download", video.ID) }
285-								hx-target="closest .video-card"
286-								hx-swap="outerHTML"
287-								class="btn btn-outline-primary"
288-							>
289+						<button
290+							type="button"
291+							class="btn btn-outline-primary"
292+							data-video-id={ video.ID }
293+							data-video-title={ video.Title }
294+							onclick="openResolutionModal(this)"
295+						>
296 								<i class="bi bi-download"></i>
297 							</button>
298 						}
299diff --git a/pages/new_videos_templ.go b/pages/new_videos_templ.go
300index 9877f02faa5663c91cacb52285cf3e90e2347f9f..44c8563dece41368e291f71d6f930aa376a767ed 100644
301--- a/pages/new_videos_templ.go
302+++ b/pages/new_videos_templ.go
303@@ -412,69 +412,95 @@ func VideoCard(video models.Video) templ.Component {
304 				return templ_7745c5c3_Err
305 			}
306 		} else if video.DownloadFailed() {
307-			templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "<button class=\"btn btn-danger\" title=\"")
308+			templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "<button type=\"button\" class=\"btn btn-danger\" title=\"")
309 			if templ_7745c5c3_Err != nil {
310 				return templ_7745c5c3_Err
311 			}
312 			var templ_7745c5c3_Var24 string
313 			templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("Download failed: %s", *video.DownloadError))
314 			if templ_7745c5c3_Err != nil {
315-				return templ.Error{Err: templ_7745c5c3_Err, FileName: `pages/new_videos.templ`, Line: 114, Col: 72}
316+				return templ.Error{Err: templ_7745c5c3_Err, FileName: `pages/new_videos.templ`, Line: 115, Col: 71}
317 			}
318 			_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var24))
319 			if templ_7745c5c3_Err != nil {
320 				return templ_7745c5c3_Err
321 			}
322-			templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "\" hx-post=\"")
323+			templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "\" data-video-id=\"")
324 			if templ_7745c5c3_Err != nil {
325 				return templ_7745c5c3_Err
326 			}
327 			var templ_7745c5c3_Var25 string
328-			templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("/videos/%s/download", video.ID))
329+			templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.JoinStringErrs(video.ID)
330 			if templ_7745c5c3_Err != nil {
331-				return templ.Error{Err: templ_7745c5c3_Err, FileName: `pages/new_videos.templ`, Line: 115, Col: 62}
332+				return templ.Error{Err: templ_7745c5c3_Err, FileName: `pages/new_videos.templ`, Line: 116, Col: 31}
333 			}
334 			_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var25))
335 			if templ_7745c5c3_Err != nil {
336 				return templ_7745c5c3_Err
337 			}
338-			templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "\" hx-target=\"closest .video-card\" hx-swap=\"outerHTML\"><i class=\"bi bi-exclamation-triangle\"></i></button> ")
339+			templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "\" data-video-title=\"")
340+			if templ_7745c5c3_Err != nil {
341+				return templ_7745c5c3_Err
342+			}
343+			var templ_7745c5c3_Var26 string
344+			templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.JoinStringErrs(video.Title)
345+			if templ_7745c5c3_Err != nil {
346+				return templ.Error{Err: templ_7745c5c3_Err, FileName: `pages/new_videos.templ`, Line: 117, Col: 37}
347+			}
348+			_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var26))
349+			if templ_7745c5c3_Err != nil {
350+				return templ_7745c5c3_Err
351+			}
352+			templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "\" onclick=\"openResolutionModal(this)\"><i class=\"bi bi-exclamation-triangle\"></i></button> ")
353 			if templ_7745c5c3_Err != nil {
354 				return templ_7745c5c3_Err
355 			}
356 		} else {
357-			templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "<button hx-post=\"")
358+			templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "<button type=\"button\" class=\"btn btn-outline-primary\" data-video-id=\"")
359 			if templ_7745c5c3_Err != nil {
360 				return templ_7745c5c3_Err
361 			}
362-			var templ_7745c5c3_Var26 string
363-			templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("/videos/%s/download", video.ID))
364+			var templ_7745c5c3_Var27 string
365+			templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.JoinStringErrs(video.ID)
366 			if templ_7745c5c3_Err != nil {
367-				return templ.Error{Err: templ_7745c5c3_Err, FileName: `pages/new_videos.templ`, Line: 123, Col: 62}
368+				return templ.Error{Err: templ_7745c5c3_Err, FileName: `pages/new_videos.templ`, Line: 126, Col: 31}
369 			}
370-			_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var26))
371+			_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var27))
372+			if templ_7745c5c3_Err != nil {
373+				return templ_7745c5c3_Err
374+			}
375+			templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "\" data-video-title=\"")
376+			if templ_7745c5c3_Err != nil {
377+				return templ_7745c5c3_Err
378+			}
379+			var templ_7745c5c3_Var28 string
380+			templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.JoinStringErrs(video.Title)
381+			if templ_7745c5c3_Err != nil {
382+				return templ.Error{Err: templ_7745c5c3_Err, FileName: `pages/new_videos.templ`, Line: 127, Col: 37}
383+			}
384+			_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var28))
385 			if templ_7745c5c3_Err != nil {
386 				return templ_7745c5c3_Err
387 			}
388-			templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "\" hx-target=\"closest .video-card\" hx-swap=\"outerHTML\" class=\"btn btn-outline-primary\"><i class=\"bi bi-download\"></i></button> ")
389+			templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "\" onclick=\"openResolutionModal(this)\"><i class=\"bi bi-download\"></i></button> ")
390 			if templ_7745c5c3_Err != nil {
391 				return templ_7745c5c3_Err
392 			}
393 		}
394-		templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "<button hx-patch=\"")
395+		templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "<button hx-patch=\"")
396 		if templ_7745c5c3_Err != nil {
397 			return templ_7745c5c3_Err
398 		}
399-		var templ_7745c5c3_Var27 string
400-		templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("/videos/%s/watch", video.ID))
401+		var templ_7745c5c3_Var29 string
402+		templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("/videos/%s/watch", video.ID))
403diff --git a/pages/resolution_modal.templ b/pages/resolution_modal.templ
404new file mode 100644
405index 0000000000000000000000000000000000000000..69e155d089660dd7f6655f91436c822407e11017
406--- /dev/null
407+++ b/pages/resolution_modal.templ
408@@ -0,0 +1,24 @@
409+package pages
410+
411+templ resolutionModal() {
412+	<div class="modal fade" id="resolution-modal" tabindex="-1">
413+		<div class="modal-dialog">
414+			<div class="modal-content">
415+				<div class="modal-header">
416+					<h1 class="modal-title fs-5">Select Resolution</h1>
417+					<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
418+				</div>
419+				<div class="modal-body">
420+					<p id="resolution-modal-title" class="mb-3"></p>
421+					<div class="d-flex flex-column gap-2">
422+						<button type="button" class="btn btn-outline-primary w-100 resolution-btn" data-height="2160">2160p</button>
423+						<button type="button" class="btn btn-outline-primary w-100 resolution-btn" data-height="1440">1440p</button>
424+						<button type="button" class="btn btn-outline-primary w-100 resolution-btn" data-height="1080">1080p</button>
425+						<button type="button" class="btn btn-outline-primary w-100 resolution-btn" data-height="720">720p</button>
426+						<button type="button" class="btn btn-outline-primary w-100 resolution-btn" data-height="480">480p</button>
427+					</div>
428+				</div>
429+			</div>
430+		</div>
431+	</div>
432+}
433diff --git a/pages/resolution_modal_templ.go b/pages/resolution_modal_templ.go
434new file mode 100644
435index 0000000000000000000000000000000000000000..de33504a0de42376c51695e125a13c55960cc16c
436--- /dev/null
437+++ b/pages/resolution_modal_templ.go
438@@ -0,0 +1,40 @@
439+// Code generated by templ - DO NOT EDIT.
440+
441+// templ: version: v0.3.1001
442+package pages
443+
444+//lint:file-ignore SA4006 This context is only used if a nested component is present.
445+
446+import "github.com/a-h/templ"
447+import templruntime "github.com/a-h/templ/runtime"
448+
449+func resolutionModal() templ.Component {
450+	return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
451+		templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
452+		if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
453+			return templ_7745c5c3_CtxErr
454+		}
455+		templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
456+		if !templ_7745c5c3_IsBuffer {
457+			defer func() {
458+				templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
459+				if templ_7745c5c3_Err == nil {
460+					templ_7745c5c3_Err = templ_7745c5c3_BufErr
461+				}
462+			}()
463+		}
464+		ctx = templ.InitializeContext(ctx)
465+		templ_7745c5c3_Var1 := templ.GetChildren(ctx)
466+		if templ_7745c5c3_Var1 == nil {
467+			templ_7745c5c3_Var1 = templ.NopComponent
468+		}
469+		ctx = templ.ClearChildren(ctx)