..
This commit is contained in:
parent
1fa47201a7
commit
526a7b598f
@ -1,6 +1,12 @@
|
||||
package kr.lunaticbum.back.lun.controllers
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode
|
||||
import com.fasterxml.jackson.databind.ObjectMapper
|
||||
import kotlinx.coroutines.reactor.awaitSingle
|
||||
import kotlinx.coroutines.reactor.awaitSingleOrNull
|
||||
import kr.lunaticbum.back.lun.model.PhotoMetadata
|
||||
import kr.lunaticbum.back.lun.repository.PhotoMetadataRepository
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.core.io.buffer.DataBuffer
|
||||
import org.springframework.core.io.buffer.DataBufferUtils
|
||||
import org.springframework.http.MediaType
|
||||
@ -9,9 +15,8 @@ import org.springframework.web.bind.annotation.*
|
||||
import org.springframework.web.reactive.function.client.WebClient
|
||||
import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody
|
||||
import reactor.core.publisher.Flux
|
||||
import java.nio.charset.StandardCharsets
|
||||
import reactor.core.publisher.Mono
|
||||
|
||||
// DTO 정의
|
||||
data class SynologyListRequest(
|
||||
val nasAddress: String,
|
||||
val sid: String,
|
||||
@ -19,13 +24,23 @@ data class SynologyListRequest(
|
||||
val limit: Int
|
||||
)
|
||||
|
||||
data class MetadataRequest(
|
||||
val id: String,
|
||||
val memo: String?,
|
||||
val tags: List<String>
|
||||
)
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/synology")
|
||||
class SynologyProxyController {
|
||||
class SynologyProxyController(
|
||||
private val photoMetadataRepository: PhotoMetadataRepository
|
||||
) {
|
||||
private val logger = LoggerFactory.getLogger(SynologyProxyController::class.java)
|
||||
private val mapper = ObjectMapper()
|
||||
|
||||
private val webClient = WebClient.builder()
|
||||
.codecs { configurer ->
|
||||
configurer.defaultCodecs().maxInMemorySize(16 * 1024 * 1024) // 16MB 버퍼
|
||||
configurer.defaultCodecs().maxInMemorySize(16 * 1024 * 1024)
|
||||
}
|
||||
.build()
|
||||
|
||||
@ -33,6 +48,88 @@ class SynologyProxyController {
|
||||
return if (nasAddress.startsWith("http")) nasAddress else "https://$nasAddress"
|
||||
}
|
||||
|
||||
// [핵심 로직] 라이브 포토의 짝꿍 비디오 ID 찾기 (Plan A -> Plan B)
|
||||
private fun fetchLivePhotoUnitId(
|
||||
baseUrl: String,
|
||||
sid: String,
|
||||
folderId: String,
|
||||
uuid: String?,
|
||||
originalFilename: String?
|
||||
): String? {
|
||||
try {
|
||||
// ========================================================================
|
||||
// PLAN A: MediaGroupUUID로 검색 (가장 정확함)
|
||||
// ========================================================================
|
||||
if (!uuid.isNullOrEmpty()) {
|
||||
val searchUri = org.springframework.web.util.UriComponentsBuilder.fromHttpUrl(baseUrl)
|
||||
.path("/webapi/entry.cgi")
|
||||
.queryParam("api", "SYNO.Foto.Browse.Item")
|
||||
.queryParam("version", "1")
|
||||
.queryParam("method", "list")
|
||||
.queryParam("folder_id", folderId)
|
||||
.queryParam("type", "video")
|
||||
.queryParam("filter_media_group_uuid", uuid)
|
||||
.queryParam("limit", 1)
|
||||
.queryParam("_sid", sid)
|
||||
.build().toUri()
|
||||
|
||||
val response = webClient.get().uri(searchUri).retrieve().bodyToMono(String::class.java).block()
|
||||
logger.info(">>> [Smart Lookup] Plan A response: $response")
|
||||
val list = mapper.readTree(response).path("data").path("list")
|
||||
|
||||
if (list.isArray && list.size() > 0) {
|
||||
val foundId = list.get(0).path("id").asText()
|
||||
logger.info(">>> [Smart Lookup] Plan A Success! Found ID via UUID: $foundId")
|
||||
return foundId
|
||||
}
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// PLAN B: 파일명 매칭 (UUID 실패 시 시도)
|
||||
// 예: IMG_1234.HEIC -> 같은 폴더의 IMG_1234.MOV 찾기
|
||||
// ========================================================================
|
||||
if (!originalFilename.isNullOrEmpty()) {
|
||||
val baseName = originalFilename.substringBeforeLast(".")
|
||||
// 해당 폴더의 비디오 목록 조회 (최대 1000개)
|
||||
val listUri = org.springframework.web.util.UriComponentsBuilder.fromHttpUrl(baseUrl)
|
||||
.path("/webapi/entry.cgi")
|
||||
.queryParam("api", "SYNO.Foto.Browse.Item")
|
||||
.queryParam("version", "1")
|
||||
.queryParam("method", "list")
|
||||
.queryParam("folder_id", folderId)
|
||||
.queryParam("offset", 0)
|
||||
.queryParam("type", "video")
|
||||
.queryParam("limit", 1000)
|
||||
.queryParam("_sid", sid)
|
||||
.build().toUri()
|
||||
|
||||
val response = webClient.get().uri(listUri).retrieve().bodyToMono(String::class.java).block()
|
||||
logger.info(">>> [Smart Lookup] Plan B response: $response")
|
||||
val list = mapper.readTree(response).path("data").path("list")
|
||||
|
||||
if (list.isArray) {
|
||||
for (node in list) {
|
||||
val videoName = node.path("filename").asText("")
|
||||
val videoBase = videoName.substringBeforeLast(".")
|
||||
|
||||
// 확장자 제외한 이름이 같으면 빙고!
|
||||
if (videoBase.equals(baseName, ignoreCase = true)) {
|
||||
val foundId = node.path("id").asText()
|
||||
logger.info(">>> [Smart Lookup] Plan B Success! Found matching file: $videoName (ID: $foundId)")
|
||||
return foundId
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
logger.warn(">>> [Smart Lookup] Failed. Both Plan A and B failed for file: $originalFilename")
|
||||
return null
|
||||
} catch (e: Exception) {
|
||||
logger.error(">>> [Smart Lookup] Error", e)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
@PostMapping("/login")
|
||||
suspend fun login(@RequestBody request: Map<String, String>): ResponseEntity<Any> {
|
||||
val nasAddress = request["nasAddress"] ?: return ResponseEntity.badRequest().body("No nasAddress")
|
||||
@ -43,11 +140,7 @@ class SynologyProxyController {
|
||||
val uri = "$baseUrl/webapi/auth.cgi?api=SYNO.API.Auth&version=3&method=login&account=$username&passwd=$password&session=Foto&format=cookie"
|
||||
|
||||
return try {
|
||||
val response = webClient.get()
|
||||
.uri(uri)
|
||||
.retrieve()
|
||||
.bodyToMono(String::class.java)
|
||||
.awaitSingle()
|
||||
val response = webClient.get().uri(uri).retrieve().bodyToMono(String::class.java).awaitSingle()
|
||||
ResponseEntity.ok(response)
|
||||
} catch (e: Exception) {
|
||||
ResponseEntity.status(500).body(mapOf("success" to false, "message" to e.message))
|
||||
@ -55,11 +148,8 @@ class SynologyProxyController {
|
||||
}
|
||||
|
||||
@PostMapping("/list")
|
||||
suspend fun getList(
|
||||
@RequestBody request: SynologyListRequest
|
||||
): ResponseEntity<Any> {
|
||||
suspend fun getList(@RequestBody request: SynologyListRequest): ResponseEntity<Any> {
|
||||
val baseUrl = buildBaseUrl(request.nasAddress)
|
||||
|
||||
val uri = org.springframework.web.util.UriComponentsBuilder.fromHttpUrl(baseUrl)
|
||||
.path("/webapi/entry.cgi")
|
||||
.queryParam("api", "SYNO.Foto.Browse.Item")
|
||||
@ -67,18 +157,13 @@ class SynologyProxyController {
|
||||
.queryParam("method", "list")
|
||||
.queryParam("offset", request.offset)
|
||||
.queryParam("limit", request.limit)
|
||||
// [중요] 썸네일 키(cache_key)를 받아오기 위해 필수
|
||||
.queryParam("additional", "[\"thumbnail\"]")
|
||||
// [중요] 정보를 미리 확보 (thumbnail, exif)
|
||||
.queryParam("additional", "[\"thumbnail\",\"exif\"]")
|
||||
.queryParam("_sid", request.sid)
|
||||
.build()
|
||||
.toUri()
|
||||
.build().toUri()
|
||||
|
||||
return try {
|
||||
val response = webClient.get()
|
||||
.uri(uri)
|
||||
.retrieve()
|
||||
.bodyToMono(String::class.java)
|
||||
.awaitSingle()
|
||||
val response = webClient.get().uri(uri).retrieve().bodyToMono(String::class.java).awaitSingle()
|
||||
ResponseEntity.ok(response)
|
||||
} catch (e: Exception) {
|
||||
ResponseEntity.status(500).body(mapOf("success" to false, "message" to e.message))
|
||||
@ -87,23 +172,40 @@ class SynologyProxyController {
|
||||
|
||||
@GetMapping("/geocode")
|
||||
suspend fun reverseGeocode(
|
||||
@RequestParam lat: Double,
|
||||
@RequestParam lon: Double
|
||||
@RequestParam id: String, @RequestParam lat: Double, @RequestParam lon: Double
|
||||
): ResponseEntity<Any> {
|
||||
val uri = "https://nominatim.openstreetmap.org/reverse?format=json&lat=$lat&lon=$lon&zoom=10&accept-language=ko"
|
||||
|
||||
return try {
|
||||
val response = webClient.get()
|
||||
.uri(uri)
|
||||
.header("User-Agent", "SynoPhotoSlideshow/1.0")
|
||||
.retrieve()
|
||||
.bodyToMono(String::class.java)
|
||||
.awaitSingle()
|
||||
|
||||
ResponseEntity.ok(response)
|
||||
} catch (e: Exception) {
|
||||
ResponseEntity.ok("{}")
|
||||
val existingData = photoMetadataRepository.findById(id).awaitSingleOrNull()
|
||||
if (existingData != null) {
|
||||
return ResponseEntity.ok(mapOf(
|
||||
"address" to (existingData.address ?: ""),
|
||||
"memo" to (existingData.memo ?: ""),
|
||||
"tags" to existingData.tags
|
||||
))
|
||||
}
|
||||
val uri = "https://nominatim.openstreetmap.org/reverse?format=json&lat=$lat&lon=$lon&zoom=10&accept-language=ko"
|
||||
return try {
|
||||
val responseString = webClient.get().uri(uri).header("User-Agent", "SynoPhotoSlideshow/1.0").retrieve().bodyToMono(String::class.java).awaitSingle()
|
||||
val node = mapper.readTree(responseString)
|
||||
val addressName = node.path("display_name").asText(null)
|
||||
if (!addressName.isNullOrEmpty()) {
|
||||
val metadata = PhotoMetadata(id = id, address = addressName, latitude = lat, longitude = lon)
|
||||
photoMetadataRepository.save(metadata).awaitSingle()
|
||||
return ResponseEntity.ok(mapOf("address" to addressName, "memo" to "", "tags" to emptyList<String>()))
|
||||
}
|
||||
ResponseEntity.ok(mapOf("address" to "Unknown"))
|
||||
} catch (e: Exception) {
|
||||
ResponseEntity.ok(mapOf("address" to "Unknown"))
|
||||
}
|
||||
}
|
||||
|
||||
@PostMapping("/metadata")
|
||||
suspend fun saveMetadata(@RequestBody req: MetadataRequest): ResponseEntity<Any> {
|
||||
val existingMetadata = photoMetadataRepository.findById(req.id).awaitSingleOrNull()
|
||||
val metadata = existingMetadata ?: PhotoMetadata(id = req.id)
|
||||
metadata.memo = req.memo
|
||||
metadata.tags = req.tags.toMutableList()
|
||||
photoMetadataRepository.save(metadata).awaitSingle()
|
||||
return ResponseEntity.ok(mapOf("success" to true))
|
||||
}
|
||||
|
||||
@GetMapping("/image")
|
||||
@ -112,21 +214,37 @@ class SynologyProxyController {
|
||||
@RequestParam sid: String,
|
||||
@RequestParam id: String,
|
||||
@RequestParam(defaultValue = "download") mode: String,
|
||||
@RequestParam(required = false) cacheKey: String?
|
||||
@RequestParam(required = false) cacheKey: String?,
|
||||
@RequestParam(required = false) folderId: String?,
|
||||
@RequestParam(required = false) uuid: String?,
|
||||
@RequestParam(required = false) filename: String?
|
||||
): ResponseEntity<StreamingResponseBody> {
|
||||
val baseUrl = buildBaseUrl(nasAddress)
|
||||
|
||||
val rawUrl = if (mode == "thumbnail") {
|
||||
val cacheKeyParam = if (!cacheKey.isNullOrEmpty()) "&cache_key=$cacheKey" else ""
|
||||
"$baseUrl/webapi/entry.cgi?api=SYNO.Foto.Thumbnail&version=1&method=get&type=unit&size=xl&id=$id$cacheKeyParam&_sid=$sid"
|
||||
} else {
|
||||
"$baseUrl/webapi/entry.cgi?api=SYNO.Foto.Download&version=1&method=download&force_download=true&item_id=[$id]&_sid=$sid"
|
||||
var targetId = id
|
||||
|
||||
// [Smart Lookup 발동 조건] video 모드 + 폴더ID 존재
|
||||
if (mode == "video" && !folderId.isNullOrEmpty()) {
|
||||
val unitId = fetchLivePhotoUnitId(baseUrl, sid, folderId, uuid, filename)
|
||||
if (unitId != null) targetId = unitId
|
||||
}
|
||||
|
||||
val rawUrl = when (mode) {
|
||||
"thumbnail" -> {
|
||||
val cacheKeyParam = if (!cacheKey.isNullOrEmpty()) "&cache_key=$cacheKey" else ""
|
||||
"$baseUrl/webapi/entry.cgi?api=SYNO.Foto.Thumbnail&version=1&method=get&type=unit&size=xl&id=$id$cacheKeyParam&_sid=$sid"
|
||||
}
|
||||
"video" -> {
|
||||
// 다운로드 API로 비디오 스트리밍 (MP4/MOV 원본)
|
||||
"$baseUrl/webapi/entry.cgi?api=SYNO.Foto.Download&version=1&method=download&id=$targetId&cache_key=$cacheKey&_sid=$sid"
|
||||
}
|
||||
else -> {
|
||||
"$baseUrl/webapi/entry.cgi?api=SYNO.Foto.Download&version=1&method=download&force_download=true&item_id=[$id]&_sid=$sid"
|
||||
}
|
||||
}
|
||||
val uri = java.net.URI.create(rawUrl)
|
||||
|
||||
try {
|
||||
// [해결] retrieve() + toEntityFlux() 사용
|
||||
// exchangeToMono의 복잡한 스코프 문제를 피하고, 헤더와 바디 스트림을 안전하게 가져옵니다.
|
||||
val responseEntity = webClient.get()
|
||||
.uri(uri)
|
||||
.accept(MediaType.ALL)
|
||||
@ -137,30 +255,17 @@ class SynologyProxyController {
|
||||
val contentType = responseEntity.headers.contentType ?: MediaType.APPLICATION_OCTET_STREAM
|
||||
val contentLength = responseEntity.headers.contentLength
|
||||
|
||||
// 1. 에러 체크 (JSON 응답이 오면 에러로 간주)
|
||||
if (contentType.includes(MediaType.APPLICATION_JSON)) {
|
||||
// 스트림을 문자열로 읽어서 로그 출력
|
||||
val errorBody = responseEntity.body?.collectList()?.block()?.joinToString("") {
|
||||
it.toString(StandardCharsets.UTF_8)
|
||||
} ?: "Unknown Error"
|
||||
|
||||
println(">>> NAS Error (ID: $id): $errorBody")
|
||||
return ResponseEntity.notFound().build()
|
||||
}
|
||||
|
||||
// 2. 정상 스트리밍 (DataBufferUtils.write 사용)
|
||||
// Flux<DataBuffer>를 OutputStream에 바로 씁니다. (메모리 효율 최적)
|
||||
val streamingBody = StreamingResponseBody { outputStream ->
|
||||
val flux = responseEntity.body ?: Flux.empty()
|
||||
DataBufferUtils.write(flux, outputStream)
|
||||
.blockLast() // 쓰기가 완료될 때까지 대기
|
||||
DataBufferUtils.write(flux, outputStream).blockLast()
|
||||
}
|
||||
|
||||
val builder = ResponseEntity.ok().contentType(contentType)
|
||||
if (contentLength > 0) {
|
||||
builder.contentLength(contentLength)
|
||||
}
|
||||
|
||||
if (contentLength > 0) builder.contentLength(contentLength)
|
||||
return builder.body(streamingBody)
|
||||
|
||||
} catch (e: Exception) {
|
||||
|
||||
@ -0,0 +1,19 @@
|
||||
package kr.lunaticbum.back.lun.model
|
||||
|
||||
import org.springframework.data.annotation.Id
|
||||
import org.springframework.data.mongodb.core.mapping.Document
|
||||
|
||||
@Document(collection = "photo_metadata")
|
||||
data class PhotoMetadata(
|
||||
@Id
|
||||
val id: String,
|
||||
|
||||
var address: String? = null,
|
||||
var latitude: Double? = null,
|
||||
var longitude: Double? = null,
|
||||
|
||||
var memo: String? = null,
|
||||
|
||||
// [추가] 태그 리스트
|
||||
var tags: MutableList<String> = mutableListOf()
|
||||
)
|
||||
@ -0,0 +1,7 @@
|
||||
package kr.lunaticbum.back.lun.repository
|
||||
|
||||
import kr.lunaticbum.back.lun.model.PhotoMetadata
|
||||
import org.springframework.data.mongodb.repository.ReactiveMongoRepository
|
||||
|
||||
interface PhotoMetadataRepository : ReactiveMongoRepository<PhotoMetadata,String> {
|
||||
}
|
||||
@ -2,7 +2,8 @@
|
||||
<html xmlns:th="http://www.thymeleaf.org">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
||||
<meta name="theme-color" content="#000000">
|
||||
<title>BUM's NAS Slideshow</title>
|
||||
|
||||
<script async src="https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js?client=ca-pub-9504446465764716" crossorigin="anonymous"></script>
|
||||
@ -16,6 +17,9 @@
|
||||
|
||||
body, html { margin: 0; padding: 0; width: 100%; height: 100%; overflow: hidden; display: flex; flex-direction: column; font-family: 'Noto Sans KR', sans-serif; background: #000; }
|
||||
|
||||
:fullscreen { background: #000; }
|
||||
::backdrop { background: #000; }
|
||||
|
||||
#bg-layer {
|
||||
position: absolute; top: 0; left: 0; width: 100%; height: 100%;
|
||||
background: linear-gradient(to top, #09203f 0%, #537895 100%);
|
||||
@ -30,16 +34,17 @@
|
||||
|
||||
#card-stage {
|
||||
position: relative; width: 100%; height: 100%;
|
||||
display: flex; justify-content: center; align-items: center; padding-bottom: 0; /* 하단 여백 제거 */
|
||||
display: flex; justify-content: center; align-items: center; padding-bottom: 0;
|
||||
}
|
||||
|
||||
.polaroid-card {
|
||||
position: absolute; background-color: #fff; color: #333;
|
||||
box-shadow: 0 25px 60px rgba(0,0,0,0.5);
|
||||
width: 320px; height: 400px;
|
||||
box-shadow: 0 10px 30px rgba(0,0,0,0.5);
|
||||
width: 300px; height: 400px;
|
||||
display: flex; flex-direction: column; border-radius: 4px;
|
||||
transition: opacity var(--anim-duration) ease-in-out, transform var(--anim-duration) cubic-bezier(0.25, 0.8, 0.25, 1);
|
||||
opacity: 0; z-index: 1; transform: scale(1.05); will-change: opacity, transform; overflow: hidden;
|
||||
max-width: 94vw; max-height: 90vh;
|
||||
}
|
||||
.polaroid-card.active { opacity: 1; z-index: 10; transform: scale(1); }
|
||||
|
||||
@ -66,7 +71,7 @@
|
||||
}
|
||||
.p-date {
|
||||
font-size: calc(1.8em * var(--text-scale)); font-weight: bold; line-height: 1.2;
|
||||
margin-bottom: calc(15px * var(--text-scale)); transition: font-size 0.3s, margin-bottom 0.3s; min-height: 1.2em;
|
||||
margin-bottom: calc(5px * var(--text-scale)); transition: font-size 0.3s, margin-bottom 0.3s; min-height: 1.2em;
|
||||
}
|
||||
.p-location {
|
||||
font-size: calc(1.3em * var(--text-scale)); color: #555; display: flex; align-items: center; gap: 5px;
|
||||
@ -74,40 +79,57 @@
|
||||
}
|
||||
.typing-cursor::after { content: '|'; display: inline-block; margin-left: 2px; color: #e44c65; animation: blink 0.8s step-start infinite; font-weight: normal; }
|
||||
@keyframes blink { 50% { opacity: 0; } }
|
||||
.p-keywords { font-size: calc(1.0em * var(--text-scale)); color: #e44c65; margin-left: 10px; transition: font-size 0.3s; }
|
||||
|
||||
.p-meta-info {
|
||||
font-size: 0.9em; color: #666; margin-top: 5px;
|
||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
font-family: 'Noto Sans KR', sans-serif;
|
||||
display: flex; gap: 5px; align-items: center; flex-wrap: wrap;
|
||||
}
|
||||
.tag-badge {
|
||||
background: #eee; color: #555; padding: 2px 6px; border-radius: 4px;
|
||||
font-size: 0.8em; display: inline-block; margin-right: 2px;
|
||||
}
|
||||
.memo-text { color: #888; margin-left: 5px; font-size: 0.9em; }
|
||||
|
||||
#exif-full-overlay {
|
||||
position: absolute; bottom: 90px; right: 20px; z-index: 25;
|
||||
background: rgba(0, 0, 0, 0.75); padding: 15px; border-radius: 10px;
|
||||
background: rgba(0, 0, 0, 0.85); padding: 15px; border-radius: 10px;
|
||||
width: 280px; max-height: 60vh; overflow-y: auto;
|
||||
font-size: 0.85em; color: #ccc; backdrop-filter: blur(5px);
|
||||
display: none; text-align: right; box-shadow: 0 5px 20px rgba(0,0,0,0.5); border: 1px solid rgba(255,255,255,0.1);
|
||||
}
|
||||
#exif-full-overlay::-webkit-scrollbar { width: 6px; }
|
||||
#exif-full-overlay::-webkit-scrollbar-thumb { background: #555; border-radius: 3px; }
|
||||
.exif-item { border-bottom: 1px dashed #444; padding: 4px 0; display: flex; justify-content: space-between; }
|
||||
.exif-key { color: #888; font-weight: bold; margin-right: 10px; }
|
||||
.exif-val { color: #fff; word-break: break-all; text-align: right; }
|
||||
@media (max-width: 600px) {
|
||||
#exif-full-overlay { right: 10px; left: 10px; width: auto; bottom: 120px; }
|
||||
}
|
||||
|
||||
/* [수정] 컨트롤 바: 평소엔 숨김 */
|
||||
#controls-bar {
|
||||
position: absolute; bottom: 30px;
|
||||
display: flex; align-items: center; gap: 15px;
|
||||
background: rgba(0, 0, 0, 0.6); backdrop-filter: blur(5px);
|
||||
padding: 10px 25px; border-radius: 50px;
|
||||
z-index: 50;
|
||||
/* 애니메이션 설정 */
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
opacity: 0; transform: translateY(20px);
|
||||
transition: opacity 0.4s ease, transform 0.4s ease;
|
||||
pointer-events: none; /* 숨겨진 상태에선 클릭 방지 */
|
||||
pointer-events: none;
|
||||
max-width: 95%; overflow-x: auto; white-space: nowrap;
|
||||
}
|
||||
#controls-bar.controls-visible { opacity: 1; transform: translateY(0); pointer-events: auto; }
|
||||
#controls-bar::-webkit-scrollbar { display: none; }
|
||||
|
||||
/* [NEW] 활성화 클래스 */
|
||||
#controls-bar.controls-visible {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
pointer-events: auto; /* 보이게 되면 클릭 허용 */
|
||||
.ctrl-filter-group {
|
||||
display: flex; gap: 10px; align-items: center; background: rgba(255,255,255,0.1); padding: 5px 15px; border-radius: 20px; margin-right: 5px;
|
||||
}
|
||||
.ctrl-filter-group label {
|
||||
color: #ccc; font-size: 0.8em; cursor: pointer; display: flex; align-items: center; gap: 4px; user-select: none;
|
||||
}
|
||||
.ctrl-filter-group input { accent-color: #e44c65; cursor: pointer; }
|
||||
|
||||
@media (max-width: 600px) {
|
||||
#controls-bar { gap: 10px; padding: 10px 15px; bottom: 20px; }
|
||||
.slider-group, .divider { display: none; }
|
||||
.ctrl-filter-group { padding: 5px 10px; gap: 8px; }
|
||||
.ctrl-filter-group label span { display: none; }
|
||||
}
|
||||
|
||||
#palette-panel { position: absolute; bottom: 100px; left: 50%; transform: translateX(-50%); background: rgba(255,255,255,0.9); padding: 10px; border-radius: 10px; display: none; grid-template-columns: repeat(6, 1fr); gap: 8px; box-shadow: 0 5px 15px rgba(0,0,0,0.3); z-index: 30; }
|
||||
@ -116,7 +138,7 @@
|
||||
.color-chip.auto-chip { background: linear-gradient(135deg, red, orange, yellow, green, blue, indigo, violet); }
|
||||
.color-chip.auto-chip::after { content: 'A'; position: absolute; top:50%; left:50%; transform:translate(-50%, -50%); color: white; font-weight: bold; font-family: sans-serif; text-shadow: 0 0 2px black; }
|
||||
|
||||
.ctrl-btn { background: none; border: none; color: white; font-size: 1.2em; cursor: pointer; width: 36px; height: 36px; border-radius: 50%; display: flex; align-items: center; justify-content: center; transition: 0.2s; }
|
||||
.ctrl-btn { background: none; border: none; color: white; font-size: 1.2em; cursor: pointer; width: 36px; height: 36px; border-radius: 50%; display: flex; align-items: center; justify-content: center; transition: 0.2s; flex-shrink: 0; }
|
||||
.ctrl-btn:hover { background: rgba(255,255,255,0.2); color: #e44c65; }
|
||||
.ctrl-btn.active { color: #e44c65; background: rgba(255,255,255,0.1); }
|
||||
|
||||
@ -133,6 +155,31 @@
|
||||
#login-form input { padding: 12px; border: 1px solid #444; background: #333; color: #fff; border-radius: 6px; }
|
||||
#login-form button { padding: 12px; background: #e44c65; border: none; color: white; cursor: pointer; border-radius: 6px; font-weight: bold; }
|
||||
.exit-btn { position: absolute; top: 20px; right: 20px; z-index: 1000; color: rgba(255,255,255,0.7); text-decoration: none; background: rgba(0,0,0,0.5); padding: 8px 15px; border-radius: 30px; font-size: 0.9em; }
|
||||
|
||||
/* 편집 모달 */
|
||||
#edit-modal {
|
||||
position: fixed; top: 0; left: 0; width: 100%; height: 100%;
|
||||
background: rgba(0,0,0,0.8); z-index: 2000;
|
||||
display: none; justify-content: center; align-items: center;
|
||||
backdrop-filter: blur(5px);
|
||||
}
|
||||
.modal-content {
|
||||
background: #222; color: #fff; padding: 25px; border-radius: 15px;
|
||||
width: 90%; max-width: 400px; display: flex; flex-direction: column; gap: 15px;
|
||||
box-shadow: 0 10px 30px rgba(0,0,0,0.5); border: 1px solid #444;
|
||||
}
|
||||
.modal-title { font-size: 1.2em; font-weight: bold; color: #e44c65; }
|
||||
.modal-input {
|
||||
width: 100%; padding: 12px; border-radius: 5px; border: 1px solid #555;
|
||||
background: #333; color: #fff; font-family: inherit; box-sizing: border-box; outline: none;
|
||||
}
|
||||
.modal-input:focus { border-color: #e44c65; }
|
||||
.modal-btns { display: flex; gap: 10px; justify-content: flex-end; margin-top: 10px; }
|
||||
.m-btn { padding: 10px 20px; border-radius: 5px; border: none; cursor: pointer; font-weight: bold; transition: 0.2s; }
|
||||
.btn-save { background: #e44c65; color: white; }
|
||||
.btn-save:hover { background: #ff6b81; }
|
||||
.btn-cancel { background: #555; color: white; }
|
||||
.btn-cancel:hover { background: #666; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@ -152,7 +199,8 @@
|
||||
</div>
|
||||
<div class="p-bottom">
|
||||
<div class="p-date"></div>
|
||||
<div class="p-location"><i class="fas fa-map-marker-alt" style="color:#e44c65; font-size:0.8em;"></i><span class="p-address">Loading...</span><span class="p-tags p-keywords"></span></div>
|
||||
<div class="p-location"><i class="fas fa-map-marker-alt" style="color:#e44c65; font-size:0.8em;"></i><span class="p-address">Loading...</span></div>
|
||||
<div class="p-meta-info"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -167,7 +215,8 @@
|
||||
</div>
|
||||
<div class="p-bottom">
|
||||
<div class="p-date"></div>
|
||||
<div class="p-location"><i class="fas fa-map-marker-alt" style="color:#e44c65; font-size:0.8em;"></i><span class="p-address">Loading...</span><span class="p-tags p-keywords"></span></div>
|
||||
<div class="p-location"><i class="fas fa-map-marker-alt" style="color:#e44c65; font-size:0.8em;"></i><span class="p-address">Loading...</span></div>
|
||||
<div class="p-meta-info"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -175,11 +224,40 @@
|
||||
<div id="palette-panel"></div>
|
||||
<div id="exif-full-overlay"><div style="color:#e44c65; font-weight:bold; margin-bottom:10px; border-bottom:1px solid #555; padding-bottom:5px;">EXIF INFO</div><div id="exif-list">Loading...</div></div>
|
||||
|
||||
<div id="edit-modal">
|
||||
<div class="modal-content">
|
||||
<div class="modal-title"><i class="fas fa-edit"></i> 정보 수정</div>
|
||||
|
||||
<div>
|
||||
<label style="font-size:0.9em; color:#aaa; display:block; margin-bottom:5px;">메모</label>
|
||||
<input type="text" id="edit-memo" class="modal-input" placeholder="이 사진에 대한 메모...">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style="font-size:0.9em; color:#aaa; display:block; margin-bottom:5px;">태그 (콤마로 구분)</label>
|
||||
<input type="text" id="edit-tags" class="modal-input" placeholder="예: 여행, 가족, 맛집">
|
||||
</div>
|
||||
|
||||
<div class="modal-btns">
|
||||
<button class="m-btn btn-cancel" onclick="closeEditModal()">취소</button>
|
||||
<button class="m-btn btn-save" onclick="saveMetadata()">저장</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="controls-bar">
|
||||
<div class="ctrl-filter-group" title="필터">
|
||||
<label><input type="checkbox" id="chk-photo" checked><i class="fas fa-image"></i><span>Img</span></label>
|
||||
<label><input type="checkbox" id="chk-video" checked><i class="fas fa-video"></i><span>Vid</span></label>
|
||||
<label><input type="checkbox" id="chk-live" checked><i class="fas fa-bolt"></i><span>Live</span></label>
|
||||
</div>
|
||||
|
||||
<button class="ctrl-btn" onclick="prevPhoto()" title="이전"><i class="fas fa-step-backward"></i></button>
|
||||
<button class="ctrl-btn" onclick="togglePause()" id="btn-play" title="재생/정지"><i class="fas fa-pause"></i></button>
|
||||
<button class="ctrl-btn" onclick="nextPhotoManual()" title="다음"><i class="fas fa-step-forward"></i></button>
|
||||
|
||||
<div class="divider"></div>
|
||||
|
||||
<div class="slider-group" title="사진 대기 시간">
|
||||
<i class="fas fa-stopwatch"></i><input type="range" min="3" max="60" value="10" step="1" oninput="updateSpeed(this.value)"><span class="slider-label"><span id="speed-display">10</span>s</span>
|
||||
</div>
|
||||
@ -191,9 +269,12 @@
|
||||
<div class="slider-group" title="텍스트 프레임 크기">
|
||||
<i class="fas fa-text-height"></i><input type="range" min="0.6" max="1.4" value="1.0" step="0.1" oninput="updateTextScale(this.value)">
|
||||
</div>
|
||||
|
||||
<div class="divider"></div>
|
||||
<button class="ctrl-btn" id="btn-edit" onclick="openEditModal()" title="정보 편집"><i class="fas fa-pen"></i></button>
|
||||
<button class="ctrl-btn" id="btn-info" onclick="toggleInfo()" title="상세 정보"><i class="fas fa-info-circle"></i></button>
|
||||
<button class="ctrl-btn" onclick="togglePalette()" title="배경 변경"><i class="fas fa-palette"></i></button>
|
||||
<button class="ctrl-btn" onclick="toggleFullscreen()" id="btn-fullscreen" title="전체화면"><i class="fas fa-expand"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -230,10 +311,10 @@
|
||||
};
|
||||
let isDynamicBg = true;
|
||||
let showFullExif = false;
|
||||
|
||||
// [NEW] 컨트롤 바 숨김/표시 타이머 변수
|
||||
let controlsHideTimer;
|
||||
|
||||
let currentMetadata = { id: null, memo: "", tags: [] };
|
||||
|
||||
const csrfToken = document.querySelector('meta[name="_csrf"]').getAttribute('content');
|
||||
const csrfHeader = document.querySelector('meta[name="_csrf_header"]').getAttribute('content');
|
||||
|
||||
@ -249,34 +330,106 @@
|
||||
|
||||
window.onload = () => {
|
||||
initPalette();
|
||||
initControlsAutoHider(); // [NEW] 자동 숨김 초기화
|
||||
initControlsAutoHider();
|
||||
initFullscreenListener();
|
||||
};
|
||||
|
||||
// [NEW] 컨트롤 바 자동 숨김 로직
|
||||
function toggleFullscreen() {
|
||||
if (!document.fullscreenElement) {
|
||||
document.documentElement.requestFullscreen().catch(err => console.error(err));
|
||||
} else {
|
||||
if (document.exitFullscreen) document.exitFullscreen();
|
||||
}
|
||||
}
|
||||
|
||||
function initFullscreenListener() {
|
||||
document.addEventListener('fullscreenchange', () => {
|
||||
const btn = document.getElementById('btn-fullscreen');
|
||||
if (document.fullscreenElement) {
|
||||
btn.innerHTML = '<i class="fas fa-compress"></i>';
|
||||
btn.title = "전체화면 종료";
|
||||
} else {
|
||||
btn.innerHTML = '<i class="fas fa-expand"></i>';
|
||||
btn.title = "전체화면";
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function openEditModal() {
|
||||
if (!currentMetadata.id) return;
|
||||
if (!isPaused) togglePause();
|
||||
document.getElementById('edit-memo').value = currentMetadata.memo || "";
|
||||
document.getElementById('edit-tags').value = currentMetadata.tags ? currentMetadata.tags.join(", ") : "";
|
||||
document.getElementById('edit-modal').style.display = 'flex';
|
||||
}
|
||||
|
||||
function closeEditModal() {
|
||||
document.getElementById('edit-modal').style.display = 'none';
|
||||
}
|
||||
|
||||
async function saveMetadata() {
|
||||
const memoVal = document.getElementById('edit-memo').value.trim();
|
||||
const tagsStr = document.getElementById('edit-tags').value.trim();
|
||||
const tagsArr = tagsStr.split(',').map(t => t.trim()).filter(t => t !== "");
|
||||
|
||||
const payload = {
|
||||
id: currentMetadata.id,
|
||||
memo: memoVal,
|
||||
tags: tagsArr
|
||||
};
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/synology/metadata', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', [csrfHeader]: csrfToken },
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
currentMetadata.memo = memoVal;
|
||||
currentMetadata.tags = tagsArr;
|
||||
updateCardMetaUI(activeCardNum);
|
||||
closeEditModal();
|
||||
} else {
|
||||
alert("저장 실패");
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
alert("네트워크 오류");
|
||||
}
|
||||
}
|
||||
|
||||
function updateCardMetaUI(cardNum) {
|
||||
const card = document.getElementById(`polaroid-card-${cardNum}`);
|
||||
const metaDiv = card.querySelector('.p-meta-info');
|
||||
let html = "";
|
||||
if (currentMetadata.tags && currentMetadata.tags.length > 0) {
|
||||
currentMetadata.tags.forEach(tag => {
|
||||
html += `<span class="tag-badge">#${tag}</span>`;
|
||||
});
|
||||
}
|
||||
if (currentMetadata.memo) {
|
||||
html += `<span class="memo-text">${currentMetadata.memo}</span>`;
|
||||
}
|
||||
metaDiv.innerHTML = html;
|
||||
}
|
||||
|
||||
function initControlsAutoHider() {
|
||||
const controls = document.getElementById('controls-bar');
|
||||
|
||||
function showControls() {
|
||||
controls.classList.add('controls-visible');
|
||||
resetHideTimer();
|
||||
}
|
||||
|
||||
function resetHideTimer() {
|
||||
clearTimeout(controlsHideTimer);
|
||||
// 마우스가 컨트롤 바 위에 있거나 슬라이더 조작 중일 때는 숨기지 않음
|
||||
if (controls.matches(':hover')) return;
|
||||
|
||||
controlsHideTimer = setTimeout(() => {
|
||||
controls.classList.remove('controls-visible');
|
||||
}, 3000); // 3초 뒤 숨김
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
// 이벤트 감지: 마우스 이동, 클릭, 터치
|
||||
document.addEventListener('mousemove', showControls);
|
||||
document.addEventListener('click', showControls);
|
||||
document.addEventListener('touchstart', showControls);
|
||||
|
||||
// 컨트롤 바에서 마우스가 나갈 때 타이머 재시작
|
||||
controls.addEventListener('mouseleave', resetHideTimer);
|
||||
}
|
||||
|
||||
@ -320,11 +473,10 @@
|
||||
|
||||
function showMessage(msg) { document.getElementById('message').innerText = msg; }
|
||||
|
||||
// [수정] 슬라이더 조작 시 타이머 리셋 (숨김 방지)
|
||||
function updateSpeed(val) {
|
||||
currentSpeed = val * 1000;
|
||||
document.getElementById('speed-display').innerText = val;
|
||||
clearTimeout(controlsHideTimer); // 조작 중엔 숨기지 않음
|
||||
clearTimeout(controlsHideTimer);
|
||||
}
|
||||
function updateAnimSpeed(val) {
|
||||
document.documentElement.style.setProperty('--anim-duration', val + 's');
|
||||
@ -360,11 +512,26 @@
|
||||
if (data.success) {
|
||||
data.data.list.forEach(item => {
|
||||
if (item.type === 'folder') return;
|
||||
|
||||
// [중요] 타입 판별 및 저장
|
||||
let type = 'photo';
|
||||
if (item.type === 'video') type = 'video';
|
||||
else if (item.type === 'live' || (item.additional && item.additional.live_photo)) type = 'live';
|
||||
|
||||
item._customType = type; // 필터링용 태그
|
||||
allPhotoItems.push(item);
|
||||
});
|
||||
|
||||
const total = data.data.total;
|
||||
if (offset + limit < total) { showMessage(`${allPhotoItems.length}장 로딩 중...`); await fetchAllPhotos(offset + limit); }
|
||||
else { document.getElementById('login-overlay').style.display = 'none'; loadNextPhoto(); }
|
||||
else {
|
||||
if (allPhotoItems.length === 0) {
|
||||
showMessage("No items found.");
|
||||
return;
|
||||
}
|
||||
document.getElementById('login-overlay').style.display = 'none';
|
||||
loadNextPhoto();
|
||||
}
|
||||
}
|
||||
} catch (error) { showMessage('Fetch Failed'); }
|
||||
}
|
||||
@ -379,12 +546,54 @@
|
||||
function nextPhotoManual() { isPaused = true; updatePlayButton(); loadNextPhoto(true); }
|
||||
function updatePlayButton() { document.getElementById('btn-play').innerHTML = isPaused ? '<i class="fas fa-play"></i>' : '<i class="fas fa-pause"></i>'; }
|
||||
|
||||
// [핵심] 랜덤 뽑기 시 필터 체크
|
||||
function loadNextPhoto(forceNew = false) {
|
||||
clearTimeout(slideshowTimeout);
|
||||
if (allPhotoItems.length === 0) return;
|
||||
let nextItem;
|
||||
if (!forceNew && historyIndex < photoHistory.length - 1) { historyIndex++; nextItem = photoHistory[historyIndex]; }
|
||||
else { nextItem = allPhotoItems[Math.floor(Math.random() * allPhotoItems.length)]; photoHistory.push(nextItem); historyIndex = photoHistory.length - 1; }
|
||||
|
||||
// 1. 현재 필터 상태 확인
|
||||
const showPhoto = document.getElementById('chk-photo').checked;
|
||||
const showVideo = document.getElementById('chk-video').checked;
|
||||
const showLive = document.getElementById('chk-live').checked;
|
||||
|
||||
if (!showPhoto && !showVideo && !showLive) {
|
||||
if(!isPaused) togglePause();
|
||||
alert("적어도 하나의 미디어 타입은 선택해야 합니다.");
|
||||
return;
|
||||
}
|
||||
|
||||
let nextItem = null;
|
||||
|
||||
if (!forceNew && historyIndex < photoHistory.length - 1) {
|
||||
historyIndex++;
|
||||
nextItem = photoHistory[historyIndex];
|
||||
}
|
||||
else {
|
||||
// 필터에 맞는 아이템 찾을 때까지 랜덤 시도
|
||||
let maxAttempts = 2000;
|
||||
while (maxAttempts > 0) {
|
||||
const candidate = allPhotoItems[Math.floor(Math.random() * allPhotoItems.length)];
|
||||
const type = candidate._customType;
|
||||
|
||||
if ((type === 'photo' && showPhoto) ||
|
||||
(type === 'video' && showVideo) ||
|
||||
(type === 'live' && showLive)) {
|
||||
nextItem = candidate;
|
||||
break;
|
||||
}
|
||||
maxAttempts--;
|
||||
}
|
||||
|
||||
if (!nextItem) {
|
||||
alert("조건에 맞는 사진이 없습니다.");
|
||||
if(!isPaused) togglePause();
|
||||
return;
|
||||
}
|
||||
|
||||
photoHistory.push(nextItem);
|
||||
historyIndex = photoHistory.length - 1;
|
||||
}
|
||||
|
||||
if (photoHistory.length > 100) { photoHistory.shift(); historyIndex--; }
|
||||
displayPhoto(nextItem);
|
||||
}
|
||||
@ -435,19 +644,56 @@
|
||||
return rawExts.includes(ext) || heicExts.includes(ext);
|
||||
}
|
||||
|
||||
function displayPhoto(item) {
|
||||
async function displayPhoto(item) {
|
||||
let cacheKey = "";
|
||||
if (item.additional && item.additional.thumbnail && item.additional.thumbnail.cache_key) {
|
||||
cacheKey = item.additional.thumbnail.cache_key;
|
||||
}
|
||||
|
||||
let baseApiUrl = `/api/synology/image?nasAddress=${encodeURIComponent(nasAddress)}&sid=${sid}&id=${item.id}&cacheKey=${cacheKey}`;
|
||||
// [정보 추출: FolderID, UUID, Filename]
|
||||
let folderId = item.folder_id;
|
||||
let mediaUuid = null;
|
||||
if (item.additional && item.additional.exif) {
|
||||
if (item.additional.exif.MediaGroupUUID) {
|
||||
mediaUuid = item.additional.exif.MediaGroupUUID;
|
||||
} else if (item.additional.exif.ContentIdentifier) {
|
||||
const cid = item.additional.exif.ContentIdentifier;
|
||||
mediaUuid = cid.includes('#') ? cid.split('#')[1] : cid;
|
||||
}
|
||||
}
|
||||
|
||||
let liveUnitId = null;
|
||||
if (item.additional && item.additional.thumbnail && item.additional.thumbnail.live_photo_unit_id) {
|
||||
liveUnitId = item.additional.thumbnail.live_photo_unit_id;
|
||||
}
|
||||
|
||||
const isLive = (item.type === 'live' || !!liveUnitId || !!mediaUuid);
|
||||
const isVideo = isVideoItem(item);
|
||||
const isRaw = isRawItem(item);
|
||||
|
||||
const requestMode = isRaw ? '&mode=thumbnail' : '&mode=download';
|
||||
const finalUrl = baseApiUrl + requestMode;
|
||||
let requestMode = '&mode=download';
|
||||
let extraParams = '';
|
||||
|
||||
if (isLive) {
|
||||
// if (liveUnitId) {
|
||||
// requestMode = '&mode=video';
|
||||
// } else if (folderId) {
|
||||
// // [핵심] 백엔드의 Plan A(UUID) + Plan B(Filename)를 위해 정보 전달
|
||||
// requestMode = '&mode=video';
|
||||
// extraParams = `&folderId=${folderId}&filename=${encodeURIComponent(item.filename)}`;
|
||||
// if (mediaUuid) extraParams += `&uuid=${encodeURIComponent(mediaUuid)}`;
|
||||
// } else {
|
||||
requestMode = '&mode=thumbnail';
|
||||
// }
|
||||
} else if (isVideo) {
|
||||
requestMode = '&mode=video';
|
||||
} else if (isRaw) {
|
||||
requestMode = '&mode=thumbnail';
|
||||
}
|
||||
|
||||
let targetId = liveUnitId || item.id;
|
||||
let baseApiUrl = `/api/synology/image?nasAddress=${encodeURIComponent(nasAddress)}&sid=${sid}&id=${targetId}&cacheKey=${cacheKey}`;
|
||||
const finalUrl = baseApiUrl + requestMode + extraParams;
|
||||
|
||||
const nextCardNum = (activeCardNum === 1) ? 2 : 1;
|
||||
const nextCard = document.getElementById(`polaroid-card-${nextCardNum}`);
|
||||
@ -455,9 +701,30 @@
|
||||
const nextImgEl = nextCard.querySelector('.card-img');
|
||||
const nextVidEl = nextCard.querySelector('.card-vid');
|
||||
|
||||
currentMetadata = { id: item.id, memo: "", tags: [] };
|
||||
|
||||
let imageSrc = finalUrl;
|
||||
const isPlayingVideo = (requestMode === '&mode=video');
|
||||
|
||||
if (!isPlayingVideo) {
|
||||
try {
|
||||
const response = await fetch(finalUrl);
|
||||
if (!response.ok) throw new Error("Network response was not ok");
|
||||
const blob = await response.blob();
|
||||
imageSrc = URL.createObjectURL(blob);
|
||||
} catch (err) {
|
||||
console.error("Image Fetch Error:", err);
|
||||
imageSrc = finalUrl;
|
||||
}
|
||||
}
|
||||
|
||||
const tempImgUrl = isPlayingVideo ?
|
||||
`/api/synology/image?nasAddress=${encodeURIComponent(nasAddress)}&sid=${sid}&id=${item.id}&cacheKey=${cacheKey}&mode=thumbnail` :
|
||||
imageSrc;
|
||||
|
||||
const tempImg = new Image();
|
||||
tempImg.crossOrigin = "Anonymous";
|
||||
tempImg.src = finalUrl;
|
||||
tempImg.src = tempImgUrl;
|
||||
|
||||
tempImg.onload = function() {
|
||||
const dims = calculateOptimalSize(tempImg.naturalWidth, tempImg.naturalHeight);
|
||||
@ -468,17 +735,18 @@
|
||||
|
||||
resetCardInfo(nextCard, nextCardNum);
|
||||
|
||||
if (isVideo) {
|
||||
if (isPlayingVideo) {
|
||||
nextImgEl.style.display = 'none';
|
||||
nextVidEl.style.display = 'block';
|
||||
nextVidEl.removeAttribute('src');
|
||||
nextVidEl.src = finalUrl;
|
||||
nextVidEl.load();
|
||||
nextVidEl.play().catch(e => console.log("Auto-play blocked", e));
|
||||
|
||||
let dateText = (item.type === 'live') ? "Live Photo" : "Video";
|
||||
let dateText = isLive ? "Live Photo" : "Video";
|
||||
if (item.takenTime) dateText = new Date(item.takenTime * 1000).toLocaleDateString();
|
||||
typeText(nextCard.querySelector('.p-date'), dateText, nextCardNum, 'date', null);
|
||||
document.getElementById('exif-list').innerHTML = `<div style='text-align:center'>${item.type === 'live' ? 'Live Photo' : 'Video File'}<br>${item.filename || ""}</div>`;
|
||||
document.getElementById('exif-list').innerHTML = `<div style='text-align:center'>${dateText}<br>${item.filename || ""}</div>`;
|
||||
|
||||
updateBgFromImage(tempImg);
|
||||
} else {
|
||||
@ -486,11 +754,11 @@
|
||||
nextVidEl.pause();
|
||||
nextVidEl.removeAttribute('src');
|
||||
nextImgEl.style.display = 'block';
|
||||
nextImgEl.src = tempImg.src;
|
||||
nextImgEl.src = imageSrc;
|
||||
|
||||
let nextFullExifHtml = "<div style='text-align:center'>No Data</div>";
|
||||
EXIF.getData(tempImg, function() {
|
||||
bindExifData(this, nextCard, nextCardNum);
|
||||
bindExifDataOnly(this, nextCard, nextCardNum);
|
||||
nextFullExifHtml = generateFullExifHtml(this);
|
||||
if (isRaw) {
|
||||
document.getElementById('exif-list').innerHTML = `<div style='text-align:center'>RAW/HEIC Preview<br>${item.filename}</div>`;
|
||||
@ -498,7 +766,6 @@
|
||||
});
|
||||
|
||||
updateBgFromImage(tempImg);
|
||||
|
||||
if (!isRaw) {
|
||||
requestAnimationFrame(() => {
|
||||
document.getElementById('exif-list').innerHTML = nextFullExifHtml;
|
||||
@ -506,9 +773,17 @@
|
||||
}
|
||||
}
|
||||
|
||||
fetchMetadataAndAddress(item, nextCard, nextCardNum);
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
nextCard.classList.add('active');
|
||||
currentCard.classList.remove('active');
|
||||
|
||||
const prevImg = currentCard.querySelector('.card-img');
|
||||
if (prevImg.src.startsWith('blob:')) {
|
||||
URL.revokeObjectURL(prevImg.src);
|
||||
prevImg.removeAttribute('src');
|
||||
}
|
||||
const prevVid = currentCard.querySelector('.card-vid');
|
||||
if(prevVid) { prevVid.pause(); prevVid.removeAttribute('src'); prevVid.load(); }
|
||||
});
|
||||
@ -527,24 +802,11 @@
|
||||
};
|
||||
}
|
||||
|
||||
function generateFullExifHtml(img) {
|
||||
const allTags = EXIF.getAllTags(img);
|
||||
let html = "";
|
||||
for (let key in allTags) {
|
||||
if (key === 'thumbnail' || key.startsWith('Thumbnail')) continue;
|
||||
let val = allTags[key];
|
||||
if (typeof val === 'object') {
|
||||
if (val instanceof Number) val = val.valueOf(); else val = JSON.stringify(val);
|
||||
}
|
||||
if (String(val).length > 40) val = String(val).substring(0, 40) + "...";
|
||||
html += `<div class="exif-item"><span class="exif-key">${key}</span><span class="exif-val">${val}</span></div>`;
|
||||
}
|
||||
return html || "<div style='text-align:center'>No EXIF Data</div>";
|
||||
}
|
||||
|
||||
function calculateOptimalSize(imgW, imgH) {
|
||||
const maxW = window.innerWidth * 0.95;
|
||||
const maxH = window.innerHeight * 0.82;
|
||||
const safeMargin = 20;
|
||||
const maxW = window.innerWidth - safeMargin;
|
||||
const maxH = window.innerHeight * 0.85;
|
||||
|
||||
const scale = parseFloat(getComputedStyle(document.documentElement).getPropertyValue('--text-scale')) || 1.0;
|
||||
const chromeH = (30 * scale) + (70 * scale) + 20;
|
||||
const maxImgH = maxH - chromeH;
|
||||
@ -558,7 +820,6 @@
|
||||
targetImgW = targetImgW * scaleFactor;
|
||||
targetImgH = targetImgH * scaleFactor;
|
||||
}
|
||||
if (targetImgW < 320) targetImgW = 320;
|
||||
|
||||
return {
|
||||
cardWidth: Math.round(targetImgW),
|
||||
@ -570,14 +831,9 @@
|
||||
card.querySelector('.p-camera').innerHTML = '<i class="fas fa-camera"></i> -';
|
||||
card.querySelector('.p-zoom').innerHTML = '<i class="fas fa-search"></i> -';
|
||||
card.querySelector('.p-coords').innerText = '';
|
||||
|
||||
const dateEl = card.querySelector('.p-date');
|
||||
dateEl.innerText = ''; dateEl.classList.remove('typing-cursor');
|
||||
|
||||
const addrEl = card.querySelector('.p-address');
|
||||
addrEl.innerText = ''; addrEl.classList.remove('typing-cursor');
|
||||
|
||||
card.querySelector('.p-tags').innerText = '';
|
||||
card.querySelector('.p-date').innerText = '';
|
||||
card.querySelector('.p-address').innerText = '';
|
||||
card.querySelector('.p-meta-info').innerHTML = '';
|
||||
|
||||
if(typingTimers[cardNum].date) clearTimeout(typingTimers[cardNum].date);
|
||||
if(typingTimers[cardNum].addr) clearTimeout(typingTimers[cardNum].addr);
|
||||
@ -601,7 +857,7 @@
|
||||
typeFunc();
|
||||
}
|
||||
|
||||
function bindExifData(img, card, cardNum) {
|
||||
function bindExifDataOnly(img, card, cardNum) {
|
||||
const make = EXIF.getTag(img, "Make"); const model = EXIF.getTag(img, "Model");
|
||||
let camText = model ? model : (make ? make : "Unknown Camera");
|
||||
if(camText.length > 20) camText = camText.substring(0, 18) + "..";
|
||||
@ -616,42 +872,55 @@
|
||||
const parts = dateTaken.split(/[: ]/);
|
||||
if(parts.length >= 3) dateStr = `${parts[0]}. ${parts[1]}. ${parts[2]}.`;
|
||||
}
|
||||
typeText(card.querySelector('.p-date'), dateStr, cardNum, 'date');
|
||||
|
||||
const lat = EXIF.getTag(img, "GPSLatitude"); const lon = EXIF.getTag(img, "GPSLongitude");
|
||||
const addrEl = card.querySelector('.p-address');
|
||||
const dateEl = card.querySelector('.p-date');
|
||||
|
||||
let addrPromise = Promise.resolve("No GPS Info");
|
||||
|
||||
if (lat && lon) {
|
||||
const latRef = EXIF.getTag(img, "GPSLatitudeRef"); const lonRef = EXIF.getTag(img, "GPSLongitudeRef");
|
||||
const decLat = convertDMSToDD(lat, latRef); const decLon = convertDMSToDD(lon, lonRef);
|
||||
card.querySelector('.p-coords').innerText = `${decLat.toFixed(4)}, ${decLon.toFixed(4)}`;
|
||||
|
||||
addrPromise = fetch(`/api/synology/geocode?lat=${decLat}&lon=${decLon}`, { headers: { [csrfHeader]: csrfToken } })
|
||||
.then(res => res.ok ? res.json() : {})
|
||||
.then(data => {
|
||||
if(data.address) {
|
||||
const city = data.address.city || data.address.province || "";
|
||||
const dist = data.address.borough || data.address.district || "";
|
||||
const country = data.address.country || "";
|
||||
return (country !== "대한민국" ? country + " " : "") + `${city} ${dist}`;
|
||||
}
|
||||
return "Unknown Location";
|
||||
})
|
||||
.catch(() => "Location Error");
|
||||
} else {
|
||||
card.querySelector('.p-coords').innerText = "";
|
||||
}
|
||||
}
|
||||
|
||||
typeText(dateEl, dateStr, cardNum, 'date', () => {
|
||||
addrPromise.then(addrText => {
|
||||
typeText(addrEl, addrText, cardNum, 'addr', null);
|
||||
});
|
||||
});
|
||||
function fetchMetadataAndAddress(item, card, cardNum) {
|
||||
fetch(`/api/synology/geocode?id=${item.id}&lat=0&lon=0`, {
|
||||
headers: { [csrfHeader]: csrfToken }
|
||||
})
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
if(item.id == currentMetadata.id) {
|
||||
currentMetadata.memo = data.memo;
|
||||
currentMetadata.tags = data.tags;
|
||||
|
||||
const desc = EXIF.getTag(img, "ImageDescription");
|
||||
card.querySelector('.p-tags').innerText = (desc && desc.trim() !== "") ? `#${desc}` : "";
|
||||
let addrText = "";
|
||||
if (data.address && data.address !== 'Unknown') {
|
||||
if (typeof data.address === 'object') {
|
||||
addrText = data.address.display_name || "Location Found";
|
||||
} else {
|
||||
addrText = data.address;
|
||||
}
|
||||
}
|
||||
if(addrText) {
|
||||
typeText(card.querySelector('.p-address'), addrText, cardNum, 'addr');
|
||||
}
|
||||
updateCardMetaUI(cardNum);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function generateFullExifHtml(img) {
|
||||
const allTags = EXIF.getAllTags(img);
|
||||
let html = "";
|
||||
for (let key in allTags) {
|
||||
if (key === 'thumbnail' || key.startsWith('Thumbnail')) continue;
|
||||
let val = allTags[key];
|
||||
if (typeof val === 'object') {
|
||||
if (val instanceof Number) val = val.valueOf(); else val = JSON.stringify(val);
|
||||
}
|
||||
if (String(val).length > 40) val = String(val).substring(0, 40) + "...";
|
||||
html += `<div class="exif-item"><span class="exif-key">${key}</span><span class="exif-val">${val}</span></div>`;
|
||||
}
|
||||
return html || "<div style='text-align:center'>No EXIF Data</div>";
|
||||
}
|
||||
|
||||
function convertDMSToDD(coord, ref) {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user