Media-only view

This commit is contained in:
lowcarbdev
2025-12-05 21:40:38 -07:00
parent c8b6b80966
commit a6ed709a99
9 changed files with 792 additions and 6 deletions
+155
View File
@@ -0,0 +1,155 @@
.media-carousel {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.95);
z-index: 9999;
display: flex;
align-items: center;
justify-content: center;
padding: 2rem;
}
.carousel-close-btn {
position: absolute;
top: 1rem;
right: 1rem;
background-color: rgba(255, 255, 255, 0.9);
border: none;
border-radius: 50%;
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
z-index: 10001;
transition: background-color 0.2s ease, transform 0.2s ease;
}
.carousel-close-btn:hover {
background-color: white;
transform: scale(1.1);
}
.carousel-counter {
position: absolute;
top: 1rem;
left: 50%;
transform: translateX(-50%);
color: white;
background-color: rgba(0, 0, 0, 0.6);
padding: 0.5rem 1rem;
border-radius: 20px;
font-size: 0.9rem;
z-index: 10001;
}
.carousel-info {
position: absolute;
bottom: 1rem;
left: 50%;
transform: translateX(-50%);
color: white;
background-color: rgba(0, 0, 0, 0.6);
padding: 0.75rem 1.5rem;
border-radius: 8px;
z-index: 10001;
max-width: 80%;
text-align: center;
}
.carousel-nav-btn {
position: absolute;
top: 50%;
transform: translateY(-50%);
background-color: rgba(255, 255, 255, 0.9);
border: none;
border-radius: 50%;
width: 50px;
height: 50px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
z-index: 10001;
transition: background-color 0.2s ease, transform 0.2s ease;
}
.carousel-nav-btn:hover {
background-color: white;
transform: translateY(-50%) scale(1.1);
}
.carousel-prev-btn {
left: 1rem;
}
.carousel-next-btn {
right: 1rem;
}
.carousel-media-container {
max-width: 90vw;
max-height: 90vh;
display: flex;
align-items: center;
justify-content: center;
}
.carousel-media {
max-width: 100%;
max-height: 90vh;
object-fit: contain;
border-radius: 8px;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.5);
}
/* Mobile adjustments */
@media (max-width: 768px) {
.media-carousel {
padding: 1rem;
}
.carousel-nav-btn {
width: 40px;
height: 40px;
}
.carousel-close-btn {
top: 0.5rem;
right: 0.5rem;
}
.carousel-counter {
top: 0.5rem;
font-size: 0.8rem;
padding: 0.4rem 0.8rem;
}
.carousel-info {
bottom: 0.5rem;
padding: 0.5rem 1rem;
font-size: 0.85rem;
max-width: 90%;
}
.carousel-prev-btn {
left: 0.5rem;
}
.carousel-next-btn {
right: 0.5rem;
}
}
/* Keyboard navigation hint */
@media (min-width: 769px) {
.carousel-nav-btn::after {
content: '';
position: absolute;
inset: -5px;
}
}
+213
View File
@@ -0,0 +1,213 @@
import { useState, useEffect, useRef } from 'react'
import { format } from 'date-fns'
import './MediaCarousel.css'
const API_BASE = import.meta.env.VITE_API_URL || 'http://localhost:8081/api'
function MediaCarousel({ mediaItems, initialIndex, onClose, transcodeVideos = new Set() }) {
const [currentIndex, setCurrentIndex] = useState(initialIndex)
const [touchStart, setTouchStart] = useState(null)
const [touchEnd, setTouchEnd] = useState(null)
const videoRef = useRef(null)
const currentItem = mediaItems[currentIndex]
const isVideo = currentItem?.media_type?.startsWith('video/')
const shouldTranscode = transcodeVideos.has(currentItem?.id)
useEffect(() => {
// Prevent body scroll when carousel is open
document.body.style.overflow = 'hidden'
// Hide UI elements that might appear over the carousel
const datePickers = document.querySelectorAll('.react-datepicker-popper')
datePickers.forEach(picker => {
picker.style.display = 'none'
})
const dateFilterContainer = document.querySelector('.date-filter-container')
if (dateFilterContainer) {
dateFilterContainer.style.visibility = 'hidden'
}
const header = document.querySelector('header')
if (header) {
header.style.visibility = 'hidden'
}
return () => {
document.body.style.overflow = 'unset'
// Restore visibility of hidden elements
const datePickers = document.querySelectorAll('.react-datepicker-popper')
datePickers.forEach(picker => {
picker.style.display = ''
})
const dateFilterContainer = document.querySelector('.date-filter-container')
if (dateFilterContainer) {
dateFilterContainer.style.visibility = ''
}
const header = document.querySelector('header')
if (header) {
header.style.visibility = ''
}
}
}, [])
useEffect(() => {
const handleKeyDown = (e) => {
if (e.key === 'Escape') {
onClose()
} else if (e.key === 'ArrowLeft') {
handlePrevious()
} else if (e.key === 'ArrowRight') {
handleNext()
}
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [currentIndex])
const handlePrevious = () => {
if (currentIndex > 0) {
setCurrentIndex(currentIndex - 1)
}
}
const handleNext = () => {
if (currentIndex < mediaItems.length - 1) {
setCurrentIndex(currentIndex + 1)
}
}
// Touch handlers for swipe gestures
const handleTouchStart = (e) => {
setTouchEnd(null)
setTouchStart(e.targetTouches[0].clientX)
}
const handleTouchMove = (e) => {
setTouchEnd(e.targetTouches[0].clientX)
}
const handleTouchEnd = () => {
if (!touchStart || !touchEnd) return
const distance = touchStart - touchEnd
const isLeftSwipe = distance > 50
const isRightSwipe = distance < -50
if (isLeftSwipe) {
handleNext()
}
if (isRightSwipe) {
handlePrevious()
}
}
const formatTime = (date) => {
return format(new Date(date), 'MMM d, yyyy h:mm a')
}
return (
<div
className="media-carousel"
onClick={onClose}
onTouchStart={handleTouchStart}
onTouchMove={handleTouchMove}
onTouchEnd={handleTouchEnd}
>
{/* Close button */}
<button
className="carousel-close-btn"
onClick={onClose}
aria-label="Close"
>
<svg style={{ width: '1.5rem', height: '1.5rem' }} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
{/* Counter */}
<div className="carousel-counter">
{currentIndex + 1} / {mediaItems.length}
</div>
{/* Media info */}
<div className="carousel-info">
<div>{formatTime(currentItem.date)}</div>
{currentItem.body && (
<div className="text-muted small mt-1">{currentItem.body}</div>
)}
</div>
{/* Previous button */}
{currentIndex > 0 && (
<button
className="carousel-nav-btn carousel-prev-btn"
onClick={(e) => {
e.stopPropagation()
handlePrevious()
}}
aria-label="Previous"
>
<svg style={{ width: '2rem', height: '2rem' }} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
</button>
)}
{/* Next button */}
{currentIndex < mediaItems.length - 1 && (
<button
className="carousel-nav-btn carousel-next-btn"
onClick={(e) => {
e.stopPropagation()
handleNext()
}}
aria-label="Next"
>
<svg style={{ width: '2rem', height: '2rem' }} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</button>
)}
{/* Media content */}
<div
className="carousel-media-container"
onClick={(e) => e.stopPropagation()}
>
{isVideo ? (
<video
ref={videoRef}
controls
autoPlay
playsInline
className="carousel-media"
src={`${API_BASE}/media?id=${currentItem.id}${shouldTranscode ? '&transcode=true' : ''}`}
key={`${currentItem.id}-${shouldTranscode}`}
onError={(e) => {
console.error('Video playback error:', e)
console.error('Video src:', `${API_BASE}/media?id=${currentItem.id}${shouldTranscode ? '&transcode=true' : ''}`)
console.error('Video error code:', e.target.error?.code)
console.error('Video error message:', e.target.error?.message)
}}
onLoadStart={() => console.log('Video load started:', currentItem.id)}
onCanPlay={() => console.log('Video can play:', currentItem.id)}
/>
) : (
<img
src={`${API_BASE}/media?id=${currentItem.id}`}
alt={`Media ${currentIndex + 1}`}
className="carousel-media"
/>
)}
</div>
</div>
)
}
export default MediaCarousel
+66
View File
@@ -0,0 +1,66 @@
.media-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 8px;
padding: 16px;
width: 100%;
}
.media-grid-item {
position: relative;
aspect-ratio: 1;
overflow: hidden;
border-radius: 8px;
cursor: pointer;
background-color: #f0f0f0;
transition: transform 0.2s ease, box-shadow 0.2s ease;
}
.media-grid-item:hover {
transform: scale(1.05);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
z-index: 1;
}
.media-thumbnail {
width: 100%;
height: 100%;
position: relative;
}
.media-thumbnail img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.video-indicator {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background-color: rgba(0, 0, 0, 0.6);
border-radius: 50%;
width: 3rem;
height: 3rem;
display: flex;
align-items: center;
justify-content: center;
pointer-events: none;
}
/* Responsive adjustments */
@media (max-width: 768px) {
.media-grid {
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
gap: 4px;
padding: 8px;
}
}
@media (min-width: 1200px) {
.media-grid {
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
}
}
+182
View File
@@ -0,0 +1,182 @@
import { useState, useEffect } from 'react'
import axios from 'axios'
import MediaCarousel from './MediaCarousel'
import './MediaGrid.css'
const API_BASE = import.meta.env.VITE_API_URL || 'http://localhost:8081/api'
function MediaGrid({ conversation, startDate, endDate }) {
const [mediaItems, setMediaItems] = useState([])
const [loading, setLoading] = useState(false)
const [selectedIndex, setSelectedIndex] = useState(null)
const [failedVideos, setFailedVideos] = useState(new Set())
const [transcodeVideos, setTranscodeVideos] = useState(new Set())
useEffect(() => {
fetchMediaItems()
setFailedVideos(new Set()) // Reset failed videos when conversation changes
setTranscodeVideos(new Set()) // Reset transcode videos when conversation changes
}, [conversation, startDate, endDate])
const fetchMediaItems = async () => {
setLoading(true)
try {
const params = {
address: conversation.address
}
if (startDate) params.start = startDate.toISOString()
if (endDate) params.end = endDate.toISOString()
const response = await axios.get(`${API_BASE}/media-items`, { params })
setMediaItems(response.data || [])
} catch (error) {
console.error('Error fetching media items:', error)
} finally {
setLoading(false)
}
}
const handleThumbnailClick = (index) => {
setSelectedIndex(index)
}
const handleCloseCarousel = () => {
setSelectedIndex(null)
}
const handleVideoError = (itemId, alreadyTranscoded = false) => {
if (!alreadyTranscoded) {
console.log('Video failed to play, retrying with transcoding:', itemId)
// Mark this video to be transcoded and it will reload
setTranscodeVideos(prev => new Set([...prev, itemId]))
return
}
// If already transcoded and still failing, hide it
console.warn('Video not playable even after transcoding:', itemId)
setFailedVideos(prev => new Set([...prev, itemId]))
}
// Filter out failed videos from display
const displayableItems = mediaItems.filter(item => !failedVideos.has(item.id))
if (loading) {
return (
<div className="d-flex justify-content-center align-items-center" style={{ minHeight: '400px' }}>
<div className="spinner-border text-primary" role="status">
<span className="visually-hidden">Loading media...</span>
</div>
</div>
)
}
if (displayableItems.length === 0 && !loading) {
return (
<div className="text-center py-5">
<svg
style={{ width: '4rem', height: '4rem' }}
className="mb-3 text-muted opacity-50"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"
/>
</svg>
<p className="text-muted">
{mediaItems.length === 0
? 'No photos or videos found in this conversation'
: 'No browser-compatible photos or videos found in this conversation'}
</p>
</div>
)
}
return (
<>
{transcodeVideos.size > 0 && failedVideos.size === 0 && (
<div className="alert alert-info mx-3 mt-3 mb-0" role="alert">
<small>
{transcodeVideos.size} video{transcodeVideos.size > 1 ? 's are' : ' is'} being transcoded for browser compatibility...
</small>
</div>
)}
{failedVideos.size > 0 && (
<div className="alert alert-warning mx-3 mt-3 mb-0" role="alert">
<small>
{failedVideos.size} video{failedVideos.size > 1 ? 's' : ''} could not be transcoded.
These videos can still be viewed in the regular message view.
</small>
</div>
)}
<div className="media-grid">
{displayableItems.map((item, index) => {
const isVideo = item.media_type?.startsWith('video/')
return (
<div
key={item.id}
className="media-grid-item"
onClick={() => handleThumbnailClick(index)}
>
<div className="media-thumbnail">
{isVideo ? (
<>
<video
src={`${API_BASE}/media?id=${item.id}${transcodeVideos.has(item.id) ? '&transcode=true' : ''}#t=0.1`}
preload="metadata"
muted
playsInline
key={`${item.id}-${transcodeVideos.has(item.id)}`}
style={{
width: '100%',
height: '100%',
objectFit: 'cover',
display: 'block',
pointerEvents: 'none'
}}
onError={(e) => {
if (e.target.error?.code === 4) {
// MEDIA_ERR_SRC_NOT_SUPPORTED - unsupported format/codec
handleVideoError(item.id, transcodeVideos.has(item.id))
}
}}
/>
<div className="video-indicator">
<svg
style={{ width: '2rem', height: '2rem' }}
fill="white"
viewBox="0 0 24 24"
>
<path d="M8 5v14l11-7z" />
</svg>
</div>
</>
) : (
<img
src={`${API_BASE}/media?id=${item.id}`}
alt={`Media ${index + 1}`}
loading="lazy"
/>
)}
</div>
</div>
)
})}
</div>
{selectedIndex !== null && (
<MediaCarousel
mediaItems={displayableItems}
initialIndex={selectedIndex}
onClose={handleCloseCarousel}
transcodeVideos={transcodeVideos}
/>
)}
</>
)
}
export default MediaGrid
+18 -2
View File
@@ -3,6 +3,7 @@ import { useLocation } from 'react-router-dom'
import axios from 'axios'
import { format } from 'date-fns'
import LazyMedia from './LazyMedia'
import MediaGrid from './MediaGrid'
const API_BASE = import.meta.env.VITE_API_URL || 'http://localhost:8081/api'
@@ -12,12 +13,14 @@ function MessageThread({ conversation, startDate, endDate }) {
const [loading, setLoading] = useState(false)
const [highlightedMessageId, setHighlightedMessageId] = useState(null)
const [isPreprintingMedia, setIsPreprintingMedia] = useState(false)
const [showMediaOnly, setShowMediaOnly] = useState(false)
const messageRefs = useRef({})
const printTriggeredRef = useRef(false)
useEffect(() => {
if (conversation) {
fetchItems()
setShowMediaOnly(false) // Reset to message view when conversation changes
} else {
setItems([])
}
@@ -464,7 +467,17 @@ function MessageThread({ conversation, startDate, endDate }) {
</span>
</div>
</div>
<div>
<div className="d-flex gap-2">
<button
onClick={() => setShowMediaOnly(!showMediaOnly)}
className={`btn btn-sm ${showMediaOnly ? 'btn-primary' : 'btn-outline-primary'} d-flex align-items-center gap-1`}
title={showMediaOnly ? "Show all messages" : "Show photos only"}
>
<svg style={{width: '1rem', height: '1rem'}} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
<span className="d-none d-md-inline">{showMediaOnly ? 'Show All' : 'Photos'}</span>
</button>
<button
onClick={handleExportPDF}
className="btn btn-sm btn-outline-primary d-flex align-items-center gap-1"
@@ -481,7 +494,10 @@ function MessageThread({ conversation, startDate, endDate }) {
{/* Content */}
<div className="flex-fill overflow-auto p-2 p-md-4 bg-light">
{isCallLog ? (
{showMediaOnly && !isCallLog ? (
// Media Grid View
<MediaGrid conversation={conversation} startDate={startDate} endDate={endDate} />
) : isCallLog ? (
// Call Log View
<div className="d-flex flex-column gap-3">
{items.map((call) => {
+55
View File
@@ -824,6 +824,61 @@ func GetActivityByAddress(userDB *sql.DB, address string, startDate, endDate *ti
return activities, nil
}
// GetMediaByAddress fetches only media items (images/videos) for a specific address
func GetMediaByAddress(userDB *sql.DB, address string, startDate, endDate *time.Time) ([]Message, error) {
query := `
SELECT id, address, COALESCE(body, '') as body, date,
COALESCE(contact_name, '') as contact_name, COALESCE(media_type, '') as media_type,
read, thread_id
FROM messages
WHERE record_type IN (1, 2)
AND media_type IS NOT NULL
AND media_type != ''
AND (media_type LIKE 'image/%' OR media_type LIKE 'video/%')
`
args := []interface{}{}
if address != "" {
query += " AND address = ?"
args = append(args, address)
}
if startDate != nil {
query += " AND date >= ?"
args = append(args, startDate.Unix())
}
if endDate != nil {
query += " AND date <= ?"
args = append(args, endDate.Unix())
}
query += " ORDER BY date DESC"
rows, err := userDB.Query(query, args...)
if err != nil {
return nil, err
}
defer rows.Close()
var mediaItems []Message
for rows.Next() {
var m Message
var dateUnix int64
var readInt int64
err := rows.Scan(&m.ID, &m.Address, &m.Body, &dateUnix, &m.ContactName, &m.MediaType, &readInt, &m.ThreadID)
if err != nil {
return nil, err
}
m.Date = time.Unix(dateUnix, 0)
m.Read = readInt == 1
mediaItems = append(mediaItems, m)
}
return mediaItems, nil
}
func GetMessageMedia(userDB *sql.DB, messageID string) ([]byte, string, error) {
query := `
SELECT COALESCE(media_data, ''), COALESCE(media_type, '')
+101 -2
View File
@@ -8,6 +8,7 @@ import (
"net/http"
"os"
"strconv"
"strings"
"time"
"github.com/labstack/echo/v4"
@@ -240,6 +241,44 @@ func HandleMessages(c echo.Context) error {
return c.JSON(http.StatusOK, messages)
}
// HandleMediaItems returns only media (images/videos) for a conversation
func HandleMediaItems(c echo.Context) error {
userDB, err := getUserDB(c)
if err != nil {
slog.Error("Error getting user database", "error", err)
return c.JSON(http.StatusInternalServerError, map[string]string{
"error": "Failed to get user database",
})
}
address := c.QueryParam("address")
var startDate, endDate *time.Time
if startStr := c.QueryParam("start"); startStr != "" {
t, err := time.Parse(time.RFC3339, startStr)
if err == nil {
startDate = &t
}
}
if endStr := c.QueryParam("end"); endStr != "" {
t, err := time.Parse(time.RFC3339, endStr)
if err == nil {
endDate = &t
}
}
mediaItems, err := GetMediaByAddress(userDB, address, startDate, endDate)
if err != nil {
slog.Error("Error getting media items", "error", err)
return c.JSON(http.StatusInternalServerError, map[string]string{
"error": "Failed to get media items",
})
}
return c.JSON(http.StatusOK, mediaItems)
}
func HandleActivity(c echo.Context) error {
userDB, err := getUserDB(c)
if err != nil {
@@ -395,6 +434,9 @@ func HandleMedia(c echo.Context) error {
})
}
// Check if transcode is requested (for videos that browser can't play)
forceTranscode := c.QueryParam("transcode") == "true"
// Fetch media from database
media, contentType, err := GetMessageMedia(userDB, messageID)
if err != nil {
@@ -410,11 +452,68 @@ func HandleMedia(c echo.Context) error {
})
}
// If transcode is requested and this is a video, try to convert it
if forceTranscode && strings.HasPrefix(contentType, "video/") {
slog.Info("Transcode requested for video", "messageID", messageID, "contentType", contentType)
convertedData, err := convertVideoToMP4(media)
if err != nil {
slog.Error("Failed to transcode video", "messageID", messageID, "error", err)
// Continue with original video if conversion fails
} else {
slog.Info("Successfully transcoded video", "messageID", messageID)
media = convertedData
contentType = "video/mp4"
}
}
slog.Debug("Serving media", "messageID", messageID, "contentType", contentType, "size", len(media))
// Set appropriate headers
c.Response().Header().Set("Cache-Control", "public, max-age=31536000") // Cache for 1 year
c.Response().Header().Set("Content-Length", fmt.Sprintf("%d", len(media)))
c.Response().Header().Set("Accept-Ranges", "bytes") // Enable range requests for video streaming
// Write binary data with proper content type
// Check for Range header (needed for video playback)
rangeHeader := c.Request().Header.Get("Range")
if rangeHeader != "" {
contentLength := int64(len(media))
var start, end int64 = 0, contentLength - 1
slog.Debug("Range request received", "messageID", messageID, "range", rangeHeader, "contentType", contentType, "contentLength", contentLength)
// Parse range header (e.g., "bytes=0-1023" or "bytes=0-")
n, _ := fmt.Sscanf(rangeHeader, "bytes=%d-%d", &start, &end)
if n == 1 {
// Only start was specified (e.g., "bytes=0-")
end = contentLength - 1
} else if n == 0 {
// Invalid range, return 416 Range Not Satisfiable
slog.Warn("Invalid range header", "range", rangeHeader)
c.Response().Header().Set("Content-Range", fmt.Sprintf("bytes */%d", contentLength))
return c.NoContent(http.StatusRequestedRangeNotSatisfiable)
}
// Ensure valid range
if start < 0 || start >= contentLength || end >= contentLength || start > end {
slog.Warn("Range out of bounds", "start", start, "end", end, "contentLength", contentLength)
c.Response().Header().Set("Content-Range", fmt.Sprintf("bytes */%d", contentLength))
return c.NoContent(http.StatusRequestedRangeNotSatisfiable)
}
slog.Debug("Serving range", "start", start, "end", end, "size", end-start+1)
// Set response headers for partial content
c.Response().Header().Set("Content-Type", contentType)
c.Response().Header().Set("Content-Range", fmt.Sprintf("bytes %d-%d/%d", start, end, contentLength))
c.Response().Header().Set("Content-Length", fmt.Sprintf("%d", end-start+1))
c.Response().WriteHeader(http.StatusPartialContent)
// Write the requested range
_, writeErr := c.Response().Write(media[start : end+1])
return writeErr
}
// No range request - serve full content
c.Response().Header().Set("Content-Length", fmt.Sprintf("%d", len(media)))
return c.Blob(http.StatusOK, contentType, media)
}
+1 -2
View File
@@ -1,6 +1,5 @@
package internal
import (
"bytes"
"database/sql"
@@ -440,10 +439,10 @@ func isHEICContentType(contentType string) bool {
// needsVideoConversion checks if a video format needs conversion for browser compatibility
func needsVideoConversion(contentType string) bool {
ct := strings.ToLower(strings.TrimSpace(contentType))
// 3GP, 3G2, and other old mobile formats that browsers don't support
unsupportedFormats := []string{
"3gpp", "3gp", "3g2", "3gpp2",
"video/3gpp", "video/3gp", "video/3gpp2", "video/3g2",
"video/x-matroska", // MKV container (may have various codecs)
}
for _, format := range unsupportedFormats {
+1
View File
@@ -73,6 +73,7 @@ func main() {
protected.GET("/daterange", internal.HandleDateRange)
protected.GET("/progress", internal.HandleProgress)
protected.GET("/media", internal.HandleMedia)
protected.GET("/media-items", internal.HandleMediaItems)
protected.GET("/search", internal.HandleSearch)
protected.GET("/settings", internal.HandleGetSettings)
protected.PUT("/settings", internal.HandleUpdateSettings)