diff --git a/eas.json b/eas.json index 003d1f1..892e3e4 100644 --- a/eas.json +++ b/eas.json @@ -47,6 +47,12 @@ }, "production7.0": { "channel": "familymart" + }, + "beta7.1": { + "channel": "gmarket" + }, + "production7.1": { + "channel": "geekbuying" } }, "submit": { diff --git a/ndView.tsx b/ndView.tsx index 85e91ce..f7ed16c 100644 --- a/ndView.tsx +++ b/ndView.tsx @@ -13,6 +13,7 @@ import { } from "react-native"; import * as FileSystem from "expo-file-system/legacy"; import * as Sharing from "expo-sharing"; +import Share from "react-native-share"; import { activateKeepAwakeAsync, deactivateKeepAwake, @@ -30,6 +31,17 @@ import lineColorList from "./assets/originData/lineColorList"; const KEEP_AWAKE_TAG = "operation-info-landscape"; +type OperationCaptureImagePayload = { + dataUrl?: string; + fileName?: string; +}; + +type OperationCaptureBatchState = { + total: number; + files: Array; + shared: boolean; +}; + const isActivityUnavailableError = (error: unknown) => String(error).includes("The current activity is no longer available"); @@ -212,10 +224,12 @@ const buildOperationPageScript = (layout: { } var compat = getSignageCompatibilityProfile(); var rules = [ - '.jrs-capture-page-wrap{display:block !important;text-align:right !important;margin:12px 0 10px !important;}', + '.jrs-capture-page-wrap{display:flex !important;flex-wrap:wrap !important;justify-content:flex-end !important;gap:8px !important;margin:12px 0 10px !important;}', '.jrs-capture-page-link{display:inline-block !important;background:#0076a8 !important;color:#fff !important;padding:11px 16px !important;border-radius:999px !important;font-size:12px !important;font-weight:800 !important;line-height:1.2 !important;text-decoration:none !important;box-shadow:0 4px 12px rgba(0,118,168,.22) !important;position:relative !important;z-index:9999 !important;}', '.jrs-capture-page-link:visited{color:#fff !important;}', '.jrs-capture-page-link:active{opacity:.9 !important;transform:translateY(1px) !important;}', + '.jrs-capture-page-link.is-secondary{background:#ffffff !important;color:#0076a8 !important;border:1px solid #0099CB !important;box-shadow:none !important;}', + '.jrs-capture-page-link.is-secondary:visited{color:#0076a8 !important;}', '.jrs-capture-wrap{display:block !important;text-align:right !important;margin:12px 0 8px !important;}', '.jrs-capture-link{display:inline-block !important;background:#0a84ff !important;color:#fff !important;padding:10px 14px !important;border-radius:999px !important;font-size:12px !important;font-weight:700 !important;line-height:1.2 !important;text-decoration:none !important;box-shadow:0 4px 12px rgba(10,132,255,.25) !important;position:relative !important;z-index:9999 !important;}', '.jrs-capture-link:visited{color:#fff !important;}', @@ -864,6 +878,40 @@ const buildOperationPageScript = (layout: { } } + async function renderAllItemsToSeparateImages(items, fileNameBase) { + try { + if (!items || !items.length) { + postMessage({ error: true }); + return; + } + + var batchId = 'operation-info-batch-' + Date.now() + '-' + Math.floor(Math.random() * 100000); + for (var index = 0; index < items.length; index += 1) { + var item = items[index]; + var capture = await buildItemCaptureImageData( + item.title, + item.subTitle, + item.updatedAt, + item.blocks || [], + fileNameBase + '-' + (item.infoId || (index + 1)) + ); + if (!capture) { + postMessage({ error: true }); + return; + } + postMessage({ + batchId: batchId, + batchIndex: index, + batchTotal: items.length, + dataUrl: capture.dataUrl, + fileName: capture.fileName + }); + } + } catch (e) { + postMessage({ error: true }); + } + } + async function renderAllItemsToImage(items, statusText, fileNameBase) { try { if (!items || !items.length) { @@ -1065,29 +1113,50 @@ const buildOperationPageScript = (layout: { if (!infoRoot) return; var items = getOperationItems(); - var existingWrap = q('.jrs-capture-page-wrap'); + var wrap = q('.jrs-capture-page-wrap'); if (!items.length) { - if (existingWrap && existingWrap.parentNode) existingWrap.parentNode.removeChild(existingWrap); + if (wrap && wrap.parentNode) wrap.parentNode.removeChild(wrap); return; } - if (existingWrap) return; + if (!wrap) { + wrap = document.createElement('div'); + wrap.className = 'jrs-capture-page-wrap'; + if (infoRoot.parentNode) { + infoRoot.parentNode.insertBefore(wrap, infoRoot); + } + } - var wrap = document.createElement('div'); - wrap.className = 'jrs-capture-page-wrap'; - var link = document.createElement('a'); - link.href = '#'; - link.className = 'jrs-capture-page-link'; - link.textContent = '表示中の運行情報をすべて切り出す'; - link.onclick = function(event) { - event.preventDefault(); - event.stopPropagation(); - renderAllItemsToImage(getOperationItems(), getStatusText(q('.delay_status')), 'operation-info-all-' + Date.now()); - }; - wrap.appendChild(link); + var allLink = q('.jrs-capture-page-link.is-all', wrap); + if (!allLink) { + allLink = document.createElement('a'); + allLink.href = '#'; + allLink.className = 'jrs-capture-page-link is-all'; + allLink.textContent = '表示中の運行情報をすべて切り出す'; + allLink.onclick = function(event) { + event.preventDefault(); + event.stopPropagation(); + renderAllItemsToImage(getOperationItems(), getStatusText(q('.delay_status')), 'operation-info-all-' + Date.now()); + }; + wrap.appendChild(allLink); + } - if (infoRoot.parentNode) { - infoRoot.parentNode.insertBefore(wrap, infoRoot); + var batchLink = q('.jrs-capture-page-link.is-batch', wrap); + if (items.length > 1) { + if (!batchLink) { + batchLink = document.createElement('a'); + batchLink.href = '#'; + batchLink.className = 'jrs-capture-page-link is-secondary is-batch'; + batchLink.textContent = '項目ごとに複数画像で共有'; + batchLink.onclick = function(event) { + event.preventDefault(); + event.stopPropagation(); + renderAllItemsToSeparateImages(getOperationItems(), 'operation-info-share-' + Date.now()); + }; + wrap.appendChild(batchLink); + } + } else if (batchLink && batchLink.parentNode) { + batchLink.parentNode.removeChild(batchLink); } } @@ -1790,6 +1859,7 @@ export default function tndView() { const webViewKey = [remountKey, layoutKey].join("-"); const reloadButtonKey = [layoutKey, Math.round(reloadTop), Math.round(reloadRight)].join("-"); const bgAtRef = useRef(null); + const operationCaptureBatchesRef = useRef>({}); const [operationLandscapeEnabled, setOperationLandscapeEnabled] = React.useState(false); const [operationCaptureEnabled, setOperationCaptureEnabled] = React.useState(false); const operationLineBadges = useMemo(() => { @@ -1991,35 +2061,105 @@ export default function tndView() { return clearReloadFadeTimer; }, [clearReloadFadeTimer, isLandscape, reloadOpacity, scheduleReloadButtonFade]); - const saveOperationCapture = useCallback(async (dataUrl?: string, fileName?: string) => { + const writeOperationCaptureFile = useCallback(async (dataUrl?: string, fileName?: string) => { if (!dataUrl?.startsWith("data:image/png;base64,")) { - Alert.alert("切り出し失敗", "画像データの生成に失敗しました。"); + throw new Error("invalid-image-data"); + } + if (!FileSystem.cacheDirectory) { + throw new Error("cache-directory-unavailable"); + } + + const base64 = dataUrl.replace("data:image/png;base64,", ""); + const uniquePrefix = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + const safeFileName = (fileName ?? `operation-info-${uniquePrefix}.png`).replace(/[^\w.-]+/g, "_"); + const fileUri = `${FileSystem.cacheDirectory}${uniquePrefix}-${safeFileName}`; + + await FileSystem.writeAsStringAsync(fileUri, base64, { + encoding: FileSystem.EncodingType.Base64, + }); + return fileUri; + }, []); + + const shareOperationCaptureFiles = useCallback(async (fileUris: string[]) => { + if (!fileUris.length) { + Alert.alert("切り出し失敗", "共有する画像が見つかりませんでした。"); return; } - try { + if (fileUris.length === 1) { const sharingAvailable = await Sharing.isAvailableAsync(); if (!sharingAvailable) { Alert.alert("共有不可", "この端末では画像共有を利用できません。"); return; } - const base64 = dataUrl.replace("data:image/png;base64,", ""); - const safeFileName = (fileName ?? `operation-info-${Date.now()}.png`).replace(/[^\w.-]+/g, "_"); - const fileUri = `${FileSystem.cacheDirectory}${safeFileName}`; - - await FileSystem.writeAsStringAsync(fileUri, base64, { - encoding: FileSystem.EncodingType.Base64, - }); - await Sharing.shareAsync(fileUri, { + await Sharing.shareAsync(fileUris[0], { mimeType: "image/png", dialogTitle: "運行情報を共有", }); - } catch { - Alert.alert("切り出し失敗", "画像の保存または共有に失敗しました。"); + return; } + + await Share.open({ + urls: fileUris, + type: "image/png", + title: "運行情報を共有", + failOnCancel: false, + }); }, []); + const saveOperationCapture = useCallback( + async (dataUrl?: string, fileName?: string) => { + try { + const fileUri = await writeOperationCaptureFile(dataUrl, fileName); + await shareOperationCaptureFiles([fileUri]); + } catch { + Alert.alert("切り出し失敗", "画像の保存または共有に失敗しました。"); + } + }, + [shareOperationCaptureFiles, writeOperationCaptureFile] + ); + + const saveOperationCaptureBatchItem = useCallback( + async (payload: OperationCaptureImagePayload & { batchId?: string; batchIndex?: number; batchTotal?: number }) => { + const batchId = typeof payload.batchId === "string" ? payload.batchId : ""; + const batchTotal = Number(payload.batchTotal ?? 0); + const batchIndex = Number(payload.batchIndex ?? 0); + + if (!batchId || batchTotal < 1 || batchIndex < 0 || batchIndex >= batchTotal) { + await saveOperationCapture(payload.dataUrl, payload.fileName); + return; + } + + try { + const fileUri = await writeOperationCaptureFile(payload.dataUrl, payload.fileName); + const batches = operationCaptureBatchesRef.current; + const entry = batches[batchId] ?? { + total: batchTotal, + files: new Array(batchTotal), + shared: false, + }; + + entry.total = batchTotal; + if (!entry.files[batchIndex]) { + entry.files[batchIndex] = fileUri; + } + batches[batchId] = entry; + + const readyFiles = entry.files.filter((uri): uri is string => !!uri); + if (!entry.shared && readyFiles.length === entry.total) { + entry.shared = true; + delete batches[batchId]; + await shareOperationCaptureFiles(readyFiles); + } + } catch { + delete operationCaptureBatchesRef.current[batchId]; + Alert.alert("切り出し失敗", "画像の保存または共有に失敗しました。"); + } + }, + [saveOperationCapture, shareOperationCaptureFiles, writeOperationCaptureFile] + ); + const goToTrainMenu = () => { if (Platform.OS === "web") { Linking.openURL("https://www.jr-shikoku.co.jp/info/"); @@ -2091,6 +2231,10 @@ export default function tndView() { Alert.alert("切り出し失敗", "項目画像の生成に失敗しました。"); return; } + if (payload.batchId) { + await saveOperationCaptureBatchItem(payload); + return; + } await saveOperationCapture(payload.dataUrl, payload.fileName); } catch { // __ping 以外の文字列メッセージは無視