Add video player

This commit is contained in:
Alan Grainger 2024-10-29 10:29:14 +01:00
parent 7be2508eac
commit 65eeba2df6
7 changed files with 157 additions and 54 deletions

BIN
public/images/play.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

8
public/lg-video.min.js vendored Normal file

File diff suppressed because one or more lines are too long

View File

@ -2,13 +2,32 @@ html {
background: #191919;
}
#lightgallery a {
text-decoration: none;
margin: 0;
padding: 0;
}
#lightgallery img {
height: 250px;
margin: 4px;
}
#lightgallery a {
position: relative; /* Establishes a positioning context */
display: inline-block; /* Allows the container to wrap around the image */
text-decoration: none;
margin: 0;
padding: 0;
cursor: pointer;
}
.play-icon {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 50px;
height: 50px;
background-image: url('/images/play.png');
background-size: contain;
background-repeat: no-repeat;
opacity: 0.5;
}
#lightgallery a:has(.play-icon):hover .play-icon {
opacity: 1;
}

View File

@ -1,14 +1,15 @@
import { Asset, AssetType, ImageSize, SharedLink } from './types'
class Immich {
async request (endpoint: string, json = true) {
async request (endpoint: string) {
const res = await fetch(process.env.IMMICH_URL + '/api' + endpoint, {
headers: {
'x-api-key': process.env.API_KEY || ''
}
})
if (res.status === 200) {
if (json) {
const contentType = res.headers.get('Content-Type') || ''
if (contentType.includes('application/json')) {
return res.json()
} else {
return res
@ -17,25 +18,38 @@ class Immich {
}
async getShareByKey (key: string) {
const links = await this.request('/shared-links') as SharedLink[]
return links.find(x => x.key === key)
const res = (await this.request('/shared-links') || []) as SharedLink[]
return res?.find(x => x.key === key)
}
async getAssetBuffer (asset: Asset, size?: ImageSize) {
switch (asset.type) {
case AssetType.image:
size = size === ImageSize.thumbnail ? ImageSize.thumbnail : ImageSize.original
return this.request('/assets/' + asset.id + '/' + size, false)
return this.request('/assets/' + asset.id + '/' + size)
case AssetType.video:
return this.request('/assets/' + asset.id + '/video/playback', false)
return this.request('/assets/' + asset.id + '/video/playback')
}
}
imageUrl (id: string, size?: ImageSize) {
async getContentType (asset: Asset) {
const assetBuffer = await this.getAssetBuffer(asset)
return assetBuffer.headers.get('Content-Type')
}
photoUrl (id: string, size?: ImageSize) {
return `${process.env.SERVER_URL}/photo/${id}` + (size ? `?size=${size}` : '')
}
videoUrl (id: string) {
return `${process.env.SERVER_URL}/video/${id}`
}
const api = new Immich()
isId (id: string) {
return !!id.match(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/)
}
}
export default api
const immich = new Immich()
export default immich

View File

@ -1,9 +1,9 @@
import express from 'express'
import api from './immich'
import immich from './immich'
import render from './render'
import dayjs from 'dayjs'
import { AssetType, ImageSize } from './types'
import { Request, Response } from 'express-serve-static-core'
import { Request } from 'express-serve-static-core'
require('dotenv').config()
@ -11,22 +11,6 @@ const app = express()
app.set('view engine', 'ejs')
app.use(express.static('public'))
async function serveImage (res: Response, id: string, size?: ImageSize) {
const image = await api.getAssetBuffer({
id,
type: AssetType.image
}, size)
if (image) {
for (const header of ['content-type', 'content-length']) {
res.set(header, image.headers[header])
}
console.log(`${dayjs().format()} Serving image ${id}`)
res.send(Buffer.from(await image.arrayBuffer()))
} else {
res.status(404).send()
}
}
const getSize = (req: Request) => {
return req?.query?.size === 'thumbnail' ? ImageSize.thumbnail : ImageSize.original
}
@ -36,33 +20,42 @@ app.get('/share/:key', async (req, res) => {
// Invalid characters in the incoming URL
res.status(404).send()
} else {
const share = await api.getShareByKey(req.params.key)
const share = await immich.getShareByKey(req.params.key)
if (!share || !share.assets.length) {
res.status(404).send()
} else if (share.assets.length === 1) {
// This is an individual item (not a gallery)
await serveImage(res, share.assets[0].id, getSize(req))
const asset = share.assets[0]
if (asset.type === AssetType.image) {
// Output the image directly
await render.assetBuffer(res, share.assets[0], getSize(req))
} else if (asset.type === AssetType.video) {
// Show the video as a web player
await render.gallery(res, share.assets, 1)
}
} else {
// Multiple images - render as a gallery
res.render('gallery', {
photos: share.assets.map(photo => {
return {
originalUrl: immich.imageUrl(photo.id),
thumbnailUrl: immich.imageUrl(photo.id, ImageSize.thumbnail)
}
})
})
await render.gallery(res, share.assets, 1)
}
}
})
app.get('/photo/:id', (req, res) => {
if (req.params.id.match(/^\w{8}-\w{4}-\w{4}-\w{4}-\w{12}$/)) {
// Check for photo
serveImage(res, req.params.id, getSize(req)).then()
} else {
// Output the buffer data for an photo or video
app.get('/:type(photo|video)/:id', (req, res) => {
if (!immich.isId(req.params.id)) {
// Invalid characters in the incoming URL
res.status(404).send()
return
}
const asset = {
id: req.params.id,
type: req.params.type === 'video' ? AssetType.video : AssetType.image
}
switch (req.params.type) {
case 'photo':
case 'video':
render.assetBuffer(res, asset, getSize(req)).then()
break
}
})

54
src/render.ts Normal file
View File

@ -0,0 +1,54 @@
import immich from './immich'
import { Response } from 'express-serve-static-core'
import { Asset, AssetType, ImageSize } from './types'
import dayjs from 'dayjs'
class Render {
async assetBuffer (res: Response, asset: Asset, size?: ImageSize) {
const data = await immich.getAssetBuffer(asset, size)
if (data) {
for (const header of ['content-type', 'content-length']) {
res.set(header, data.headers[header])
}
console.log(`${dayjs().format()} Serving asset ${asset.id}`)
res.send(Buffer.from(await data.arrayBuffer()))
} else {
res.status(404).send()
}
}
async gallery (res: Response, assets: Asset[], openItem?: number) {
const items = []
for (const asset of assets) {
let video
if (asset.type === AssetType.video) {
// Populate the data-video property
video = JSON.stringify({
source: [
{
src: immich.videoUrl(asset.id),
type: await immich.getContentType(asset)
}
],
attributes: {
preload: false,
controls: true
}
})
}
items.push({
originalUrl: immich.photoUrl(asset.id),
thumbnailUrl: immich.photoUrl(asset.id, ImageSize.thumbnail),
video
})
}
res.render('gallery', {
items,
openItem
})
}
}
const render = new Render()
export default render

View File

@ -7,21 +7,36 @@
</head>
<body>
<div id="lightgallery">
<% photos.forEach(photo => { %>
<a href="<%- photo.originalUrl %>">
<img alt="" src="<%- photo.thumbnailUrl %>" />
<% items.forEach(item => {
if (item.video) { %>
<a data-video='<%- item.video %>'>
<img alt="" src="<%- item.thumbnailUrl %>"/>
<div class="play-icon"></div>
</a>
<% }) %>
<% } else { %>
<a href="<%- item.originalUrl %>">
<img alt="" src="<%- item.thumbnailUrl %>"/>
</a>
<% }
}) %>
</div>
<script src="/lightgallery.min.js"></script>
<script src="/lg-thumbnail.min.js"></script>
<script src="/lg-video.min.js"></script>
<script src="/lg-zoom.min.js"></script>
<script type="text/javascript">
lightGallery(document.getElementById('lightgallery'), {
plugins: [lgZoom, lgThumbnail],
plugins: [lgZoom, lgThumbnail, lgVideo],
licenseKey: '0000-0000-000-0000',
speed: 500
});
})
<% if (openItem) { %>
const openItem = <%- openItem %>
const thumbs = document.querySelectorAll('#lightgallery a')
if (thumbs.length >= openItem) {
thumbs[openItem - 1].click()
}
<% } %>
</script>
</body>
</html>