PageSourceSearch

https://line-movie-gallery.vercel.app/js/app.js

js line-movie-gallery.vercel.app collected 2026-10-03 07:14:07 UTC 17,991 bytes, 544 lines download raw bytes

1/* ============================================
2   MOVIE GALLERY
3   LINE LIFF App - Main Application
4   ============================================ */
5
6// --- カテゴリ定義 ---
7const CATEGORIES = [
8  { key: 'all',      label: 'すべて' },
9  { key: 'nakayama', label: '中山拓哉' },
10  { key: 'taguchi',  label: '田口成浩' },
11  { key: 'ako',      label: '赤穂雷太' },
12  { key: 'motoda',   label: '元田大陽' },
13  { key: 'wetzell',  label: 'ヤニー・ウェッツェル' },
14];
15
16// --- Cloud Storage ベースURL ---
17const GCS = 'https://storage.googleapis.com/gen-lang-client-0306469427.firebasestorage.app/movies';
18
19// --- 動画データ ---
20const ALL_VIDEOS = [
21  {
22    src: `${GCS}/01-nakayama.mp4`,
23    title: '秋田の心臓',
24    category: 'nakayama',
25    tags: ['中山拓哉', '選手', '紹介'],
26  },
27  {
28    src: `${GCS}/02-taguchi.mp4`,
29    title: '何度でも',
30    category: 'taguchi',
31    tags: ['田口成浩', '選手', '紹介'],
32  },
33  {
34    src: `${GCS}/03-ako.mp4`,
35    title: '双子の誓い',
36    category: 'ako',
37    tags: ['赤穂雷太', '選手', '紹介'],
38  },
39  {
40    src: `${GCS}/04-motoda.mp4`,
41    title: '秋田を照らす光',
42    category: 'motoda',
43    tags: ['元田大陽', '選手', '紹介'],
44  },
45  {
46    src: `${GCS}/05-wetzell.mp4`,
47    title: '世界が秋田へ',
48    category: 'wetzell',
49    tags: ['ヤニー・ウェッツェル', '選手', '紹介'],
50  },
51];
52
53// --- LIFF設定 ---
54// LINEミニアプリ「秋田ノーザンハピネッツムービーギャラリー」
55// プロバイダー=秋田ノーザンハピネッツ株式会社 (再申請用に再作成 / チャネルID 2010426723/724/726)
56// 本番用LIFF ID。審査用=2010426724-SQjJAsuz / 開発用=2010426723-4IoTyE4N
57// ※liff.initはtry/catchで握りつぶしており、IDが環境と不一致でもアプリ表示には影響しない
58const LIFF_ID = '2010426726-rVwJAcaH';
59
60// --- グローバル変数 ---
61let swiper = null;
62let currentSlideIndex = 0;
63let filteredVideos = [...ALL_VIDEOS];
64let activeCategory = 'all';
65let searchQuery = '';
66let isFullscreenMode = false;
67let overlayTimer = null;
68let appInitialized = false;
69let searchDebounceTimer = null;
70let liffAvailable = false;
71let userInteracted = false; // ユーザーが画面操作したか(ミュート解除用)
72
73// ============================================
74// 初期化
75// ============================================
76
77document.addEventListener('DOMContentLoaded', initApp);
78
79async function initApp() {
80  await initLIFF();
81  buildFilterChips();
82  setupSearch();
83  applyFilter();
84  setupFullscreenHandler();
85  setupFsControls();
86  setupUserInteraction();
87  setupFooterLinks();
88  appInitialized = true;
89}
90
91// フッターのプラポリ・利用規約リンク(外部リダイレクトを避け、LINE内ブラウザ/別タブで開く)
92function setupFooterLinks() {
93  const urls = {
94    privacy: 'https://northern-happinets.com/privacy/',
95    terms: 'https://northern-happinets.com/rule/',
96  };
97  document.querySelectorAll('.footer-link').forEach((btn) => {
98    btn.addEventListener('click', () => {
99      const url = urls[btn.dataset.doc];
100      if (!url) return;
101      // LIFF環境では liff.openWindow(external:false) でLINE内ブラウザ表示(ミニアプリから離脱しない)
102      if (liffAvailable && typeof liff !== 'undefined' && liff.openWindow) {
103        try { liff.openWindow({ url, external: false }); return; } catch (e) {}
104      }
105      // 通常ブラウザ用フォールバック
106      window.open(url, '_blank', 'noopener');
107    });
108  });
109}
110
111async function initLIFF() {
112  if (!LIFF_ID || window.__liffLoadFailed || typeof liff === 'undefined') return;
113  try { await liff.init({ liffId: LIFF_ID }); liffAvailable = true; } catch (e) { console.warn('LIFF:', e); }
114}
115
116// ユーザー初回操作でミュート解除
117function setupUserInteraction() {
118  const unlock = () => {
119    if (userInteracted) return;
120    userInteracted = true;
121    const video = document.getElementById(`video-${currentSlideIndex}`);
122    if (video) {
123      video.muted = false;
124    }
125  };
126  document.addEventListener('touchstart', unlock, { once: true });
127  document.addEventListener('click', unlock, { once: true });
128}
129
130// ============================================
131// フィルター & 検索
132// ============================================
133
134function buildFilterChips() {
135  const scroll = document.querySelector('.filter-scroll');
136  scroll.innerHTML = CATEGORIES.map(cat =>
137    `<button class="filter-chip ${cat.key === 'all' ? 'active' : ''}" data-category="${cat.key}">${cat.label}</button>`
138  ).join('');
139  scroll.addEventListener('click', (e) => {
140    const chip = e.target.closest('.filter-chip');
141    if (chip) setActiveCategory(chip.dataset.category);
142  });
143}
144
145function setActiveCategory(category) {
146  activeCategory = category;
147  document.querySelectorAll('.filter-chip').forEach(chip =>
148    chip.classList.toggle('active', chip.dataset.category === category)
149  );
150  applyFilter();
151}
152
153function setupSearch() {
154  const input = document.getElementById('searchInput');
155  const clearBtn = document.getElementById('searchClear');
156  const resetBtn = document.getElementById('resetFilterBtn');
157
158  input.addEventListener('focus', () => {
159    document.getElementById('mainContent').style.overflow = 'hidden';
160    setTimeout(() => window.scrollTo(0, 0), 50);
161  });
162  input.addEventListener('blur', () => {
163    document.getElementById('mainContent').style.overflow = '';
164  });
165  input.addEventListener('touchstart', (e) => { e.stopPropagation(); });
166
167  input.addEventListener('input', () => {
168    const val = input.value.trim();
169    clearBtn.style.display = val ? 'flex' : 'none';
170    clearTimeout(searchDebounceTimer);
171    searchDebounceTimer = setTimeout(() => { searchQuery = val; applyFilter(); }, 250);
172  });
173
174  clearBtn.addEventListener('click', () => {
175    input.value = ''; clearBtn.style.display = 'none'; searchQuery = ''; applyFilter();
176  });
177
178  resetBtn.addEventListener('click', () => {
179    input.value = ''; clearBtn.style.display = 'none'; searchQuery = ''; setActiveCategory('all');
180  });
181}
182
183function applyFilter() {
184  // 現在の動画をすべて停止
185  filteredVideos.forEach((_, i) => pausePlayer(i));
186
187  filteredVideos = ALL_VIDEOS.filter(video => {
188    const matchCat = activeCategory === 'all' || video.category === activeCategory;
189    if (!matchCat) return false;
190    if (!searchQuery) return true;
191    const q = searchQuery.toLowerCase();
192    return video.title.toLowerCase().includes(q) ||
193           video.tags.some(tag => tag.toLowerCase().includes(q));
194  });
195  currentSlideIndex = 0;
196
197  const hasResults = filteredVideos.length > 0;
198  document.getElementById('playerSection').classList.toggle('hidden', !hasResults);
199  document.getElementById('playerBar').classList.toggle('hidden', !hasResults);
200  document.getElementById('videoInfo').classList.toggle('hidden', !hasResults);
201  document.getElementById('videoGrid').style.display = hasResults ? '' : 'none';
202  document.getElementById('noResults').style.display = hasResults ? 'none' : 'flex';
203
204  const catLabel = CATEGORIES.find(c => c.key === activeCategory)?.label || 'すべて';
205  document.getElementById('listHeading').textContent = activeCategory === 'all' ? 'ALL MOVIES' : catLabel;
206  document.getElementById('listCount').textContent = `${filteredVideos.length}本`;
207
208  if (hasResults) {
209    buildSwiperSlides();
210    buildVideoGrid();
211    initSwiper();
212    updateVideoInfo(0);
213    updateDots(0);
214    // 最初の動画を自動再生
215    playVideo(0);
216  }
217}
218
219// ============================================
220// プレーヤー & スワイパー
221// ============================================
222
223function buildSwiperSlides() {
224  const wrapper = document.getElementById('swiperWrapper');
225  wrapper.innerHTML = filteredVideos.map((video, i) => `
226    <div class="swiper-slide" data-index="${i}">
227      <div class="video-frame-wrapper" id="playerWrapper-${i}">
228        <video
229          class="slide-video"
230          id="video-${i}"
231          preload="${i <= 1 ? 'auto' : 'metadata'}"
232          playsinline
233          webkit-playsinline
234          muted
235          loop
236          src="${video.src}"
237        ></video>
238        <div class="touch-overlay" data-index="${i}">
239          <div class="tap-to-play" id="tapIcon-${i}"></div>
240        </div>
241      </div>
242    </div>
243  `).join('');
244
245  // タッチオーバーレイ設定
246  document.querySelectorAll('.touch-overlay').forEach(setupTouchOverlay);
247}
248
249function setupTouchOverlay(overlay) {
250  let sx = 0, sy = 0, st = 0;
251  let handledByTouch = false;
252
253  overlay.addEventListener('touchstart', (e) => {
254    sx = e.touches[0].clientX;
255    sy = e.touches[0].clientY;
256    st = Date.now();
257  }, { passive: true });
258
259  overlay.addEventListener('touchend', (e) => {
260    const t = e.changedTouches[0];
261    if (Math.abs(t.clientX - sx) < 15 && Math.abs(t.clientY - sy) < 15 && Date.now() - st < 300) {
262      handledByTouch = true;
263      setTimeout(() => { handledByTouch = false; }, 500);
264      const idx = parseInt(overlay.dataset.index);
265      if (isFullscreenMode) { toggleFsOverlay(); } else { handleTap(idx); }
266    }
267  }, { passive: true });
268
269  // デスクトップ用 click(touchend で処理済みならスキップ)
270  overlay.addEventListener('click', () => {
271    if (handledByTouch) return;
272    const idx = parseInt(overlay.dataset.index);
273    if (isFullscreenMode) { toggleFsOverlay(); } else { handleTap(idx); }
274  });
275}
276
277// タップ処理: 初回はミュート解除、以降は再生/一時停止トグル
278function handleTap(index) {
279  const video = document.getElementById(`video-${index}`);
280  if (!video) return;
281
282  // ミュート中なら解除して再生継続
283  if (video.muted) {
284    video.muted = false;
285    userInteracted = true;
286    // 停止中なら再生開始
287    if (video.paused) {
288      video.play().catch(() => {});
289    }
290    return;
291  }
292
293  // 再生/一時停止トグル
294  togglePlayPause(index);
295}
296
297function togglePlayPause(index) {
298  const video = document.getElementById(`video-${index}`);
299  if (!video) return;
300  const icon = document.getElementById(`tapIcon-${index}`);
301
302  if (video.paused) {
303    video.play().catch(() => {});
304    if (icon) { icon.classList.remove('paused'); icon.classList.add('show'); setTimeout(() => icon.classList.remove('show'), 800); }
305  } else {
306    video.pause();
307    if (icon) { icon.classList.add('paused', 'show'); setTimeout(() => icon.classList.remove('show'), 800); }
308  }
309}
310
311// 動画を自動再生(ミュートならミュートのまま)
312function playVideo(index) {
313  const video = document.getElementById(`video-${index}`);
314  if (!video) return;
315
316  // ユーザー操作済みならミュート解除
317  video.muted = !userInteracted;
318  video.currentTime = 0;
319  video.play().catch(() => {
320    // ブロックされたらミュートで再試行
321    video.muted = true;
322    video.play().catch(() => {});
323  });
324
325  // 隣接動画をプリロード
326  preloadAdjacent(index);
327}
328
329function preloadAdjacent(index) {
330  [index - 1, index + 1].forEach(i => {
331    if (i >= 0 && i < filteredVideos.length) {
332      const v = document.getElementById(`video-${i}`);
333      if (v && v.preload !== 'auto') v.preload = 'auto';
334    }
335  });
336}
337
338function buildVideoGrid() {
339  const grid = document.getElementById('videoGrid');
340  grid.innerHTML = filteredVideos.map((video, i) => {
341    const catLabel = CATEGORIES.find(c => c.key === video.category)?.label || '';
342    return `
343      <div class="video-card ${i === 0 ? 'active' : ''}" data-index="${i}" onclick="goToVideo(${i})">
344        <div class="card-thumbnail">
345          <video preload="metadata" muted playsinline src="${video.src}#t=0.5"></video>
346          <span class="card-category-badge">${catLabel}</span>
347          ${i === 0 ? '<span class="card-now-playing">再生中</span>' : ''}
348        </div>
349        <div class="card-info">
350          <div class="card-title">${video.title}</div>
351        </div>
352      </div>`;
353  }).join('');
354}
355
356function initSwiper() {
357  if (swiper) { swiper.destroy(true, true); swiper = null; }
358  swiper = new Swiper('#playerSwiper', {
359    slidesPerView: 1,
360    spaceBetween: 0,
361    resistance: true,
362    resistanceRatio: 0.3,
363    speed: 300,
364    threshold: 10,
365    touchEventsTarget: 'container',
366    on: { slideChange: function () { onSlideChange(this.activeIndex); } },
367  });
368}
369
370function onSlideChange(newIndex) {
371  pausePlayer(currentSlideIndex);
372  currentSlideIndex = newIndex;
373  updateVideoInfo(newIndex);
374  updateDots(newIndex);
375  updateVideoGrid(newIndex);
376  updateFsUI(newIndex);
377
378  // 新しい動画を自動再生
379  playVideo(newIndex);
380}
381
382function goToVideo(index) {
383  if (swiper) swiper.slideTo(index);
384}
385
386function pausePlayer(index) {
387  const video = document.getElementById(`video-${index}`);
388  if (video) { try { video.pause(); } catch (e) {} }
389}
390
391// ============================================
392// UI更新
393// ============================================
394
395function updateVideoInfo(index) {
396  if (index >= filteredVideos.length) return;
397  const video = filteredVideos[index];
398  const catLabel = CATEGORIES.find(c => c.key === video.category)?.label || '';
399  document.getElementById('videoCategory').textContent = catLabel;
400  document.getElementById('videoTitle').textContent = video.title;
401}
402
403function updateDots(index) {
404  document.getElementById('swipeDots').innerHTML = filteredVideos.map((_, i) =>
405    `<div class="swipe-dot ${i === index ? 'active' : ''}"></div>`
406  ).join('');
407}
408
409function updateVideoGrid(index) {
410  document.querySelectorAll('.video-card').forEach((card, i) => {
411    card.classList.toggle('active', i === index);
412    const badge = card.querySelector('.card-now-playing');
413    if (i === index && !badge) {
414      const span = document.createElement('span');
415      span.className = 'card-now-playing';
416      span.textContent = '再生中';
417      card.querySelector('.card-thumbnail').appendChild(span);
418    } else if (i !== index && badge) { badge.remove(); }
419  });
420}
421
422// ============================================
423// フルスクリーン(縦画面対応)
424// ============================================
425
426function setupFullscreenHandler() {
427  const fsBtn = document.getElementById('fullscreenBtn');
428  fsBtn.addEventListener('click', (e) => {
429    e.preventDefault();
430    e.stopPropagation();
431    enterFullscreenMode();
432  });
433  fsBtn.addEventListener('touchend', (e) => {
434    e.preventDefault();
435    e.stopPropagation();
436    enterFullscreenMode();
437  });
438
439  document.addEventListener('fullscreenchange', onFullscreenChange);
440  document.addEventListener('webkitfullscreenchange', onFullscreenChange);
441}
442
443function onFullscreenChange() {
444  const fsEl = document.fullscreenElement || document.webkitFullscreenElement;
445  if (!fsEl && isFullscreenMode) {
446    isFullscreenMode = false;
447    document.body.classList.remove('fs-active');
448    document.getElementById('fsOverlay').classList.remove('visible');
449    clearTimeout(overlayTimer);
450    setTimeout(() => { if (swiper) swiper.update(); }, 100);
451  }
452}
453
454function enterFullscreenMode() {
455  isFullscreenMode = true;
456  document.body.classList.add('fs-active');
457  updateFsUI(currentSlideIndex);
458
459  const container = document.getElementById('fullscreenContainer');
460  requestFS(container);
461
462  document.getElementById('fsOverlay').classList.add('visible');
463  resetOverlayTimer();
464
465  setTimeout(() =>
465 { if (swiper) swiper.update(); }, 150);
466  setTimeout(() => { if (swiper) swiper.update(); }, 500);
467}
468
469function exitFullscreenMode() {
470  isFullscreenMode = false;
471  document.body.classList.remove('fs-active');
472  document.getElementById('fsOverlay').classList.remove('visible');
473  clearTimeout(overlayTimer);
474  exitFS();
475  setTimeout(() => { if (swiper) swiper.update(); }, 100);
476}
477
478function requestFS(el) {
479  try {
480    if (el.requestFullscreen) return el.requestFullscreen().catch(() => {});
481    else if (el.webkitRequestFullscreen) { el.webkitRequestFullscreen(); return Promise.resolve(); }
482  } catch (e) {}
483  return Promise.resolve();
484}
485
486function exitFS() {
487  try {
488    const fsEl = document.fullscreenElement || document.webkitFullscreenElement;
489    if (fsEl) {
490      if (document.exitFullscreen) document.exitFullscreen().catch(() => {});
491      else if (document.webkitExitFullscreen) document.webkitExitFullscreen();
492    }
493  } catch (e) {}
494}
495
496// ============================================
497// フルスクリーン UI コントロール
498// ============================================
499
500function updateFsUI(index) {
501  if (index >= filteredVideos.length) return;
502  const video = filteredVideos[index];
503  document.getElementById('fsTitle').textContent = video.title;
504  document.getElementById('fsCounter').textContent = `${index + 1} / ${filteredVideos.length}`;
505  document.getElementById('fsPrevBtn').style.visibility = index > 0 ? 'visible' : 'hidden';
506  document.getElementById('fsNextBtn').style.visibility = index < filteredVideos.length - 1 ? 'visible' : 'hidden';
507}
508
509function setupFsControls() {
510  document.getElementById('fsBackBtn').addEventListener('click', (e) => {
511    e.stopPropagation();
512    exitFullscreenMode();
513  });
514
515  document.getElementById('fsPrevBtn').addEventListener('click', (e) => {
516    e.stopPropagation();
517    if (currentSlideIndex > 0) goToVideo(currentSlideIndex - 1);
518    resetOverlayTimer();
519  });
520
521  document.getElementById('fsNextBtn').addEventListener('click', (e) => {
522    e.stopPropagation();
523    if (currentSlideIndex < filteredVideos.length - 1) goToVideo(currentSlideIndex + 1);
524    resetOverlayTimer();
525  });
526}
527
528function toggleFsOverlay() {
529  const overlay = document.getElementById('fsOverlay');
530  if (overlay.classList.contains('visible')) {
531    overlay.classList.remove('visible');
532    clearTimeout(overlayTimer);
533  } else {
534    overlay.classList.add('visible');
535    resetOverlayTimer();
536  }
537}
538
539function resetOverlayTimer() {
540  clearTimeout(overlayTimer);
541  overlayTimer = setTimeout(() => {
542    document.getElementById('fsOverlay').classList.remove('visible');
543  }, 4000);
544}

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.