| <!DOCTYPE html> |
| <html lang="ja"> |
| <head> |
| <meta charset="UTF-8"> |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> |
| <title>仲良しTube</title> |
| <style> |
| :root { |
| --bg-color: #ffffff; |
| --text-color: #0f0f0f; |
| --text-secondary: #606060; |
| --border-color: #e5e5e5; |
| --hover-color: #f2f2f2; |
| --primary-color: #ff0000; |
| --header-bg: #ffffff; |
| --search-bg: #ffffff; |
| --search-border: #cccccc; |
| --comment-bg: #f2f2f2; |
| --scrollbar-bg: #cccccc; |
| --desc-bg: #f2f2f2; |
| --desc-hover: #e5e5e5; |
| --chip-bg: #f2f2f2; |
| --chip-hover: #e5e5e5; |
| --active-sidebar-bg: #e8e8e8; |
| font-family: 'Roboto', 'Arial', sans-serif; |
| } |
| [data-theme="dark"] { |
| --bg-color: #0f0f0f; |
| --text-color: #f1f1f1; |
| --text-secondary: #aaaaaa; |
| --border-color: #3f3f3f; |
| --hover-color: #272727; |
| --header-bg: #0f0f0f; |
| --search-bg: #121212; |
| --search-border: #303030; |
| --comment-bg: #272727; |
| --scrollbar-bg: #717171; |
| --desc-bg: #272727; |
| --desc-hover: #3f3f3f; |
| --chip-bg: #272727; |
| --chip-hover: #3f3f3f; |
| --active-sidebar-bg: #3f3f3f; |
| } |
| * { box-sizing: border-box; margin: 0; padding: 0; } |
| body { background-color: var(--bg-color); color: var(--text-color); overflow-x: hidden; font-size: 14px;} |
| a { color: inherit; text-decoration: none; } |
| button { cursor: pointer; border: none; background: none; color: inherit; font-family: inherit; } |
| ::-webkit-scrollbar { width: 8px; height: 8px; } |
| ::-webkit-scrollbar-thumb { background: var(--scrollbar-bg); border-radius: 4px; } |
| ::-webkit-scrollbar-thumb:hover { background: var(--text-secondary); } |
|
|
| header { |
| position: fixed; top: 0; width: 100%; height: 56px; |
| background-color: var(--header-bg); display: flex; align-items: center; |
| justify-content: space-between; padding: 0 16px; z-index: 100; |
| border-bottom: 1px solid var(--border-color); |
| } |
| .header-left { display: flex; align-items: center; gap: 16px; } |
| .logo { display: flex; align-items: center; gap: 4px; font-size: 20px; font-weight: bold; cursor: pointer; letter-spacing: -1px; } |
| .logo svg { fill: var(--primary-color); width: 30px; height: 30px; } |
| .header-center { flex: 1; max-width: 720px; display: flex; justify-content: center; margin: 0 40px; } |
| .search-bar { display: flex; width: 100%; max-width: 600px; height: 40px; border: 1px solid var(--search-border); border-radius: 20px; overflow: hidden; background-color: var(--search-bg); box-shadow: inset 0 1px 2px rgba(0,0,0,0.01); } |
| .search-bar input { flex: 1; padding: 0 16px 0 24px; border: none; background: transparent; color: var(--text-color); outline: none; font-size: 16px; } |
| .search-bar button { width: 64px; background-color: var(--chip-bg); border-left: 1px solid var(--search-border); display: flex; align-items: center; justify-content: center; } |
| .search-bar button:hover { background-color: var(--chip-hover); } |
| .header-right { display: flex; align-items: center; gap: 8px; } |
| .icon-btn { width: 40px; height: 40px; border-radius: 50%; display: flex; align-items: center; justify-content: center; transition: background 0.2s; } |
| .icon-btn:hover { background-color: var(--hover-color); } |
| .icon-btn svg { width: 24px; height: 24px; fill: currentColor; } |
|
|
| .categories-bar { |
| position: fixed; top: 56px; left: 72px; right: 0; height: 56px; |
| background-color: var(--header-bg); z-index: 98; display: flex; |
| align-items: center; gap: 12px; padding: 0 24px; overflow-x: auto; |
| transition: left 0.3s ease; border-bottom: 1px solid var(--border-color); |
| } |
| .categories-bar::-webkit-scrollbar { display: none; } |
| .sidebar.open ~ .categories-bar { left: 240px; } |
| .category-chip { |
| background: var(--chip-bg); padding: 6px 12px; border-radius: 8px; |
| font-size: 14px; font-weight: 500; white-space: nowrap; cursor: pointer; transition: 0.2s; |
| } |
| .category-chip:hover { background: var(--chip-hover); } |
| .category-chip.active { background: var(--text-color); color: var(--bg-color); } |
|
|
| .sidebar { |
| position: fixed; top: 56px; left: 0; bottom: 0; width: 72px; |
| background-color: var(--header-bg); padding: 8px 0; z-index: 99; |
| transition: width 0.3s ease; overflow-x: hidden; overflow-y: auto; |
| } |
| .sidebar.open { width: 240px; } |
| .sidebar-item { |
| display: flex; flex-direction: column; align-items: center; justify-content: center; |
| gap: 4px; padding: 14px 0; cursor: pointer; font-size: 10px; font-weight: 500; |
| border-radius: 10px; margin: 2px 4px; transition: background 0.15s; |
| color: var(--text-secondary); position: relative; white-space: nowrap; overflow: hidden; |
| } |
| .sidebar.open .sidebar-item { |
| flex-direction: row; gap: 18px; padding: 10px 16px; font-size: 14px; |
| margin: 2px 8px; justify-content: flex-start; |
| } |
| .sidebar-item:hover { background-color: var(--hover-color); color: var(--text-color); } |
| .sidebar-item.active { background-color: var(--active-sidebar-bg); color: var(--text-color); font-weight: 700; } |
| .sidebar-item.active::before { |
| content: ''; position: absolute; left: 0; top: 20%; bottom: 20%; |
| width: 3px; background: var(--primary-color); border-radius: 0 2px 2px 0; |
| } |
| .sidebar.open .sidebar-item.active::before { display: none; } |
| .sidebar-item svg { |
| width: 24px; height: 24px; fill: none; stroke: currentColor; |
| stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; flex-shrink: 0; |
| } |
| .sidebar-item.active svg { fill: currentColor; stroke: none; } |
| .logo svg, .icon-btn svg, .header-right svg { fill: var(--primary-color) !important; stroke: none !important; } |
| .sidebar-item .label { font-size: 10px; line-height: 1; } |
| .sidebar.open .sidebar-item .label { font-size: 14px; } |
| .sidebar-divider { height: 1px; background-color: var(--border-color); margin: 8px 0; display: none; } |
| .sidebar.open .sidebar-divider { display: block; } |
| .sidebar-section-title { display: none; font-size: 16px; font-weight: 700; padding: 8px 28px; color: var(--text-color); } |
| .sidebar.open .sidebar-section-title { display: block; } |
| .sidebar-channel-item { |
| display: none; flex-direction: row; gap: 12px; padding: 8px 16px; font-size: 13px; margin: 0 8px; |
| cursor: pointer; border-radius: 10px; position: relative; align-items: center; |
| } |
| .sidebar.open .sidebar-channel-item { display: flex; } |
| .sidebar-channel-item:hover { background-color: var(--hover-color); } |
| .sidebar-channel-item img { width: 28px; height: 28px; border-radius: 50%; object-fit: cover; flex-shrink: 0; } |
| .sidebar-channel-item .channel-name-sidebar { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1; } |
| .live-badge { background: var(--primary-color); color: #fff; font-size: 9px; font-weight: bold; padding: 1px 4px; border-radius: 3px; flex-shrink: 0; } |
| #sidebar-sub-divider, #sidebar-sub-title { display: none; } |
| .sidebar.open #sidebar-sub-divider, .sidebar.open #sidebar-sub-title { display: block; } |
|
|
| .subs-page-header { display: flex; align-items: center; gap: 12px; margin-bottom: 24px; } |
| .subs-page-header h2 { font-size: 22px; font-weight: bold; } |
| #subs-grid .sub-channel-card { |
| display: flex; align-items: center; gap: 16px; padding: 14px; border-radius: 12px; |
| cursor: pointer; border: 1px solid var(--border-color); transition: background 0.15s; margin-bottom: 10px; |
| } |
| #subs-grid .sub-channel-card:hover { background: var(--hover-color); } |
| #subs-grid .sub-channel-card img { width: 56px; height: 56px; border-radius: 50%; object-fit: cover; flex-shrink: 0; } |
| #subs-grid .sub-channel-info { flex: 1; } |
| #subs-grid .sub-channel-name { font-size: 16px; font-weight: 600; margin-bottom: 4px; } |
| #subs-grid .sub-channel-meta { font-size: 12px; color: var(--text-secondary); } |
| #subs-grid .unsub-btn { padding: 7px 16px; border-radius: 20px; background: var(--chip-bg); font-size: 13px; font-weight: 500; } |
| #subs-grid .unsub-btn:hover { background: var(--chip-hover); } |
|
|
| main { margin-top: 112px; margin-left: 72px; padding: 24px; min-height: calc(100vh - 112px); transition: margin-left 0.3s; } |
| main.sidebar-open { margin-left: 240px; } |
| .view { display: none; } |
| .view.active { display: block; } |
|
|
| .video-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 16px; row-gap: 40px; } |
| @media (max-width: 1100px) { .video-grid { grid-template-columns: repeat(3, 1fr); } } |
| @media (max-width: 760px) { .video-grid { grid-template-columns: repeat(2, 1fr); } } |
| @media (max-width: 480px) { .video-grid { grid-template-columns: 1fr; } } |
|
|
| .video-card { cursor: pointer; display: flex; flex-direction: column; gap: 12px; } |
| .thumbnail-container { width: 100%; aspect-ratio: 16 / 9; background-color: var(--hover-color); border-radius: 12px; overflow: hidden; position: relative; } |
| .thumbnail-container img { width: 100%; height: 100%; object-fit: cover; transition: 0.2s;} |
| .video-card:hover .thumbnail-container img { transform: scale(1.05); } |
| .video-info { display: flex; gap: 12px; } |
| .channel-avatar { width: 36px; height: 36px; border-radius: 50%; background-color: var(--hover-color); flex-shrink: 0; overflow: hidden; } |
| .channel-avatar img { width: 100%; height: 100%; object-fit: cover; } |
| .video-details { display: flex; flex-direction: column; overflow: hidden; flex: 1; } |
| .video-title { font-size: 14px; font-weight: 500; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; line-height: 1.4; margin-bottom: 4px;} |
| .video-meta { font-size: 12px; color: var(--text-secondary); display: flex; flex-direction: column; gap: 2px; } |
| .duration-badge { |
| position: absolute; bottom: 6px; right: 6px; |
| background: rgba(0,0,0,0.82); color: #fff; |
| font-size: 12px; font-weight: 600; padding: 2px 5px; border-radius: 4px; pointer-events: none; |
| } |
| .live-thumb-badge { position: absolute; bottom: 8px; left: 8px; background: var(--primary-color); color: #fff; font-size: 11px; font-weight: bold; padding: 2px 6px; border-radius: 4px; } |
| .archived-badge { position: absolute; bottom: 8px; left: 8px; background: rgba(0,0,0,0.75); color: #ccc; font-size: 10px; font-weight: bold; padding: 2px 6px; border-radius: 4px; } |
|
|
| .recommend-section { margin-bottom: 32px; } |
| .recommend-title { display: flex; align-items: center; gap: 8px; font-size: 18px; font-weight: bold; margin-bottom: 16px; } |
| .recommend-title svg { width: 22px; height: 22px; fill: var(--primary-color); stroke: none; } |
|
|
| .search-results-list { display: flex; flex-direction: column; gap: 12px; } |
| .search-result-item { |
| display: flex; gap: 16px; cursor: pointer; padding: 4px; border-radius: 8px; transition: background 0.15s; |
| } |
| .search-result-item:hover { background: var(--hover-color); } |
| .search-result-thumb { |
| flex-shrink: 0; width: 360px; aspect-ratio: 16/9; border-radius: 10px; |
| overflow: hidden; background: var(--hover-color); position: relative; |
| } |
| .search-result-thumb img { width: 100%; height: 100%; object-fit: cover; } |
| .search-result-info { flex: 1; display: flex; flex-direction: column; gap: 8px; padding: 4px 0; } |
| .search-result-title { font-size: 18px; font-weight: 400; line-height: 1.4; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; } |
| .search-result-meta { font-size: 13px; color: var(--text-secondary); display: flex; align-items: center; gap: 8px; } |
| .search-result-channel { display: flex; align-items: center; gap: 8px; margin-top: 4px; cursor: pointer; } |
| .search-result-channel-icon { width: 24px; height: 24px; border-radius: 50%; overflow: hidden; flex-shrink: 0; } |
| .search-result-channel-icon img { width: 100%; height: 100%; object-fit: cover; } |
| .search-result-channel-name { font-size: 13px; color: var(--text-secondary); } |
| .search-result-channel-name:hover { color: var(--text-color); } |
| @media (max-width: 900px) { .search-result-thumb { width: 240px; } .search-result-title { font-size: 15px; } } |
| @media (max-width: 600px) { .search-result-item { flex-direction: column; } .search-result-thumb { width: 100%; } } |
|
|
| .search-shorts-section { margin-bottom: 32px; padding-bottom: 24px; border-bottom: 1px solid var(--border-color); } |
| .search-shorts-title { display: flex; align-items: center; gap: 8px; font-size: 18px; font-weight: bold; margin-bottom: 16px; } |
| .search-shorts-title svg { fill: #ff0000; stroke: none; width: 22px; height: 22px; } |
| .search-shorts-scroll { display: flex; gap: 12px; overflow-x: auto; padding-bottom: 8px; scrollbar-width: none; } |
| .search-shorts-scroll::-webkit-scrollbar { display: none; } |
|
|
| .home-shorts-section { margin-bottom: 40px; border-bottom: 4px solid var(--border-color); padding-bottom: 20px; } |
| .home-shorts-title { display: flex; align-items: center; gap: 8px; font-size: 18px; font-weight: bold; margin-bottom: 16px; } |
| .home-shorts-title svg { fill: #ff0000; stroke: none; width: 24px; height: 24px; } |
| .home-shorts-scroll { display: flex; gap: 12px; overflow-x: auto; padding-bottom: 8px; scrollbar-width: none; } |
| .home-shorts-scroll::-webkit-scrollbar { display: none; } |
| .home-short-card { flex: 0 0 160px; cursor: pointer; } |
| .home-short-thumb { width: 100%; aspect-ratio: 9/16; border-radius: 12px; background: #000; overflow: hidden; margin-bottom: 8px; position: relative; } |
| .home-short-thumb img { width: 100%; height: 100%; object-fit: cover; transition: transform 0.2s; } |
| .home-short-card:hover .home-short-thumb img { transform: scale(1.04); } |
| .home-short-title { font-size: 13px; font-weight: 500; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; } |
|
|
| .channel-banner-wrap { width: 100%; border-radius: 12px; overflow: hidden; margin-bottom: 0; background: #000; } |
| .channel-banner-img { width: 100%; height: 180px; object-fit: cover; display: block; } |
| .channel-banner-placeholder { width: 100%; height: 180px; display: block; } |
| .channel-header-row { display: flex; align-items: flex-start; gap: 24px; padding: 16px 0 16px 0; } |
| .channel-header-icon-wrap { position: relative; flex-shrink: 0; } |
| .channel-header-icon { width: 80px; height: 80px; border-radius: 50%; object-fit: cover; background: var(--hover-color); } |
| .channel-header-info { flex: 1; display: flex; flex-direction: column; gap: 4px; } |
| .channel-header-title { font-size: 24px; font-weight: 700; line-height: 1.2; } |
| .channel-header-handle { font-size: 14px; color: var(--text-secondary); margin-top: 2px; } |
| .channel-header-meta { font-size: 13px; color: var(--text-secondary); } |
| .channel-action-row { display: flex; gap: 8px; align-items: center; margin-top: 8px; } |
| .subscribe-btn { |
| background-color: var(--text-color); color: var(--bg-color); |
| padding: 10px 16px; border-radius: 20px; font-weight: 500; font-size: 14px; |
| transition: opacity 0.2s; cursor: pointer; |
| } |
| .subscribe-btn:hover { opacity: 0.85; } |
| .subscribe-btn.subscribed { background-color: var(--hover-color); color: var(--text-color); border: 1px solid var(--border-color); } |
| .channel-bell-btn { width: 40px; height: 40px; border-radius: 50%; background: var(--hover-color); display: flex; align-items: center; justify-content: center; cursor: pointer; } |
| .channel-bell-btn svg { width: 20px; height: 20px; fill: currentColor; stroke: none; } |
| .channel-join-btn { background: none; border: 1px solid var(--border-color); padding: 10px 16px; border-radius: 20px; font-size: 14px; font-weight: 500; color: var(--text-color); cursor: pointer; } |
| .channel-join-btn:hover { background: var(--hover-color); } |
| .channel-tabs-bar { |
| display: flex; gap: 0; border-bottom: 1px solid var(--border-color); |
| margin-bottom: 24px; padding: 0; overflow-x: auto; scrollbar-width: none; |
| position: sticky; top: 56px; background: var(--bg-color); z-index: 10; |
| } |
| .channel-tabs-bar::-webkit-scrollbar { display: none; } |
| .channel-tab { |
| padding: 12px 16px; font-size: 14px; font-weight: 500; cursor: pointer; |
| border: none; background: none; color: var(--text-secondary); |
| border-bottom: 3px solid transparent; transition: color 0.1s; |
| white-space: nowrap; position: relative; bottom: -1px; |
| } |
| .channel-tab:hover { color: var(--text-color); background: var(--hover-color); } |
| .channel-tab.active { color: var(--text-color); border-bottom: 3px solid var(--text-color); font-weight: 600; } |
| .channel-featured { display: flex; flex-direction: column; gap: 24px; } |
| .channel-featured-video { display: flex; gap: 16px; padding: 16px; background: var(--hover-color); border-radius: 12px; cursor: pointer; } |
| .channel-featured-thumb { width: 280px; flex-shrink: 0; aspect-ratio: 16/9; border-radius: 8px; overflow: hidden; } |
| .channel-featured-thumb img { width: 100%; height: 100%; object-fit: cover; } |
| .channel-featured-info { flex: 1; display: flex; flex-direction: column; gap: 8px; } |
| .channel-featured-title { font-size: 18px; font-weight: 600; } |
| .channel-filter-bar { display: flex; gap: 8px; margin-bottom: 20px; overflow-x: auto; scrollbar-width: none; padding-bottom: 2px; } |
| .channel-filter-bar::-webkit-scrollbar { display: none; } |
| .channel-filter-chip { padding: 6px 12px; border-radius: 8px; background: var(--chip-bg); font-size: 14px; font-weight: 500; white-space: nowrap; cursor: pointer; border: none; color: var(--text-color); } |
| .channel-filter-chip.active { background: var(--text-color); color: var(--bg-color); } |
| .channel-filter-chip:hover { background: var(--chip-hover); } |
|
|
| .channel-shorts-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); gap: 12px; } |
| .channel-short-card { cursor: pointer; } |
| .channel-short-thumb { width: 100%; aspect-ratio: 9/16; border-radius: 12px; background: #000; overflow: hidden; margin-bottom: 8px; position: relative; } |
| .channel-short-thumb img { width: 100%; height: 100%; object-fit: cover; transition: transform 0.2s; } |
| .channel-short-card:hover .channel-short-thumb img { transform: scale(1.04); } |
| .channel-short-title { font-size: 13px; font-weight: 500; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; } |
|
|
| .shorts-full-page { |
| position: fixed; top: 56px; left: 72px; right: 0; bottom: 0; |
| background: var(--bg-color); overflow-y: scroll; scroll-snap-type: y mandatory; scrollbar-width: none; |
| } |
| .shorts-full-page::-webkit-scrollbar { display: none; } |
| main.sidebar-open .shorts-full-page { left: 240px; } |
| .short-snap-item { |
| width: 100%; height: calc(100vh - 56px); |
| display: flex; align-items: center; justify-content: center; |
| scroll-snap-align: start; position: relative; background: var(--bg-color); |
| } |
| .short-center { display: flex; align-items: flex-end; gap: 16px; height: 100%; max-height: 900px; padding: 12px 0; } |
| .short-video-wrap { |
| position: relative; height: 100%; |
| aspect-ratio: 9/16; background: #000; border-radius: 12px; overflow: hidden; flex-shrink: 0; |
| } |
| .short-video-wrap iframe { width: 100%; height: 100%; border: none; display: block; } |
| .short-video-wrap video { width: 100%; height: 100%; display: block; background: #000; } |
| .short-side-actions { display: flex; flex-direction: column; gap: 20px; padding-bottom: 40px; color: var(--text-color); } |
| .short-action-btn { display: flex; flex-direction: column; align-items: center; gap: 6px; cursor: pointer; } |
| .short-action-btn .icon { width: 48px; height: 48px; border-radius: 50%; background: var(--hover-color); display: flex; align-items: center; justify-content: center; } |
| .short-action-btn svg { width: 24px; height: 24px; fill: var(--text-color); stroke: none; } |
| .short-action-btn span { font-size: 12px; font-weight: 500; color: var(--text-color); } |
| .short-overlay-bottom { |
| position: absolute; bottom: 0; left: 0; right: 0; padding: 60px 16px 16px; |
| background: linear-gradient(transparent, rgba(0,0,0,0.75)); |
| color: #fff; pointer-events: none; border-radius: 0 0 12px 12px; |
| } |
| .short-overlay-bottom .short-channel { display: flex; align-items: center; gap: 10px; margin-bottom: 8px; pointer-events: all; cursor: pointer; flex-wrap: wrap; } |
| .short-overlay-bottom .short-channel img { width: 40px; height: 40px; border-radius: 50%; border: 2px solid #fff; flex-shrink:0; } |
| .short-overlay-bottom .short-channel-name { font-weight: bold; font-size: 15px; color: #fff; flex:1; } |
| .short-overlay-bottom .short-title { font-size: 14px; line-height: 1.4; pointer-events: all; color: #fff; margin-bottom:8px; } |
| .short-sub-btn { background: #fff; color: #000; border: none; border-radius: 20px; padding: 4px 12px; font-size: 12px; font-weight: 700; cursor: pointer; pointer-events: all; flex-shrink:0; } |
| .short-sub-btn.subscribed { background: rgba(255,255,255,0.3); color: #fff; border: 1px solid #fff; } |
| .short-avatar-btn { background: none !important; overflow: visible !important; } |
| .short-action-btn .icon { background: rgba(255,255,255,0.15) !important; backdrop-filter: blur(4px); } |
| [data-theme="dark"] .short-action-btn .icon { background: rgba(255,255,255,0.1) !important; } |
| /* ショートストリームセレクタ: 3ボタン */ |
| .short-stream-selector { |
| position: absolute; top: 12px; right: 12px; |
| display: flex; background: rgba(0,0,0,0.6); backdrop-filter: blur(6px); |
| border-radius: 20px; padding: 3px; gap: 2px; z-index: 10; |
| } |
| .short-stream-btn { padding: 5px 10px; border-radius: 16px; font-size: 11px; font-weight: 600; color: rgba(255,255,255,0.7); transition: 0.2s; cursor: pointer; border: none; background: none; } |
| .short-stream-btn.active { background: rgba(255,255,255,0.25); color: #fff; } |
| .short-gv-loading { |
| position: absolute; inset: 0; background: #000; |
| display: flex; align-items: center; justify-content: center; flex-direction: column; gap: 12px; |
| border-radius: 12px; z-index: 5; |
| } |
| .short-gv-loading .spinner { |
| width: 36px; height: 36px; border: 3px solid rgba(255,255,255,0.2); |
| border-top-color: #fff; border-radius: 50%; animation: spin 0.8s linear infinite; |
| } |
| .short-gv-loading span { color: rgba(255,255,255,0.8); font-size: 13px; } |
| @keyframes spin { to { transform: rotate(360deg); } } |
|
|
| .watch-layout { display: flex; gap: 24px; max-width: 1500px; margin: 0 auto; flex-wrap: wrap; } |
| .watch-main { flex: 1; min-width: 65%; } |
| .watch-sidebar { width: 400px; flex-shrink: 0; } |
| .player-container { width: 100%; aspect-ratio: 16 / 9; background-color: black; border-radius: 12px; overflow: hidden; position: relative; display: flex; align-items: center; justify-content: center; } |
| .player-container iframe, .player-container video { width: 100%; height: 100%; border: none; outline: none; } |
| .watch-title { font-size: 20px; font-weight: bold; margin: 12px 0; } |
| .watch-actions { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; flex-wrap: wrap; gap: 16px; } |
| .watch-channel-info { display: flex; align-items: center; gap: 12px; cursor: pointer; } |
| .watch-channel-name { font-weight: bold; font-size: 16px; } |
| .watch-channel-subs { font-size: 12px; color: var(--text-secondary); } |
| .stream-selector { display: flex; background: var(--hover-color); border-radius: 20px; padding: 4px; flex-wrap: wrap; gap: 2px; } |
| .stream-btn { padding: 8px 16px; border-radius: 16px; font-size: 14px; font-weight: 500; } |
| .stream-btn.active { background: var(--text-color); color: var(--bg-color); } |
| .action-buttons { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; } |
| .action-btn { display: flex; align-items: center; gap: 6px; padding: 8px 16px; background-color: var(--hover-color); border-radius: 18px; font-size: 14px; font-weight: 500; } |
|
|
| .quality-wrap { position: relative; } |
| .quality-panel { |
| display: none; position: absolute; bottom: 48px; right: 0; |
| background: var(--header-bg); border: 1px solid var(--border-color); |
| border-radius: 12px; padding: 8px; min-width: 200px; z-index: 20; |
| box-shadow: 0 4px 20px rgba(0,0,0,0.18); |
| } |
| .quality-panel.open { display: block; } |
| .quality-panel-title { padding: 6px 16px 4px; font-size: 11px; font-weight: 700; color: var(--text-secondary); text-transform: uppercase; letter-spacing: 0.5px; } |
| .quality-option { |
| padding: 8px 16px; border-radius: 8px; cursor: pointer; font-size: 14px; |
| display: flex; align-items: center; gap: 8px; transition: background 0.1s; |
| } |
| .quality-option:hover { background: var(--hover-color); } |
| .quality-option.active { font-weight: bold; color: var(--primary-color); } |
| .quality-option .q-badge { font-size: 10px; padding: 1px 6px; border-radius: 4px; background: rgba(255,0,0,0.12); color: var(--primary-color); font-weight: 700; } |
|
|
| .download-panel { |
| display: none; position: absolute; bottom: 48px; right: 0; |
| background: var(--header-bg); border: 1px solid var(--border-color); |
| border-radius: 12px; padding: 8px; min-width: 220px; z-index: 20; |
| box-shadow: 0 4px 20px rgba(0,0,0,0.15); |
| } |
| .download-panel.open { display: block; } |
| .download-option { |
| padding: 10px 16px; border-radius: 8px; cursor: pointer; font-size: 14px; |
| display: flex; align-items: center; gap: 10px; transition: background 0.15s; |
| } |
| .download-option:hover { background: var(--hover-color); } |
| .download-option svg { width: 18px; height: 18px; fill: currentColor; stroke: none; flex-shrink: 0; } |
| .download-option .dl-label { display: flex; flex-direction: column; } |
| .download-option .dl-title { font-weight: 500; } |
| .download-option .dl-desc { font-size: 11px; color: var(--text-secondary); } |
| .download-wrap { position: relative; } |
|
|
| .dl-toast { |
| position: fixed; bottom: 80px; left: 50%; transform: translateX(-50%); |
| background: #323232; color: #fff; padding: 12px 24px; border-radius: 24px; |
| font-size: 14px; z-index: 9999; display: none; gap: 12px; align-items: center; |
| box-shadow: 0 4px 16px rgba(0,0,0,0.3); |
| } |
| .dl-toast.show { display: flex; } |
| .dl-toast-bar { width: 160px; height: 4px; background: rgba(255,255,255,0.3); border-radius: 2px; overflow: hidden; } |
| .dl-toast-fill { height: 100%; background: #4caf50; border-radius: 2px; transition: width 0.3s; width: 0%; } |
|
|
| .video-description { background-color: var(--desc-bg); border-radius: 12px; padding: 12px; margin-bottom: 24px; font-size: 14px; cursor: pointer; } |
| .video-description:hover { background-color: var(--desc-hover); } |
| .desc-content { overflow: hidden; display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; line-height: 1.5; } |
| .desc-content.expanded { display: block; -webkit-line-clamp: unset; } |
| .related-video { display: flex; gap: 8px; margin-bottom: 12px; cursor: pointer; padding: 4px; border-radius: 8px; } |
| .related-video:hover { background-color: var(--hover-color); } |
| .related-thumb { width: 168px; height: 94px; flex-shrink: 0; border-radius: 8px; background: var(--hover-color); overflow: hidden; position: relative; } |
| .related-thumb img { width: 100%; height: 100%; object-fit: cover; } |
| .related-info { flex: 1; } |
| .related-title { font-size: 14px; font-weight: 500; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; margin-bottom: 4px; } |
| .comment { display: flex; gap: 12px; margin-bottom: 16px; } |
| .comment-avatar { width: 40px; height: 40px; border-radius: 50%; background: #eee; flex-shrink: 0; } |
| .comment-body { flex: 1; } |
|
|
| .loader { text-align: center; padding: 40px; color: var(--text-secondary); font-weight: bold; width: 100%; } |
| .hidden { display: none !important; } |
| #hidden-cse-container { display: none; background: white; padding: 20px; box-shadow: 0 0 20px rgba(0,0,0,0.5); border-radius: 10px; } |
|
|
| .welcome-container { display: flex; flex-direction: column; align-items: center; justify-content: center; min-height: calc(100vh - 150px); max-width: 800px; margin: 0 auto; text-align: center; } |
| .welcome-logo { font-size: 48px; font-weight: bold; margin-bottom: 40px; letter-spacing: -2px; display: flex; align-items: center; gap: 8px; } |
| .welcome-logo svg { fill: var(--primary-color); stroke: none; width: 60px; height: 60px; } |
| .welcome-search { width: 100%; height: 56px; border: 1px solid var(--search-border); border-radius: 28px; display: flex; overflow: hidden; background-color: var(--search-bg); box-shadow: 0 2px 6px rgba(0,0,0,0.05); margin-bottom: 48px; } |
| .welcome-search input { flex: 1; padding: 0 24px; border: none; background: transparent; color: var(--text-color); font-size: 18px; outline: none; } |
| .welcome-search button { width: 80px; background-color: var(--chip-bg); border-left: 1px solid var(--search-border); display: flex; align-items: center; justify-content: center; } |
|
|
| .settings-panel { width: 100%; background: var(--header-bg); border: 1px solid var(--border-color); border-radius: 16px; padding: 32px; text-align: left; } |
| .settings-section { margin-bottom: 28px; padding-bottom: 24px; border-bottom: 1px solid var(--border-color); } |
| .settings-section:last-child { margin-bottom: 0; padding-bottom: 0; border-bottom: none; } |
| .settings-section-title { font-size: 13px; font-weight: 700; color: var(--text-secondary); text-transform: uppercase; letter-spacing: 0.8px; margin-bottom: 16px; } |
| .setting-item { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; gap: 12px; } |
| .setting-item:last-child { margin-bottom: 0; } |
| .setting-info { display: flex; flex-direction: column; flex: 1; } |
| .setting-title { font-size: 15px; font-weight: 500; margin-bottom: 3px; } |
| .setting-desc { font-size: 12px; color: var(--text-secondary); line-height: 1.4; } |
| .toggle-switch { position: relative; display: inline-block; width: 44px; height: 24px; flex-shrink: 0; } |
| .toggle-switch input { opacity: 0; width: 0; height: 0; } |
| .slider { position: absolute; cursor: pointer; top: 0; left: 0; right: 0; bottom: 0; background-color: #ccc; transition: .4s; border-radius: 24px; } |
| .slider:before { position: absolute; content: ""; height: 18px; width: 18px; left: 3px; bottom: 3px; background-color: white; transition: .4s; border-radius: 50%; } |
| input:checked + .slider { background-color: var(--primary-color); } |
| input:checked + .slider:before { transform: translateX(20px); } |
| .select-modern { padding: 8px 12px; border-radius: 8px; border: 1px solid var(--border-color); background-color: var(--bg-color); color: var(--text-color); font-size: 14px; outline: none; min-width: 160px; } |
| .start-btn { background-color: var(--primary-color); color: white; padding: 12px 32px; border-radius: 24px; font-size: 16px; font-weight: bold; border: none; cursor: pointer; } |
| .subs-empty { text-align: center; padding: 40px; color: var(--text-secondary); } |
|
|
| /* ストリームオプション: 3列グリッド */ |
| .stream-group { display: flex; flex-direction: column; gap: 8px; } |
| .stream-group-label { font-size: 11px; color: var(--text-secondary); font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; margin-top: 8px; } |
| .stream-options-grid { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 8px; } |
| .stream-option-btn { |
| padding: 10px 12px; border-radius: 10px; border: 2px solid var(--border-color); |
| background: var(--bg-color); color: var(--text-color); font-size: 13px; font-weight: 600; |
| cursor: pointer; transition: all 0.15s; display: flex; flex-direction: column; gap: 3px; align-items: flex-start; |
| } |
| .stream-option-btn:hover { border-color: var(--text-secondary); background: var(--hover-color); } |
| .stream-option-btn.selected { border-color: var(--primary-color); background: rgba(255,0,0,0.06); color: var(--primary-color); } |
| .stream-option-btn .s-label { font-size: 11px; font-weight: 400; color: var(--text-secondary); } |
| .stream-option-btn.selected .s-label { color: rgba(255,0,0,0.7); } |
|
|
| @media (max-width: 1100px) { .watch-sidebar { width: 100%; } } |
| @media (max-width: 768px) { |
| .sidebar { transform: translateX(-100%); width: 240px; } |
| .sidebar.open { transform: translateX(0); } |
| .sidebar-item { flex-direction: row; gap: 24px; padding: 10px 24px; font-size: 14px; margin: 0 12px; } |
| main { margin-left: 0; } main.sidebar-open { margin-left: 0; } |
| .categories-bar { left: 0; } .sidebar.open ~ .categories-bar { left: 0; } |
| .shorts-full-page { left: 0; } |
| .channel-featured-video { flex-direction: column; } |
| .channel-featured-thumb { width: 100%; } |
| .channel-header-icon { width: 64px; height: 64px; } |
| .channel-header-title { font-size: 18px; } |
| .channel-banner-img, .channel-banner-placeholder { height: 100px; } |
| } |
|
|
| .share-panel { |
| display: none; position: fixed; inset: 0; z-index: 9999; |
| align-items: center; justify-content: center; background: rgba(0,0,0,0.5); |
| } |
| .share-panel.open { display: flex; } |
| .share-panel-box { |
| background: var(--header-bg); border-radius: 16px; padding: 24px; |
| width: 90%; max-width: 400px; box-shadow: 0 8px 32px rgba(0,0,0,0.3); |
| } |
| .share-panel-title { font-size: 18px; font-weight: bold; margin-bottom: 16px; } |
| .share-url-row { display: flex; gap: 8px; margin-bottom: 16px; } |
| .share-url-row input { flex: 1; padding: 10px 14px; border: 1px solid var(--border-color); border-radius: 8px; background: var(--bg-color); color: var(--text-color); font-size: 14px; outline: none; } |
| .share-copy-btn { padding: 10px 16px; background: var(--primary-color); color: #fff; border-radius: 8px; font-weight: 600; font-size: 14px; } |
| .share-close-btn { width: 100%; padding: 10px; border-radius: 8px; background: var(--hover-color); font-size: 14px; font-weight: 500; margin-top: 8px; } |
|
|
| /* Manifest Hunterステータス表示 */ |
| .manifest-status { |
| position: absolute; top: 8px; left: 8px; |
| background: rgba(0,0,0,0.7); color: #0f0; |
| font-size: 10px; font-family: monospace; padding: 4px 8px; |
| border-radius: 4px; z-index: 10; max-width: 200px; |
| white-space: nowrap; overflow: hidden; text-overflow: ellipsis; |
| } |
| </style> |
| </head> |
| <body> |
| <div id="hidden-cse-container"></div> |
| <div class="dl-toast" id="dl-toast"> |
| <span id="dl-toast-msg">ダウンロード準備中...</span> |
| <div class="dl-toast-bar"><div class="dl-toast-fill" id="dl-toast-fill"></div></div> |
| </div> |
|
|
| <div class="share-panel" id="share-panel" onclick="if(event.target===this)closeSharePanel()"> |
| <div class="share-panel-box"> |
| <div class="share-panel-title">🔗 共有</div> |
| <div class="share-url-row"> |
| <input type="text" id="share-url-input" readonly> |
| <button class="share-copy-btn" onclick="copyShareUrl()">コピー</button> |
| </div> |
| <button class="share-close-btn" onclick="closeSharePanel()">閉じる</button> |
| </div> |
| </div> |
|
|
| <header> |
| <div class="header-left"> |
| <button class="icon-btn" onclick="toggleSidebar()"> |
| <svg viewBox="0 0 24 24" style="fill:currentColor;stroke:none;"><path d="M21,6H3V5h18V6z M21,11H3v1h18V11z M21,17H3v1h18V17z"></path></svg> |
| </button> |
| <div class="logo" onclick="navigate('home')"> |
| <svg viewBox="0 0 24 24"><path d="M21.58,7.19C21.34,6.31,20.69,5.66,19.81,5.42C18.25,5,12,5,12,5s-6.25,0-7.81,0.42c-0.88,0.24-1.53,0.89-1.77,1.77 C2,8.75,2,12,2,12s0,3.25,0.42,4.81c0.24,0.88,0.89,1.53,1.77,1.77C5.75,19,12,19,12,19s6.25,0,7.81-0.42 c0.88-0.24,1.53-0.89,1.77-1.77C22,15.25,22,12,22,12S22,8.75,21.58,7.19z M10,15.5v-7l6,3.5L10,15.5z"></path></svg> |
| 仲良しTube |
| </div> |
| </div> |
| <div class="header-center"> |
| <form class="search-bar" onsubmit="handleSearch(event)"> |
| <input type="text" id="search-input" placeholder="検索" autocomplete="off"> |
| <button type="submit"> |
| <svg viewBox="0 0 24 24" style="width:20px;height:20px;fill:currentColor;stroke:none;"><path d="M20.87,20.17l-5.59-5.59C16.35,13.35,17,11.75,17,10c0-3.87-3.13-7-7-7s-7,3.13-7,7s3.13,7,7,7c1.75,0,3.35-0.65,4.58-1.71 l5.59,5.59L20.87,20.17z M10,16c-3.31,0-6-2.69-6-6s2.69-6,6-6s6,2.69,6,6S13.31,16,10,16z"></path></svg> |
| </button> |
| </form> |
| </div> |
| <div class="header-right"> |
| <button class="icon-btn" onclick="toggleTheme()" title="テーマ切り替え"> |
| <svg viewBox="0 0 24 24" style="fill:currentColor;stroke:none;"><path d="M12 3a9 9 0 1 0 9 9c0-.46-.04-.92-.1-1.36a5.389 5.389 0 0 1-4.4 2.26 5.403 5.403 0 0 1-3.14-9.8c-.44-.06-.9-.1-1.36-.1z"></path></svg> |
| </button> |
| </div> |
| </header> |
|
|
| <div class="categories-bar" id="categories-bar"> |
| <button class="category-chip active" onclick="handleCategorySearch('すべて', this)">すべて</button> |
| <button class="category-chip" onclick="handleCategorySearch('音楽', this)">音楽</button> |
| <button class="category-chip" onclick="handleCategorySearch('ゲーム', this)">ゲーム</button> |
| <button class="category-chip" onclick="handleCategorySearch('ニュース', this)">ニュース</button> |
| <button class="category-chip" onclick="handleCategorySearch('ライブ', this)">ライブ</button> |
| <button class="category-chip" onclick="handleCategorySearch('アニメ', this)">アニメ</button> |
| <button class="category-chip" onclick="handleCategorySearch('料理', this)">料理</button> |
| </div> |
|
|
| <div class="sidebar" id="sidebar"> |
| <div class="sidebar-item" id="nav-home" onclick="navigate('home')"> |
| <svg viewBox="0 0 24 24"><path d="M12,3L4,9v12h5v-7h6v7h5V9L12,3z"/></svg> |
| <span class="label">ホーム</span> |
| </div> |
| <div class="sidebar-item" id="nav-shorts" onclick="navigate('shorts')"> |
| <svg viewBox="0 0 24 24"><path d="M17.77,10.32l-1.2-.5L18,9.06c1.84-.96,2.53-3.23,1.56-5.06s-3.24-2.53-5.07-1.56L6,6.94c-1.29.68-2.07,2.04-2,3.49.07,1.42.93,2.67,2.22,3.25.03.01,1.2.5,1.2.5L6,14.94c-1.84.96-2.53,3.23-1.56,5.06s3.24,2.53,5.07,1.56l8.5-4.5c1.29-.68,2.06-2.04,1.99-3.49C19.93,12.16,19.08,10.91,17.77,10.32Z"/><path d="M10,14.65v-5.3L15,12Z" style="fill:currentColor;stroke:none;"/></svg> |
| <span class="label">ショート</span> |
| </div> |
| <div class="sidebar-divider"></div> |
| <div class="sidebar-item" id="nav-subscriptions" onclick="navigate('subscriptions')"> |
| <svg viewBox="0 0 24 24"><path d="M10 18h4v-2h-4v2zM3 6v2h18V6H3zm3 7h12v-2H6v2z"/></svg> |
| <span class="label">登録CH</span> |
| </div> |
| <div class="sidebar-item" id="nav-history" onclick="navigate('history')"> |
| <svg viewBox="0 0 24 24"><path d="M14.97,16.95L10,13.87V7h2v5.76l4.03,2.49L14.97,16.95z M12,3c-4.96,0-9,4.04-9,9s4.04,9,9,9s9-4.04,9-9S16.96,3,12,3"/></svg> |
| <span class="label">履歴</span> |
| </div> |
| <div class="sidebar-divider" id="sidebar-sub-divider"></div> |
| <div class="sidebar-section-title" id="sidebar-sub-title">登録チャンネル</div> |
| <div id="sidebar-subscriptions"></div> |
| <div class="sidebar-divider"></div> |
| <div class="sidebar-item" id="nav-settings" onclick="navigate('settings')"> |
| <svg viewBox="0 0 24 24"><path d="M19.14,12.94c0.04-0.3,0.06-0.61,0.06-0.94c0-0.32-0.02-0.64-0.06-0.94l2.03-1.58c0.18-0.14,0.23-0.41,0.12-0.61 l-1.92-3.32c-0.12-0.22-0.37-0.29-0.59-0.22l-2.39,0.96c-0.5-0.38-1.03-0.7-1.62-0.94L14.4,2.81c-0.04-0.24-0.24-0.41-0.48-0.41 h-3.84c-0.24,0-0.43,0.17-0.47,0.41L9.25,5.35C8.66,5.59,8.12,5.92,7.63,6.29L5.24,5.33c-0.22-0.08-0.47,0-0.59,0.22L2.73,8.87 C2.62,9.08,2.66,9.34,2.86,9.48l2.03,1.58C4.84,11.36,4.8,11.69,4.8,12s0.02,0.64,0.06,0.94l-2.03,1.58 c-0.18,0.14-0.23,0.41-0.12,0.61l1.92,3.32c0.12,0.22,0.37,0.29,0.59,0.22l2.39-0.96c0.5,0.38,1.03,0.7,1.62,0.94l0.36,2.54 c0.05,0.24,0.24,0.41,0.48,0.41h3.84c0.24,0,0.43-0.17,0.47-0.41l0.36-2.54c0.59-0.24,1.13-0.56,1.62-0.94l2.39,0.96 c0.22,0.08,0.47,0,0.59-0.22l1.92-3.32c0.12-0.22,0.07-0.49-0.12-0.61L19.14,12.94z M12,15.6c-1.98,0-3.6-1.62-3.6-3.6 s1.62-3.6,3.6-3.6s3.6,1.62,3.6,3.6S13.98,15.6,12,15.6z"/></svg> |
| <span class="label">設定</span> |
| </div> |
| </div> |
|
|
| <main id="main-content"> |
|
|
| |
| <div id="view-welcome" class="view"> |
| <div class="welcome-container"> |
| <div class="welcome-logo"> |
| <svg viewBox="0 0 24 24"><path d="M21.58,7.19C21.34,6.31,20.69,5.66,19.81,5.42C18.25,5,12,5,12,5s-6.25,0-7.81,0.42c-0.88,0.24-1.53,0.89-1.77,1.77 C2,8.75,2,12,2,12s0,3.25,0.42,4.81c0.24,0.88,0.89,1.53,1.77,1.77C5.75,19,12,19,12,19s6.25,0,7.81-0.42 c0.88-0.24,1.53-0.89,1.77-1.77C22,15.25,22,12,22,12S22,8.75,21.58,7.19z M10,15.5v-7l6,3.5L10,15.5z"></path></svg> |
| 仲良しTube |
| </div> |
| <form class="welcome-search" onsubmit="handleWelcomeSearch(event)"> |
| <input type="text" id="welcome-search-input" placeholder="何を学びますか?" autocomplete="off"> |
| <button type="submit"> |
| <svg viewBox="0 0 24 24" style="width:24px;height:24px;fill:currentColor;stroke:none;"><path d="M20.87,20.17l-5.59-5.59C16.35,13.35,17,11.75,17,10c0-3.87-3.13-7-7-7s-7,3.13-7,7s3.13,7,7,7c1.75,0,3.35-0.65,4.58-1.71 l5.59,5.59L20.87,20.17z M10,16c-3.31,0-6-2.69-6-6s2.69-6,6-6s6,2.69,6,6S13.31,16,10,16z"></path></svg> |
| </button> |
| </form> |
| <div class="settings-panel"> |
| <div style="font-size:18px; font-weight:bold; margin-bottom:24px;">⚙️ 初期設定</div> |
| <div class="settings-section"> |
| <div class="settings-section-title">ネットワーク</div> |
| <div class="setting-item"> |
| <div class="setting-info"><div class="setting-title">CORSプロキシを利用する</div><div class="setting-desc">外部APIへの通信時にCORSプロキシを経由します(推奨)</div></div> |
| <label class="toggle-switch"><input type="checkbox" id="setting-proxy" checked onchange="saveSettings()"><span class="slider"></span></label> |
| </div> |
| </div> |
| <div class="settings-section"> |
| <div class="settings-section-title">動画ストリーム(ウォッチページ)</div> |
| |
| <div class="stream-options-grid" id="welcome-stream-options"> |
| <button class="stream-option-btn selected" data-val="1" onclick="selectWelcomeStream(1)"> |
| Nocookie<span class="s-label">標準・安定</span> |
| </button> |
| <button class="stream-option-btn" data-val="2" onclick="selectWelcomeStream(2)"> |
| Edu<span class="s-label">教育版・Premium</span> |
| </button> |
| <button class="stream-option-btn" data-val="3" onclick="selectWelcomeStream(3)"> |
| Manifest<span class="s-label">直接ストリーム</span> |
| </button> |
| </div> |
| <input type="hidden" id="setting-stream" value="1"> |
| </div> |
| <div class="settings-section"> |
| <div class="settings-section-title">ショートストリーム</div> |
| <div class="stream-options-grid" id="welcome-short-stream-options"> |
| <button class="stream-option-btn selected" data-val="1" onclick="selectWelcomeShortStream(1)"> |
| Nocookie<span class="s-label">標準</span> |
| </button> |
| <button class="stream-option-btn" data-val="2" onclick="selectWelcomeShortStream(2)"> |
| Edu<span class="s-label">教育版</span> |
| </button> |
| <button class="stream-option-btn" data-val="3" onclick="selectWelcomeShortStream(3)"> |
| Manifest<span class="s-label">直接再生</span> |
| </button> |
| </div> |
| <input type="hidden" id="setting-short-stream" value="1"> |
| </div> |
| <div class="settings-section"> |
| <div class="settings-section-title">表示</div> |
| <div class="setting-item"> |
| <div class="setting-info"><div class="setting-title">ホームにトレンド動画を表示</div></div> |
| <label class="toggle-switch"><input type="checkbox" id="setting-trend" checked onchange="saveSettings()"><span class="slider"></span></label> |
| </div> |
| <div class="setting-item"> |
| <div class="setting-info"><div class="setting-title">ダークモード</div></div> |
| <label class="toggle-switch"><input type="checkbox" id="setting-theme" onchange="toggleThemeFromSettings()"><span class="slider"></span></label> |
| </div> |
| </div> |
| <div style="text-align: center; margin-top: 20px;"> |
| <button class="start-btn" onclick="finishSetup()">仲良しTubeを始める</button> |
| </div> |
| </div> |
| </div> |
| </div> |
|
|
| |
| <div id="view-home" class="view"> |
| <div id="home-recommend" class="recommend-section hidden"> |
| <div class="recommend-title"> |
| <svg viewBox="0 0 24 24" style="fill:var(--primary-color);stroke:none;"><path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"/></svg> |
| あなたへのおすすめ |
| </div> |
| <div class="video-grid" id="home-recommend-grid"></div> |
| </div> |
| <div id="home-shorts" class="home-shorts-section hidden"> |
| <div class="home-shorts-title"> |
| <svg viewBox="0 0 24 24" style="fill:#ff0000;stroke:none;"><path d="M17.77,10.32l-1.2-.5L18,9.06c1.84-.96,2.53-3.23,1.56-5.06s-3.24-2.53-5.07-1.56L6,6.94c-1.29.68-2.07,2.04-2,3.49.07,1.42.93,2.67,2.22,3.25.03.01,1.2.5,1.2.5L6,14.94c-1.84.96-2.53,3.23-1.56,5.06s3.24,2.53,5.07,1.56l8.5-4.5c1.29-.68,2.06-2.04,1.99-3.49C19.93,12.16,19.08,10.91,17.77,10.32Z"/></svg> |
| ショート |
| </div> |
| <div class="home-shorts-scroll" id="home-shorts-container"></div> |
| </div> |
| <div id="home-grid" class="video-grid"></div> |
| <div id="home-loader" class="loader">動画を読み込み中...</div> |
| </div> |
|
|
| |
| <div id="view-search" class="view"> |
| <div id="search-shorts-section" class="search-shorts-section hidden"> |
| <div class="search-shorts-title"> |
| <svg viewBox="0 0 24 24" style="fill:#ff0000;stroke:none;"><path d="M17.77,10.32l-1.2-.5L18,9.06c1.84-.96,2.53-3.23,1.56-5.06s-3.24-2.53-5.07-1.56L6,6.94c-1.29.68-2.07,2.04-2,3.49.07,1.42.93,2.67,2.22,3.25.03.01,1.2.5,1.2.5L6,14.94c-1.84.96-2.53,3.23-1.56,5.06s3.24,2.53,5.07,1.56l8.5-4.5c1.29-.68,2.06-2.04,1.99-3.49C19.93,12.16,19.08,10.91,17.77,10.32Z"/></svg> |
| ショート |
| </div> |
| <div class="search-shorts-scroll" id="search-shorts-container"></div> |
| </div> |
| <div id="search-results-list" class="search-results-list"></div> |
| <div id="search-loader" class="loader hidden">さらに読み込み中...</div> |
| </div> |
|
|
| |
| <div id="view-shorts" class="view" style="padding:0; margin:0;"> |
| <div class="shorts-full-page" id="shorts-full-page"> |
| <div id="shorts-container"></div> |
| <div id="shorts-loader" class="loader" style="color:#fff;">ショート動画を読み込み中...</div> |
| </div> |
| </div> |
|
|
| |
| <div id="view-subscriptions" class="view"> |
| <div class="subs-page-header"> |
| <svg viewBox="0 0 24 24" style="width:28px;height:28px;fill:var(--primary-color);stroke:none;"><path d="M10 18h4v-2h-4v2zM3 6v2h18V6H3zm3 7h12v-2H6v2z"/></svg> |
| <h2>登録チャンネル</h2> |
| </div> |
| <div id="subs-grid"></div> |
| </div> |
|
|
| |
| <div id="view-channel" class="view"> |
| <div class="channel-banner-wrap" id="channel-banner-wrap"> |
| <canvas id="channel-banner-canvas" class="channel-banner-placeholder" style="display:none;"></canvas> |
| <img id="channel-banner-img" class="channel-banner-img" src="" alt="" style="display:none;" |
| onerror="this.style.display='none'; document.getElementById('channel-banner-canvas').style.display='block';"> |
| </div> |
| <div class="channel-header-row"> |
| <div class="channel-header-icon-wrap"> |
| <img src="" class="channel-header-icon" id="channel-page-icon"> |
| </div> |
| <div class="channel-header-info"> |
| <div class="channel-header-title" id="channel-page-name">チャンネル名</div> |
| <div class="channel-header-handle" id="channel-page-handle">@channel</div> |
| <div class="channel-header-meta" id="channel-page-meta">登録者数 -- • 動画 --件</div> |
| <div class="channel-action-row"> |
| <button class="subscribe-btn" id="channel-subscribe-btn" onclick="toggleSubscribeChannel()">チャンネル登録</button> |
| <button class="channel-bell-btn" id="channel-bell-btn" style="display:none;"> |
| <svg viewBox="0 0 24 24" style="fill:currentColor;stroke:none;"><path d="M12 22c1.1 0 2-.9 2-2h-4c0 1.1.9 2 2 2zm6-6v-5c0-3.07-1.64-5.64-4.5-6.32V4c0-.83-.67-1.5-1.5-1.5s-1.5.67-1.5 1.5v.68C7.63 5.36 6 7.92 6 11v5l-2 2v1h16v-1l-2-2z"/></svg> |
| </button> |
| <button class="channel-join-btn" id="channel-join-btn" style="display:none;">メンバーになる</button> |
| </div> |
| </div> |
| </div> |
| <div class="channel-tabs-bar" id="channel-tabs-bar"> |
| <button class="channel-tab active" id="ch-tab-home" onclick="switchChannelTab('home')">ホーム</button> |
| <button class="channel-tab" id="ch-tab-shorts" onclick="switchChannelTab('shorts')">ショート</button> |
| <button class="channel-tab" id="ch-tab-videos" onclick="switchChannelTab('videos')">動画</button> |
| <button class="channel-tab" id="ch-tab-live" onclick="switchChannelTab('live')">ライブ</button> |
| </div> |
| <div id="channel-tab-home" class="channel-featured"> |
| <div id="channel-featured-video" style="margin-bottom:16px;"></div> |
| <h3 style="margin-bottom:12px;">最新動画</h3> |
| <div id="channel-home-grid" class="video-grid"></div> |
| </div> |
| <div id="channel-tab-shorts" style="display:none;"> |
| <div id="channel-shorts-grid" class="channel-shorts-grid"></div> |
| <div id="channel-shorts-loader" class="loader">読み込み中...</div> |
| <button id="channel-shorts-more-btn" onclick="loadMoreChannelShorts()" style="display:none;margin:16px auto;padding:10px 24px;border-radius:20px;background:var(--chip-bg);font-size:14px;cursor:pointer;">もっと見る</button> |
| </div> |
| <div id="channel-tab-videos" style="display:none;"> |
| <div class="channel-filter-bar"> |
| <button class="channel-filter-chip active" onclick="setChannelFilter(this,'latest')">最新</button> |
| <button class="channel-filter-chip" onclick="setChannelFilter(this,'popular')">人気</button> |
| <button class="channel-filter-chip" onclick="setChannelFilter(this,'oldest')">古い順</button> |
| </div> |
| <div id="channel-grid" class="video-grid"></div> |
| <div id="channel-loader" class="loader hidden">読み込み中...</div> |
| </div> |
| <div id="channel-tab-live" style="display:none;"> |
| <div id="channel-live-grid" class="video-grid"></div> |
| <div id="channel-live-loader" class="loader">読み込み中...</div> |
| </div> |
| </div> |
|
|
| |
| <div id="view-watch" class="view"> |
| <div class="watch-layout"> |
| <div class="watch-main"> |
| <div class="player-container" id="player-wrapper"> |
| <iframe id="yt-player" allow="autoplay; fullscreen" allowfullscreen></iframe> |
| </div> |
| <h1 class="watch-title" id="watch-title-text">タイトル</h1> |
| <div class="watch-actions"> |
| <div class="watch-channel-info" id="watch-channel-trigger"> |
| <img src="" class="channel-avatar" id="watch-channel-icon"> |
| <div> |
| <div class="watch-channel-name" id="watch-channel-name">投稿者</div> |
| <div class="watch-channel-subs">登録者数 --</div> |
| </div> |
| <button class="subscribe-btn" id="watch-subscribe-btn" style="margin-left:12px;" onclick="event.stopPropagation(); toggleSubscribeFromWatch()">登録</button> |
| </div> |
| <div class="action-buttons"> |
| |
| <div class="stream-selector"> |
| <button class="stream-btn active" id="btn-stream-1" onclick="switchStream(1)">Nocookie</button> |
| <button class="stream-btn" id="btn-stream-2" onclick="switchStream(2)">Edu</button> |
| <button class="stream-btn" id="btn-stream-3" onclick="switchStream(3)">Manifest</button> |
| </div> |
| <div class="quality-wrap" id="quality-wrap" style="display:none;"> |
| <button class="action-btn" onclick="toggleQualityPanel(event)"> |
| <svg viewBox="0 0 24 24" style="width:16px;height:16px;fill:currentColor;stroke:none;"><path d="M4 6H2v14c0 1.1.9 2 2 2h14v-2H4V6zm16-4H8c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm-8 12.5v-9l6 4.5-6 4.5z"/></svg> |
| <span id="quality-label">画質</span> ▾ |
| </button> |
| <div class="quality-panel" id="quality-panel"></div> |
| </div> |
| <div class="download-wrap" id="download-wrap-watch"> |
| <button class="action-btn" onclick="toggleDownloadPanel(event, 'watch')" title="ダウンロード"> |
| <svg viewBox="0 0 24 24" style="width:16px;height:16px;fill:currentColor;stroke:none;"><path d="M19 9h-4V3H9v6H5l7 7 7-7zM5 18v2h14v-2H5z"/></svg> |
| DL |
| </button> |
| <div class="download-panel" id="download-panel-watch"></div> |
| </div> |
| <button class="action-btn" onclick="shareCurrentVideo()"> |
| <svg viewBox="0 0 24 24" style="width:16px;height:16px;fill:currentColor;stroke:none;"><path d="M18 16.08c-.76 0-1.44.3-1.96.77L8.91 12.7c.05-.23.09-.46.09-.7s-.04-.47-.09-.7l7.05-4.11c.54.5 1.25.81 2.04.81 1.66 0 3-1.34 3-3s-1.34-3-3-3-3 1.34-3 3c0 .24.04.47.09.7L8.04 9.81C7.5 9.31 6.79 9 6 9c-1.66 0-3 1.34-3 3s1.34 3 3 3c.79 0 1.5-.31 2.04-.81l7.12 4.16c-.05.21-.08.43-.08.65 0 1.61 1.31 2.92 2.92 2.92 1.61 0 2.92-1.31 2.92-2.92s-1.31-2.92-2.92-2.92z"/></svg> |
| 共有 |
| </button> |
| </div> |
| </div> |
| <div class="video-description" onclick="toggleDescription()"> |
| <div class="video-stats"> |
| <span id="api-views">---</span> 回視聴 • <span id="api-likes">---</span> 高評価 <span id="api-date"></span> |
| </div> |
| <div class="desc-content" id="api-desc">概要欄を読み込んでいます...</div> |
| <span id="desc-toggle-text">続きを読む</span> |
| </div> |
| <div class="comments-section"> |
| <div style="font-weight:bold; margin-bottom:12px;"><span id="comment-count">0</span> 件のコメント</div> |
| <div id="comments-container"></div> |
| </div> |
| </div> |
| <div class="watch-sidebar"> |
| <div style="font-weight:bold; margin-bottom:16px;">次の動画</div> |
| <div id="related-videos"></div> |
| <div id="related-loader" class="loader hidden">関連動画を読み込み中...</div> |
| </div> |
| </div> |
| </div> |
|
|
| |
| <div id="view-history" class="view"> |
| <h2 style="margin-bottom:20px;">視聴履歴</h2> |
| <div id="history-grid" class="video-grid"></div> |
| </div> |
|
|
| |
| <div id="view-settings" class="view"> |
| <div style="max-width:680px; margin:0 auto; padding-top:20px;"> |
| <h2 style="font-size:22px;font-weight:bold;margin-bottom:24px;">⚙️ 設定</h2> |
| <div class="settings-panel"> |
| <div class="settings-section"> |
| <div class="settings-section-title">ネットワーク</div> |
| <div class="setting-item"> |
| <div class="setting-info"><div class="setting-title">CORSプロキシを利用する</div><div class="setting-desc">外部APIへの通信時にプロキシを経由します(推奨)</div></div> |
| <label class="toggle-switch"><input type="checkbox" id="nav-setting-proxy" onchange="saveNavSettings()"><span class="slider"></span></label> |
| </div> |
| </div> |
| <div class="settings-section"> |
| <div class="settings-section-title">動画ストリーム(ウォッチページ)</div> |
| <div class="stream-options-grid" id="nav-stream-options"> |
| <button class="stream-option-btn" data-val="1" onclick="selectNavStream(1)">Nocookie<span class="s-label">標準・安定</span></button> |
| <button class="stream-option-btn" data-val="2" onclick="selectNavStream(2)">Edu<span class="s-label">教育版・Premium</span></button> |
| <button class="stream-option-btn" data-val="3" onclick="selectNavStream(3)">Manifest<span class="s-label">直接ストリーム</span></button> |
| </div> |
| <input type="hidden" id="nav-setting-stream" value="1"> |
| </div> |
| <div class="settings-section"> |
| <div class="settings-section-title">ショートストリーム</div> |
| <div class="stream-options-grid" id="nav-short-stream-options"> |
| <button class="stream-option-btn" data-val="1" onclick="selectNavShortStream(1)">Nocookie<span class="s-label">標準</span></button> |
| <button class="stream-option-btn" data-val="2" onclick="selectNavShortStream(2)">Edu<span class="s-label">教育版</span></button> |
| <button class="stream-option-btn" data-val="3" onclick="selectNavShortStream(3)">Manifest<span class="s-label">直接再生</span></button> |
| </div> |
| <input type="hidden" id="nav-setting-short-stream" value="1"> |
| </div> |
| <div class="settings-section"> |
| <div class="settings-section-title">表示</div> |
| <div class="setting-item"> |
| <div class="setting-info"><div class="setting-title">ホームにトレンド動画を表示</div></div> |
| <label class="toggle-switch"><input type="checkbox" id="nav-setting-trend" onchange="saveNavSettings()"><span class="slider"></span></label> |
| </div> |
| <div class="setting-item"> |
| <div class="setting-info"><div class="setting-title">ダークモード</div></div> |
| <label class="toggle-switch"><input type="checkbox" id="nav-setting-theme" onchange="toggleThemeFromNavSettings()"><span class="slider"></span></label> |
| </div> |
| </div> |
| <div class="settings-section"> |
| <div class="settings-section-title">登録チャンネル管理</div> |
| <div id="settings-subs-list"></div> |
| </div> |
| </div> |
| </div> |
| </div> |
|
|
| </main> |
|
|
| <script> |
| // =================== ベース設定 =================== |
| let currentView = 'welcome'; |
| let currentVideoId = null; let currentVideoTitle = ''; |
| let currentChannelName = ''; let currentChannelThumb = ''; |
| let currentChannelId = null; |
| let searchContext = 'trend'; let isFetching = false; |
| let lastQuery = ''; let currentPage = 1; let captchaTimer = null; |
| const seenVideoIds = new Set(); |
| const KAHOOT_KEY_URL = 'https://apis.kahoot.it/media-api/youtube/key'; |
| const CORS_PROXIES = ['https://api.codetabs.com/v1/proxy?quest=', 'https://api.codetabs.com/v1/tmp/?quest=']; |
| let currentChannelTab = 'home'; |
| let shortStreamType = 1; |
| let currentShortItems = []; |
| let currentEduKey = null; |
| let currentGVFormats = null; |
| let currentGVAllFormats = null; |
| let selectedQuality = null; |
| let currentChannelShortsPage = 1; |
| let currentChannelShortsName = ''; |
|
|
| const INVIDIOUS_INSTANCES = [ |
| 'https://invidious.f5.si','https://yt.omada.cafe','https://inv.thepixora.com', |
| 'https://yawtu.be','https://inv.nadeko.net','https://iv.duti.dev','https://inv.vern.cc', |
| 'https://invidious.privacyredirect.com','https://inv1.nadeko.net','https://inv2.nadeko.net', |
| 'https://inv3.nadeko.net','https://inv4.nadeko.net','https://inv5.nadeko.net', |
| 'https://yewtu.be','https://invidious.garudalinux.org','https://invidious.tiekoetter.com', |
| 'https://yt.artemislena.eu','https://invidious.privacydev.net','https://invidious.nerdvpn.de', |
| 'https://invidious.lunar.icu','https://invidious.slipfox.xyz','https://iv.ggtyler.dev', |
| 'https://nyc1.iv.ggtyler.dev','https://cal1.iv.ggtyler.dev','https://iv.catgirl.cloud', |
| 'https://invidious.sethforprivacy.com','https://invidious.weblibre.org','https://invidious.pufe.org', |
| 'https://invidious.projectsegfau.lt','https://invidious.fdn.fr','https://invidious.epicsite.xyz', |
| 'https://yt.funami.tech','https://invidious.esmailelbob.xyz','https://invidious.drivet.xyz', |
| 'https://invidious.flokinet.to','https://invidious.rhyshl.live','https://invidious.perennialte.ch', |
| 'https://invidious.private.coffee','https://inv.privacy.com.de','https://iv.nboeck.de' |
| ]; |
|
|
| // =================== 設定管理 =================== |
| function getAppConfig() { |
| const defaults = { proxy: true, stream: 1, shortStream: 1, trend: true, theme: 'light', isFirstVisit: true }; |
| try { return { ...defaults, ...JSON.parse(localStorage.getItem('study2525_config')) }; } catch(e) { return defaults; } |
| } |
| function saveSettings() { |
| const config = { |
| proxy: document.getElementById('setting-proxy').checked, |
| stream: parseInt(document.getElementById('setting-stream').value) || 1, |
| shortStream: parseInt(document.getElementById('setting-short-stream').value) || 1, |
| trend: document.getElementById('setting-trend').checked, |
| theme: document.body.getAttribute('data-theme') || 'light', |
| isFirstVisit: false |
| }; |
| localStorage.setItem('study2525_config', JSON.stringify(config)); syncSettings(config); |
| } |
| function saveNavSettings() { |
| const config = { |
| proxy: document.getElementById('nav-setting-proxy').checked, |
| stream: parseInt(document.getElementById('nav-setting-stream').value) || 1, |
| shortStream: parseInt(document.getElementById('nav-setting-short-stream').value) || 1, |
| trend: document.getElementById('nav-setting-trend').checked, |
| theme: document.body.getAttribute('data-theme') || 'light', |
| isFirstVisit: false |
| }; |
| localStorage.setItem('study2525_config', JSON.stringify(config)); syncSettings(config); |
| } |
| function syncSettings(config) { |
| const p1 = document.getElementById('setting-proxy'), p2 = document.getElementById('nav-setting-proxy'); |
| const t1 = document.getElementById('setting-trend'), t2 = document.getElementById('nav-setting-trend'); |
| const th1 = document.getElementById('setting-theme'), th2 = document.getElementById('nav-setting-theme'); |
| const s1 = document.getElementById('setting-stream'), s2 = document.getElementById('nav-setting-stream'); |
| const ss1 = document.getElementById('setting-short-stream'), ss2 = document.getElementById('nav-setting-short-stream'); |
| if(p1) p1.checked = config.proxy; if(p2) p2.checked = config.proxy; |
| if(t1) t1.checked = config.trend; if(t2) t2.checked = config.trend; |
| if(th1) th1.checked = config.theme==='dark'; if(th2) th2.checked = config.theme==='dark'; |
| if(s1) s1.value = config.stream; if(s2) s2.value = config.stream; |
| if(ss1) ss1.value = config.shortStream; if(ss2) ss2.value = config.shortStream; |
| ['welcome-stream-options','nav-stream-options'].forEach(id => { |
| const el = document.getElementById(id); |
| if (!el) return; |
| el.querySelectorAll('.stream-option-btn').forEach(b => b.classList.toggle('selected', parseInt(b.dataset.val) === config.stream)); |
| }); |
| ['welcome-short-stream-options','nav-short-stream-options'].forEach(id => { |
| const el = document.getElementById(id); |
| if (!el) return; |
| el.querySelectorAll('.stream-option-btn').forEach(b => b.classList.toggle('selected', parseInt(b.dataset.val) === config.shortStream)); |
| }); |
| } |
| function selectWelcomeStream(val) { |
| document.getElementById('setting-stream').value = val; |
| document.querySelectorAll('#welcome-stream-options .stream-option-btn').forEach(b => b.classList.toggle('selected', parseInt(b.dataset.val)===val)); |
| saveSettings(); |
| } |
| function selectWelcomeShortStream(val) { |
| document.getElementById('setting-short-stream').value = val; |
| document.querySelectorAll('#welcome-short-stream-options .stream-option-btn').forEach(b => b.classList.toggle('selected', parseInt(b.dataset.val)===val)); |
| saveSettings(); |
| } |
| function selectNavStream(val) { |
| document.getElementById('nav-setting-stream').value = val; |
| document.querySelectorAll('#nav-stream-options .stream-option-btn').forEach(b => b.classList.toggle('selected', parseInt(b.dataset.val)===val)); |
| saveNavSettings(); |
| } |
| function selectNavShortStream(val) { |
| document.getElementById('nav-setting-short-stream').value = val; |
| document.querySelectorAll('#nav-short-stream-options .stream-option-btn').forEach(b => b.classList.toggle('selected', parseInt(b.dataset.val)===val)); |
| saveNavSettings(); |
| } |
| function buildFetchUrl(targetUrl) { return getAppConfig().proxy ? CORS_PROXIES[0] + encodeURIComponent(targetUrl) : targetUrl; } |
|
|
| function initApp() { |
| const config = getAppConfig(); |
| if (config.theme === 'dark') document.body.setAttribute('data-theme', 'dark'); |
| shortStreamType = config.shortStream || 1; |
| syncSettings(config); |
| renderSidebarSubscriptions(); |
| const urlState = parseInitialUrl(); |
| if (config.isFirstVisit) { |
| navigate('welcome', { noHistory: true }); |
| } else if (urlState && urlState.view === 'watch' && urlState.videoId) { |
| navigate('home', { noHistory: true }); |
| fetch(`https://inv.nadeko.net/api/v1/videos/${urlState.videoId}?fields=title,author,authorThumbnails`) |
| .then(r => r.json()).then(d => { |
| playVideo(urlState.videoId, d.title || urlState.videoId, d.author || '', d.authorThumbnails?.[0]?.url || null, true); |
| }).catch(() => playVideo(urlState.videoId, urlState.videoId, '', null, true)); |
| } else if (urlState && urlState.view === 'history') { |
| navigate('history'); |
| } else if (urlState && urlState.view === 'settings') { |
| navigate('settings'); |
| } else if (urlState && urlState.view === 'subscriptions') { |
| navigate('subscriptions'); |
| } else { |
| navigate('home'); |
| } |
| } |
|
|
| function toggleTheme() { |
| const isDark = document.body.getAttribute('data-theme') === 'dark'; |
| document.body.setAttribute('data-theme', isDark ? 'light' : 'dark'); |
| const config = getAppConfig(); config.theme = isDark ? 'light' : 'dark'; |
| localStorage.setItem('study2525_config', JSON.stringify(config)); |
| syncSettings(config); |
| } |
| function toggleThemeFromSettings() { |
| const isDark = document.getElementById('setting-theme').checked; |
| document.body.setAttribute('data-theme', isDark ? 'dark' : 'light'); saveSettings(); |
| } |
| function toggleThemeFromNavSettings() { |
| const isDark = document.getElementById('nav-setting-theme').checked; |
| document.body.setAttribute('data-theme', isDark ? 'dark' : 'light'); saveNavSettings(); |
| } |
| function finishSetup() { saveSettings(); navigate('home'); if(getAppConfig().trend) loadTrend(); } |
|
|
| // =================== 登録チャンネル =================== |
| function getSubscriptions() { try { return JSON.parse(localStorage.getItem('subscriptions') || '[]'); } catch(e) { return []; } } |
| function saveSubscriptions(subs) { localStorage.setItem('subscriptions', JSON.stringify(subs)); } |
| function isSubscribed(channelName) { return getSubscriptions().some(s => s.name === channelName); } |
| function toggleSubscribeChannel() { |
| const name = document.getElementById('channel-page-name').innerText; |
| const thumb = document.getElementById('channel-page-icon').src; |
| let subs = getSubscriptions(); |
| if (isSubscribed(name)) { subs = subs.filter(s => s.name !== name); } else { subs.unshift({ name, thumb, channelId: currentChannelId || null, isLive: Math.random() < 0.15 }); } |
| saveSubscriptions(subs); updateChannelSubscribeUI(name); renderSidebarSubscriptions(); |
| } |
| function toggleSubscribeFromWatch() { |
| const name = document.getElementById('watch-channel-name').innerText; |
| const thumb = document.getElementById('watch-channel-icon').src; |
| let subs = getSubscriptions(); |
| if (isSubscribed(name)) { subs = subs.filter(s => s.name !== name); } else { subs.unshift({ name, thumb, isLive: Math.random() < 0.15 }); } |
| saveSubscriptions(subs); updateWatchSubscribeUI(name); renderSidebarSubscriptions(); |
| } |
| function updateChannelSubscribeUI(name) { |
| const btn = document.getElementById('channel-subscribe-btn'); |
| const bell = document.getElementById('channel-bell-btn'); |
| const join = document.getElementById('channel-join-btn'); |
| if (!btn) return; |
| if (isSubscribed(name)) { |
| btn.innerText = '登録済み'; btn.classList.add('subscribed'); |
| bell.style.display = 'flex'; join.style.display = 'block'; |
| } else { |
| btn.innerText = 'チャンネル登録'; btn.classList.remove('subscribed'); |
| bell.style.display = 'none'; join.style.display = 'none'; |
| } |
| } |
| function updateWatchSubscribeUI(name) { |
| const btn = document.getElementById('watch-subscribe-btn'); |
| if (!btn) return; |
| if (isSubscribed(name)) { btn.innerText = '登録済み'; btn.classList.add('subscribed'); } |
| else { btn.innerText = '登録'; btn.classList.remove('subscribed'); } |
| } |
| function renderSidebarSubscriptions() { |
| const subs = getSubscriptions(); |
| const container = document.getElementById('sidebar-subscriptions'); |
| if (!container) return; |
| if (subs.length === 0) { container.innerHTML = ''; return; } |
| container.innerHTML = subs.slice(0, 8).map(s => ` |
| <div class="sidebar-channel-item" onclick="openChannel('${s.name.replace(/'/g,"\\'")}', '${s.thumb}')"> |
| <img src="${s.thumb}" onerror="this.src='https://i.pravatar.cc/40?u=${encodeURIComponent(s.name)}'"> |
| <span class="channel-name-sidebar">${s.name}</span> |
| ${s.isLive ? '<span class="live-badge">LIVE</span>' : ''} |
| </div> |
| `).join(''); |
| } |
| function renderSubscriptionsPage() { |
| const subs = getSubscriptions(); |
| const grid = document.getElementById('subs-grid'); |
| if (!grid) return; |
| if (subs.length === 0) { |
| grid.innerHTML = '<div class="subs-empty" style="padding:60px 0;">チャンネルを登録するとここに表示されます<br><br><button onclick="navigate(\'home\')" style="padding:10px 24px;border-radius:20px;background:var(--primary-color);color:#fff;font-weight:bold;font-size:14px;margin-top:12px;">ホームへ戻る</button></div>'; |
| return; |
| } |
| grid.innerHTML = subs.map(s => ` |
| <div class="sub-channel-card" onclick="openChannel('${s.name.replace(/'/g,"\\'")}', '${s.thumb}')"> |
| <img src="${s.thumb}" onerror="this.src='https://i.pravatar.cc/56?u=${encodeURIComponent(s.name)}'"> |
| <div class="sub-channel-info"> |
| <div class="sub-channel-name">${s.name}</div> |
| <div class="sub-channel-meta">${s.isLive ? '🔴 ライブ配信中' : '登録済みチャンネル'}</div> |
| </div> |
| <button class="unsub-btn" onclick="event.stopPropagation(); unsubscribeChannelPage('${s.name.replace(/'/g,"\\'")}', this)">登録解除</button> |
| </div> |
| `).join(''); |
| } |
| function unsubscribeChannelPage(name, btn) { |
| let subs = getSubscriptions().filter(s => s.name !== name); |
| saveSubscriptions(subs); renderSubscriptionsPage(); renderSidebarSubscriptions(); renderSettingsSubsList(); |
| } |
| function renderSettingsSubsList() { |
| const subs = getSubscriptions(); |
| const el = document.getElementById('settings-subs-list'); |
| if (!el) return; |
| if (subs.length === 0) { el.innerHTML = '<div style="color:var(--text-secondary);padding:8px 0;">登録チャンネルはありません</div>'; return; } |
| el.innerHTML = subs.map(s => ` |
| <div style="display:flex;align-items:center;gap:12px;padding:10px 0;border-bottom:1px solid var(--border-color);"> |
| <img src="${s.thumb}" style="width:40px;height:40px;border-radius:50%;object-fit:cover;" onerror="this.src='https://i.pravatar.cc/40?u=${encodeURIComponent(s.name)}'"> |
| <span style="flex:1;font-weight:500;">${s.name}</span> |
| ${s.isLive ? '<span style="background:#ff0000;color:#fff;font-size:11px;font-weight:bold;padding:2px 7px;border-radius:4px;">LIVE</span>' : ''} |
| <button onclick="unsubscribeChannelSettings('${s.name.replace(/'/g,"\\'")}')" style="padding:6px 14px;border-radius:16px;background:var(--hover-color);font-size:13px;">登録解除</button> |
| </div> |
| `).join(''); |
| } |
| function unsubscribeChannelSettings(name) { |
| let subs = getSubscriptions().filter(s => s.name !== name); |
| saveSubscriptions(subs); renderSettingsSubsList(); renderSidebarSubscriptions(); |
| } |
|
|
| // =================== MANIFEST HUNTER (1つ目のHTMLを参考に実装) =================== |
| // データの全階層からmanifest.を抽出する再帰関数 |
| function deepSearch(obj) { |
| let found = []; |
| if (!obj || typeof obj !== 'object') return found; |
| if (obj.url && String(obj.url).includes('manifest.')) { |
| const type = String(obj.type || obj.mimeType || ''); |
| if (!type.includes('audio') || type.includes('video')) found.push(obj); |
| } |
| for (let k in obj) found = found.concat(deepSearch(obj[k])); |
| return found; |
| } |
|
|
| // プロキシを順番に試してmanifestを取得する |
| async function manifestHunt(videoId) { |
| const proxies = [ |
| '', // 直通 |
| 'https://api.allorigins.win/raw?url=', // Proxy A |
| 'https://corsproxy.io/?' // Proxy B |
| ]; |
| const apiBase = `https://siawaseok.f5.si/api/streams/${videoId}`; |
|
|
| for (const proxy of proxies) { |
| try { |
| const targetUrl = proxy ? proxy + encodeURIComponent(apiBase) : apiBase; |
| const res = await fetch(targetUrl, { signal: AbortSignal.timeout(15000) }); |
| if (!res.ok) throw new Error(`HTTP ${res.status}`); |
| const data = await res.json(); |
|
|
| // 1. deepSearchでmanifest.を含むURL再帰探索 |
| const manifestResults = deepSearch(data); |
| if (manifestResults.length > 0) { |
| const unique = Array.from(new Map(manifestResults.map(s => [s.url, s])).values()); |
| const sorted = unique.sort((a, b) => (parseInt(b.qualityLabel) || 0) - (parseInt(a.qualityLabel) || 0)); |
| return { type: 'manifest', streams: sorted }; |
| } |
|
|
| // 2. 通常のformats配列 |
| if (data.formats && data.formats.length > 0) { |
| const validFormats = data.formats.filter(f => f.url); |
| if (validFormats.length > 0) return { type: 'formats', streams: validFormats }; |
| } |
|
|
| // 3. 単一url |
| if (data.url) return { type: 'single', streams: [{ url: data.url, ext: 'mp4', height: 360 }] }; |
|
|
| } catch(e) { |
| console.warn('[ManifestHunter] proxy failed:', proxy || 'direct', e.message); |
| } |
| } |
| return null; |
| } |
|
|
| // =================== siawaseok API (manifestHuntを内部利用) =================== |
| async function fetchGVStreamsFromSiawaseok(videoId) { |
| const result = await manifestHunt(videoId); |
| if (!result) return null; |
| if (result.type === 'manifest' || result.type === 'formats') return result.streams; |
| if (result.type === 'single') return result.streams; |
| return null; |
| } |
|
|
| function parseSiawaseokFormats(formats) { |
| if (!formats) return { mp4Pairs: [], manifestList: [] }; |
| const mp4Formats = formats.filter(f => (f.ext === 'mp4' || f.container === 'mp4') && f.url && f.vcodec && f.vcodec !== 'none' && f.acodec === 'none'); |
| const m4aFormats = formats.filter(f => (f.ext === 'm4a' || f.acodec === 'mp4a.40.2') && f.url && (!f.vcodec || f.vcodec === 'none')); |
| const combinedMp4 = formats.filter(f => (f.ext === 'mp4') && f.url && f.acodec && f.acodec !== 'none' && f.vcodec && f.vcodec !== 'none'); |
| // manifestリスト (manifest.を含むURL全般) |
| const manifestList = formats.filter(f => f.url && (f.url.includes('manifest.') || f.url.includes('.m3u8') || f.url.includes('hls_playlist'))); |
| const mp4Pairs = []; |
| mp4Formats.forEach(vf => { |
| const res = getResolutionStr(vf); |
| const af = m4aFormats.find(a => true) || null; |
| mp4Pairs.push({ videoFmt: vf, audioFmt: af, resolution: res, label: getLabelFromFmt(vf) }); |
| }); |
| combinedMp4.forEach(f => { |
| const lbl = getLabelFromFmt(f); |
| if (!mp4Pairs.find(p => p.label === lbl)) mp4Pairs.push({ videoFmt: f, audioFmt: null, resolution: getResolutionStr(f), label: lbl, isCombined: true }); |
| }); |
| const order = ['2160p','1440p','1080p','720p','480p','360p','240p','144p']; |
| mp4Pairs.sort((a, b) => { |
| const ia = order.indexOf(a.label), ib = order.indexOf(b.label); |
| return (ia === -1 ? 99 : ia) - (ib === -1 ? 99 : ib); |
| }); |
| return { mp4Pairs, manifestList }; |
| } |
| function getResolutionStr(fmt) { |
| if (fmt.resolution) return fmt.resolution; |
| if (fmt.width && fmt.height) return `${fmt.width}x${fmt.height}`; |
| if (fmt.height) return `?x${fmt.height}`; |
| return ''; |
| } |
| function getLabelFromFmt(fmt) { |
| const note = (fmt.note || fmt.format_note || fmt.resolution || '').toString(); |
| const h = fmt.height ? `${fmt.height}p` : ''; |
| const priority = ['2160p','1440p','1080p','720p','480p','360p','240p','144p']; |
| for (const p of priority) { if (note.includes(p.replace('p','')) || h === p) return p; } |
| return h || note.substring(0, 10) || 'unknown'; |
| } |
|
|
| async function renderSiawaseokPlayer(formats, qualityLabel) { |
| const wrapper = document.getElementById('player-wrapper'); |
| const { mp4Pairs, manifestList } = parseSiawaseokFormats(formats); |
|
|
| // manifestリストが存在する場合はそちらを優先(Manifest Hunter成果物) |
| if (mp4Pairs.length === 0 && manifestList.length > 0) { |
| const mf = manifestList[0]; |
| await renderManifestPlayer(mf.url, getLabelFromFmt(mf) || 'Manifest'); |
| return; |
| } |
|
|
| if (mp4Pairs.length === 0) { |
| wrapper.innerHTML = `<div style="display:flex;align-items:center;justify-content:center;width:100%;height:100%;background:#000;color:#aaa;flex-direction:column;gap:8px;font-size:13px;"><span>再生できるフォーマットがありません</span><button onclick="switchStream(1)" style="padding:8px 16px;border-radius:20px;background:#fff;color:#000;font-weight:bold;cursor:pointer;border:none;">Nocookieで再生</button></div>`; |
| return; |
| } |
| const defaultLabels = ['480p','360p','720p']; |
| let target = mp4Pairs.find(p => p.label === qualityLabel); |
| if (!target) { for (const dl of defaultLabels) { target = mp4Pairs.find(p => p.label === dl); if (target) break; } } |
| if (!target) target = mp4Pairs[0]; |
| selectedQuality = target.label; |
| document.getElementById('quality-label').textContent = selectedQuality; |
| document.querySelectorAll('.quality-option').forEach(el => el.classList.toggle('active', el.dataset.q === selectedQuality)); |
| const videoUrl = target.videoFmt.url; |
| const audioUrl = target.audioFmt ? target.audioFmt.url : null; |
| if (target.isCombined || !audioUrl) { |
| wrapper.innerHTML = `<video id="gv-video" style="width:100%;height:100%;background:#000;" controls autoplay crossorigin="anonymous"><source src="${videoUrl}" type="video/mp4"></video>`; |
| document.getElementById('gv-video').onerror = () => { wrapper.innerHTML = failBlock(); }; |
| } else { |
| wrapper.innerHTML = `<video id="gv-video" style="width:100%;height:100%;background:#000;" controls autoplay crossorigin="anonymous"><source src="${videoUrl}" type="video/mp4"></video><audio id="gv-audio" style="display:none;" crossorigin="anonymous"><source src="${audioUrl}" type="audio/mp4"></audio>`; |
| const vid = document.getElementById('gv-video'), aud = document.getElementById('gv-audio'); |
| attachAudioVideoSync(vid, aud); |
| vid.onerror = () => { wrapper.innerHTML = failBlock(); }; |
| } |
| } |
|
|
| // manifest URL直接再生 (HLS/MPDなど) |
| async function renderManifestPlayer(manifestUrl, label) { |
| const wrapper = document.getElementById('player-wrapper'); |
| selectedQuality = label; |
| const ql = document.getElementById('quality-label'); if (ql) ql.textContent = label; |
| wrapper.innerHTML = `<video id="gv-video" style="width:100%;height:100%;background:#000;" controls autoplay></video>`; |
| const vid = document.getElementById('gv-video'); |
|
|
| if (manifestUrl.includes('.m3u8') || manifestUrl.includes('hls')) { |
| if (typeof Hls !== 'undefined' && Hls.isSupported()) { |
| const hls = new Hls({ enableWorker: true }); |
| hls.loadSource(manifestUrl); hls.attachMedia(vid); |
| hls.on(Hls.Events.MANIFEST_PARSED, () => { vid.play().catch(() => {}); }); |
| hls.on(Hls.Events.ERROR, (event, data) => { if (data.fatal) wrapper.innerHTML = failBlock(); }); |
| } else if (vid.canPlayType('application/vnd.apple.mpegurl')) { |
| vid.src = manifestUrl; vid.play().catch(() => {}); |
| } else { |
| wrapper.innerHTML = failBlock(); |
| } |
| } else { |
| // manifest.mpd等、そのままsrcにセット |
| vid.src = manifestUrl; |
| vid.play().catch(() => {}); |
| vid.onerror = () => { wrapper.innerHTML = failBlock(); }; |
| } |
| } |
|
|
| function failBlock() { return `<div style="display:flex;align-items:center;justify-content:center;width:100%;height:100%;background:#000;color:#aaa;flex-direction:column;gap:8px;font-size:13px;"><span>再生に失敗しました</span><button onclick="switchStream(1)" style="padding:8px 16px;border-radius:20px;background:#fff;color:#000;font-weight:bold;cursor:pointer;border:none;">Nocookieで再生</button></div>`; } |
|
|
| function buildSiawaseokQualityPanel(formats) { |
| const panel = document.getElementById('quality-panel'), wrap = document.getElementById('quality-wrap'); |
| const { mp4Pairs, manifestList } = parseSiawaseokFormats(formats); |
|
|
| // manifestのみの場合 |
| if (mp4Pairs.length === 0 && manifestList.length > 0) { |
| wrap.style.display = 'flex'; |
| const defaultOpt = manifestList[0]; |
| selectedQuality = getLabelFromFmt(defaultOpt) || 'Manifest'; |
| document.getElementById('quality-label').textContent = '🎯 ' + selectedQuality; |
| panel.innerHTML = '<div class="quality-panel-title">📡 Manifest ストリーム</div>' + |
| manifestList.map(f => { |
| const lbl = getLabelFromFmt(f) || 'Manifest'; |
| return `<div class="quality-option" data-q="${lbl}" onclick="selectManifestQuality('${encodeURIComponent(f.url)}','${lbl}')"> |
| 🎯 ${lbl} |
| </div>`; |
| }).join(''); |
| return; |
| } |
|
|
| if (mp4Pairs.length === 0) { wrap.style.display = 'none'; return; } |
| wrap.style.display = 'flex'; |
| const defaultLabels = ['480p','360p','720p']; |
| let defaultOpt = null; |
| for (const dl of defaultLabels) { defaultOpt = mp4Pairs.find(p => p.label === dl); if (defaultOpt) break; } |
| if (!defaultOpt) defaultOpt = mp4Pairs[0]; |
| selectedQuality = defaultOpt.label; |
| document.getElementById('quality-label').textContent = selectedQuality; |
| const hdLabels = ['1080p','720p','1440p','2160p']; |
| panel.innerHTML = '<div class="quality-panel-title">画質を選択(MP4+M4A)</div>' + |
| mp4Pairs.map(o => { |
| const isHd = hdLabels.includes(o.label), hasAudio = o.audioFmt || o.isCombined; |
| return `<div class="quality-option ${o.label === selectedQuality ? 'active' : ''}" data-q="${o.label}" onclick="selectSiawaseokQuality('${o.label}')"> |
| ${o.label} |
| ${isHd ? '<span class="q-badge">HD</span>' : ''} |
| ${hasAudio ? '<span style="font-size:10px;color:#4caf50;margin-left:4px;">🔊</span>' : ''} |
| </div>`; |
| }).join('') + |
| (manifestList.length > 0 ? '<div class="quality-panel-title" style="margin-top:8px;">📡 Manifest</div>' + |
| manifestList.map(f => { |
| const lbl = getLabelFromFmt(f) || 'Manifest'; |
| return `<div class="quality-option" data-q="m_${lbl}" onclick="selectManifestQuality('${encodeURIComponent(f.url)}','${lbl}')">🎯 ${lbl}</div>`; |
| }).join('') : ''); |
| } |
|
|
| async function selectManifestQuality(encodedUrl, label) { |
| const url = decodeURIComponent(encodedUrl); |
| selectedQuality = label; |
| document.getElementById('quality-label').textContent = '🎯 ' + label; |
| document.getElementById('quality-panel').classList.remove('open'); |
| await renderManifestPlayer(url, label); |
| } |
|
|
| async function selectSiawaseokQuality(label) { |
| if (!currentGVAllFormats) return; |
| selectedQuality = label; |
| document.getElementById('quality-label').textContent = label; |
| document.getElementById('quality-panel').classList.remove('open'); |
| await renderSiawaseokPlayer(currentGVAllFormats, label); |
| } |
|
|
| async function setupWatchManifest(videoId) { |
| const wrapper = document.getElementById('player-wrapper'); |
| wrapper.innerHTML = `<div style="display:flex;align-items:center;justify-content:center;width:100%;height:100%;background:#000;color:#fff;flex-direction:column;gap:12px;"> |
| <div style="width:40px;height:40px;border:4px solid rgba(255,255,255,0.3);border-top-color:#0f0;border-radius:50%;animation:spin 0.8s linear infinite;"></div> |
| <div style="font-size:13px;color:#0f0;font-family:monospace;">🚨 Manifestをスキャン中...</div> |
| </div><style>@keyframes spin{to{transform:rotate(360deg)}}</style>`; |
|
|
| // manifestHuntで直接取得 |
| const huntResult = await manifestHunt(videoId); |
|
|
| if (huntResult) { |
| if (huntResult.type === 'manifest') { |
| // manifest.URLが見つかった! |
| const streams = huntResult.streams; |
| currentGVAllFormats = streams; currentGVFormats = null; |
| buildSiawaseokQualityPanel(streams); |
| buildDownloadPanelFromSiawaseok(streams, 'watch'); |
| const first = streams[0]; |
| const label = first.qualityLabel || getLabelFromFmt(first) || 'Manifest'; |
| selectedQuality = label; |
| if (document.getElementById('quality-label')) document.getElementById('quality-label').textContent = '🎯 ' + label; |
| await renderManifestPlayer(first.url, label); |
| return; |
| } |
| if (huntResult.type === 'formats' && huntResult.streams.length > 0) { |
| const formats = huntResult.streams; |
| currentGVAllFormats = formats; currentGVFormats = null; |
| buildSiawaseokQualityPanel(formats); |
| buildDownloadPanelFromSiawaseok(formats, 'watch'); |
| const { mp4Pairs } = parseSiawaseokFormats(formats); |
| const defaultLabels = ['480p','360p','720p']; |
| let defaultQ = null; |
| for (const dl of defaultLabels) { defaultQ = mp4Pairs.find(p => p.label === dl); if (defaultQ) break; } |
| selectedQuality = defaultQ ? defaultQ.label : (mp4Pairs[0] ? mp4Pairs[0].label : null); |
| if (selectedQuality && document.getElementById('quality-label')) document.getElementById('quality-label').textContent = selectedQuality; |
| await renderSiawaseokPlayer(formats, selectedQuality); |
| return; |
| } |
| } |
|
|
| // Invidiousフォールバック |
| const invFormats = await fetchGoogleVideoStreamsInvidious(videoId); |
| if (invFormats && invFormats.length > 0) { |
| currentGVFormats = invFormats; currentGVAllFormats = null; |
| buildInvidiousQualityPanel(invFormats); |
| buildDownloadPanel(invFormats, 'watch'); |
| const opts = getInvidiousQualityOptions(invFormats); |
| const defaultQ = opts.find(o => o.label === '480p') || opts.find(o => o.label === '360p') || opts[0]; |
| selectedQuality = defaultQ ? defaultQ.label : null; |
| if (selectedQuality && document.getElementById('quality-label')) document.getElementById('quality-label').textContent = selectedQuality; |
| await renderGVPlayerInvidious(invFormats, selectedQuality); |
| return; |
| } |
|
|
| wrapper.innerHTML = `<div style="display:flex;align-items:center;justify-content:center;width:100%;height:100%;background:#000;color:#aaa;flex-direction:column;gap:8px;font-size:13px;"><span>ストリームの取得に失敗しました</span><button onclick="switchStream(1)" style="padding:8px 16px;border-radius:20px;background:#fff;color:#000;font-weight:bold;cursor:pointer;border:none;">Nocookieで再生</button></div>`; |
| } |
|
|
| async function fetchGoogleVideoStreamsInvidious(videoId) { |
| const priorityInstances = ['https://invidious.f5.si','https://yt.omada.cafe',...INVIDIOUS_INSTANCES.slice(2,10)]; |
| for (const instance of priorityInstances) { |
| try { |
| const url = buildFetchUrl(`${instance}/api/v1/videos/${videoId}?fields=adaptiveFormats,formatStreams`); |
| const res = await fetch(url, { signal: AbortSignal.timeout(6000) }); |
| if (!res.ok) continue; |
| const data = await res.json(); |
| const formats = [...(data.adaptiveFormats||[]),...(data.formatStreams||[])]; |
| if (formats.length > 0) return formats; |
| } catch(e) {} |
| } |
| return null; |
| } |
|
|
| function buildInvidiousQualityPanel(formats) { |
| const panel = document.getElementById('quality-panel'), wrap = document.getElementById('quality-wrap'); |
| const opts = getInvidiousQualityOptions(formats); |
| if (opts.length === 0) { wrap.style.display = 'none'; return; } |
| wrap.style.display = 'flex'; |
| const defaultQ = opts.find(o => o.label === '720p') || opts.find(o => o.label === '480p') || opts[0]; |
| selectedQuality = defaultQ.label; |
| document.getElementById('quality-label').textContent = selectedQuality; |
| const hdLabels = ['1080p','720p','1440p','2160p']; |
| panel.innerHTML = '<div class="quality-panel-title">画質を選択</div>' + opts.map(o => ` |
| <div class="quality-option ${o.label === selectedQuality ? 'active' : ''}" data-q="${o.label}" onclick="selectInvidiousQuality('${o.label}')"> |
| ${o.label}${hdLabels.includes(o.label) ? '<span class="q-badge">HD</span>' : ''} |
| </div> |
| `).join(''); |
| } |
| function getInvidiousQualityOptions(formats) { |
| const seen = new Set(), opts = []; |
| const priority = ['2160p','1440p','1080p','720p','480p','360p','240p','144p']; |
| for (const label of priority) { |
| const qNum = label.replace('p',''); |
| const vf = formats.find(f => f.type && f.type.includes('video') && !f.type.includes('audio') && f.qualityLabel && f.qualityLabel.includes(qNum) && f.url); |
| const cf = formats.find(f => f.type && f.type.includes('video') && f.type.includes('audio') && f.qualityLabel && f.qualityLabel.includes(qNum) && f.url); |
| if ((vf||cf) && !seen.has(label)) { seen.add(label); opts.push({ label, videoFmt: vf, combinedFmt: cf }); } |
| } |
| return opts; |
| } |
| async function selectInvidiousQuality(label) { |
| if (!currentGVFormats) return; |
| selectedQuality = label; |
| document.getElementById('quality-label').textContent = label; |
| document.getElementById('quality-panel').classList.remove('open'); |
| document.querySelectorAll('.quality-option').forEach(o => o.classList.toggle('active', o.dataset.q === label)); |
| await renderGVPlayerInvidious(currentGVFormats, label); |
| } |
| async function renderGVPlayerInvidious(formats, quality) { |
| const wrapper = document.getElementById('player-wrapper'); |
| const result = buildGoogleVideoPlayer(formats, quality); |
| if (!result) { wrapper.innerHTML = failBlock(); return; } |
| if (result.type === 'dual') { |
| wrapper.innerHTML = `<video id="gv-video" style="width:100%;height:100%;" controls autoplay crossorigin="anonymous"><source src="${result.videoUrl}" type="video/mp4"></video><audio id="gv-audio" style="display:none;" crossorigin="anonymous"><source src="${result.audioUrl}" type="audio/mp4"></audio>`; |
| attachAudioVideoSync(document.getElementById('gv-video'), document.getElementById('gv-audio')); |
| } else { |
| wrapper.innerHTML = `<video style="width:100%;height:100%;" controls autoplay crossorigin="anonymous"><source src="${result.url}"></video>`; |
| } |
| } |
| function buildGoogleVideoPlayer(formats, preferQuality = null) { |
| const audioFmt = formats.find(f => f.type && f.type.includes('audio') && !f.type.includes('video') && f.url) || formats.find(f => f.encoding && (f.encoding==='opus'||f.encoding==='aac') && f.url); |
| let videoFmt = null; |
| if (preferQuality) { |
| const qNum = preferQuality.replace('p',''); |
| videoFmt = formats.find(f => f.type && f.type.includes('video') && !f.type.includes('audio') && f.url && f.qualityLabel && f.qualityLabel.includes(qNum)); |
| } |
| if (!videoFmt) videoFmt = formats.find(f => f.type && f.type.includes('video') && !f.type.includes('audio') && f.url && f.qualityLabel && (f.qualityLabel.includes('720')||f.qualityLabel.includes('1080'))) || formats.find(f => f.type && f.type.includes('video') && !f.type.includes('audio') && f.url && f.qualityLabel && f.qualityLabel.includes('480')) || formats.find(f => f.type && f.type.includes('video') && !f.type.includes('audio') && f.url); |
| if (videoFmt && audioFmt) return { type: 'dual', videoUrl: videoFmt.url, audioUrl: audioFmt.url, quality: videoFmt.qualityLabel||'HD' }; |
| let combined = formats.find(f => f.type && f.type.includes('video') && f.type.includes('audio') && f.url && preferQuality && f.qualityLabel && f.qualityLabel.includes(preferQuality.replace('p',''))) || formats.find(f => f.type && f.type.includes('video') && f.type.includes('audio') && f.url) || formats.find(f => f.url && f.qualityLabel); |
| if (combined) return { type: 'single', url: combined.url, quality: combined.qualityLabel||'SD' }; |
| return null; |
| } |
|
|
| function toggleQualityPanel(e) { e.stopPropagation(); document.getElementById('quality-panel').classList.toggle('open'); } |
| document.addEventListener('click', () => { |
| document.getElementById('quality-panel')?.classList.remove('open'); |
| document.querySelectorAll('.download-panel').forEach(p => p.classList.remove('open')); |
| }); |
|
|
| // =================== ダウンロード =================== |
| function buildDownloadPanelFromSiawaseok(formats, panelId) { |
| const panel = document.getElementById(`download-panel-${panelId}`); |
| if (!panel) return; |
| const { mp4Pairs, manifestList } = parseSiawaseokFormats(formats); |
| const m4aFormats = formats.filter(f => (f.ext==='m4a'||f.acodec==='mp4a.40.2') && f.url && (!f.vcodec||f.vcodec==='none')); |
| let html = ''; |
| if (manifestList.length > 0) { |
| html += '<div style="padding:8px 16px;font-size:12px;color:var(--text-secondary);font-weight:bold;">🎯 Manifest</div>'; |
| manifestList.slice(0, 3).forEach(f => { |
| const lbl = getLabelFromFmt(f) || 'Manifest'; |
| html += `<div class="download-option" onclick="window.open('${f.url}','_blank')"><svg viewBox="0 0 24 24"><path d="M15 8H9v3H6l6 6 6-6h-3V8zm-9 9h12v2H6v-2z"/></svg><div class="dl-label"><span class="dl-title">Manifest ${lbl}</span><span class="dl-desc">右クリック→リンクを保存</span></div></div>`; |
| }); |
| } |
| if (mp4Pairs.length > 0) { |
| html += '<div style="padding:8px 16px;font-size:12px;color:var(--text-secondary);font-weight:bold;border-top:1px solid var(--border-color);margin-top:4px;">映像 (MP4)</div>'; |
| mp4Pairs.slice(0, 5).forEach(o => { |
| const url = o.videoFmt.url; |
| html += `<div class="download-option" onclick="startDownload('${encodeURIComponent(url)}','${o.label}.mp4','${o.label}')"><svg viewBox="0 0 24 24"><path d="M15 8H9v3H6l6 6 6-6h-3V8zm-9 9h12v2H6v-2z"/></svg><div class="dl-label"><span class="dl-title">${o.label} (MP4)</span><span class="dl-desc">映像のみ</span></div></div>`; |
| }); |
| } |
| if (m4aFormats.length > 0) { |
| html += '<div style="padding:8px 16px 4px;font-size:12px;color:var(--text-secondary);font-weight:bold;border-top:1px solid var(--border-color);margin-top:4px;">音声 (M4A)</div>'; |
| m4aFormats.slice(0, 2).forEach((f, i) => { |
| html += `<div class="download-option" onclick="startDownload('${encodeURIComponent(f.url)}','audio_${i}.m4a','M4A音声')"><svg viewBox="0 0 24 24"><path d="M12 3v10.55c-.59-.34-1.27-.55-2-.55-2.21 0-4 1.79-4 4s1.79 4 4 4 4-1.79 4-4V7h4V3h-6z"/></svg><div class="dl-label"><span class="dl-title">音声 (M4A)</span></div></div>`; |
| }); |
| } |
| html += `<div style="padding:8px 16px 4px;font-size:12px;color:var(--text-secondary);font-weight:bold;border-top:1px solid var(--border-color);margin-top:4px;">外部ツール</div><div class="download-option" onclick="openYtdl()"><svg viewBox="0 0 24 24"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 14.5v-9l6 4.5-6 4.5z"/></svg><div class="dl-label"><span class="dl-title">cobaltで開く</span><span class="dl-desc">cobalt.tools(高品質DL)</span></div></div>`; |
| panel.innerHTML = html; |
| } |
| function buildDownloadPanel(formats, panelId) { |
| const panel = document.getElementById(`download-panel-${panelId}`); |
| if (!panel) return; |
| const audioFmt = formats.find(f => f.type && f.type.includes('audio') && !f.type.includes('video') && f.url); |
| const videoOpts = getInvidiousQualityOptions(formats); |
| let html = '<div style="padding:8px 16px;font-size:12px;color:var(--text-secondary);font-weight:bold;">動画 (MP4)</div>'; |
| videoOpts.slice(0, 5).forEach(o => { |
| const url = (o.combinedFmt||o.videoFmt)?.url; |
| if (!url) return; |
| html += `<div class="download-option" onclick="startDownload('${encodeURIComponent(url)}','${o.label}.mp4','${o.label}')"><svg viewBox="0 0 24 24"><path d="M15 8H9v3H6l6 6 6-6h-3V8zm-9 9h12v2H6v-2z"/></svg><div class="dl-label"><span class="dl-title">${o.label} (MP4)</span></div></div>`; |
| }); |
| if (audioFmt) html += `<div style="padding:8px 16px 4px;font-size:12px;color:var(--text-secondary);font-weight:bold;border-top:1px solid var(--border-color);margin-top:4px;">音声</div><div class="download-option" onclick="startDownload('${encodeURIComponent(audioFmt.url)}','audio.m4a','M4A')"><svg viewBox="0 0 24 24"><path d="M12 3v10.55c-.59-.34-1.27-.55-2-.55-2.21 0-4 1.79-4 4s1.79 4 4 4 4-1.79 4-4V7h4V3h-6z"/></svg><div class="dl-label"><span class="dl-title">音声のみ (M4A)</span></div></div>`; |
| html += `<div style="padding:8px 16px 4px;font-size:12px;color:var(--text-secondary);font-weight:bold;border-top:1px solid var(--border-color);margin-top:4px;">外部ツール</div><div class="download-option" onclick="openYtdl()"><svg viewBox="0 0 24 24"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 14.5v-9l6 4.5-6 4.5z"/></svg><div class="dl-label"><span class="dl-title">cobaltで開く</span><span class="dl-desc">cobalt.tools</span></div></div>`; |
| panel.innerHTML = html; |
| } |
| function toggleDownloadPanel(e, panelId) { |
| e.stopPropagation(); |
| const panel = document.getElementById(`download-panel-${panelId}`); |
| if (panelId === 'watch' && !currentGVAllFormats && !currentGVFormats) { |
| panel.innerHTML = `<div style="padding:8px 16px;font-size:12px;color:var(--text-secondary);">Manifestモードでストリームを取得するか外部ツールをご利用ください</div><div class="download-option" onclick="openYtdl()"><svg viewBox="0 0 24 24" style="width:18px;height:18px;fill:currentColor;"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 14.5v-9l6 4.5-6 4.5z"/></svg><div class="dl-label"><span class="dl-title">cobaltで開く</span></div></div>`; |
| } |
| panel.classList.toggle('open'); |
| } |
| function openYtdl() { window.open(`https://cobalt.tools/?url=https://www.youtube.com/watch?v=${currentVideoId}`, '_blank'); } |
| async function startDownload(encodedUrl, filename, label) { |
| document.querySelectorAll('.download-panel').forEach(p => p.classList.remove('open')); |
| const url = decodeURIComponent(encodedUrl); |
| showDlToast(`${label}をダウンロード中...`, 0); |
| try { |
| const res = await fetch(url, { mode: 'cors' }); |
| if (!res.ok) throw new Error('fetch failed'); |
| const total = parseInt(res.headers.get('Content-Length') || '0'); |
| const reader = res.body.getReader(); |
| const chunks = []; let received = 0; |
| while (true) { |
| const { done, value } = await reader.read(); |
| if (done) break; |
| chunks.push(value); received += value.length; |
| if (total > 0) updateDlProgress(received / total * 100); |
| } |
| updateDlProgress(100); |
| const blob = new Blob(chunks); |
| const a = document.createElement('a'); |
| a.href = URL.createObjectURL(blob); |
| a.download = `${(currentVideoTitle||'video').replace(/[\\/:*?"<>|]/g,'_')}_${filename}`; |
| a.click(); |
| setTimeout(() => hideDlToast(), 2000); |
| } catch(e) { |
| hideDlToast(); |
| if (confirm('直接ダウンロードに失敗しました。cobalt.toolsで開きますか?')) openYtdl(); |
| } |
| } |
| function showDlToast(msg, progress) { |
| const t = document.getElementById('dl-toast'); |
| document.getElementById('dl-toast-msg').textContent = msg; |
| document.getElementById('dl-toast-fill').style.width = progress + '%'; |
| t.classList.add('show'); |
| } |
| function updateDlProgress(p) { document.getElementById('dl-toast-fill').style.width = p + '%'; } |
| function hideDlToast() { document.getElementById('dl-toast').classList.remove('show'); } |
|
|
| // =================== 共有 =================== |
| function shareCurrentVideo() { |
| if (!currentVideoId) return; |
| openSharePanel(`${location.origin}/watch?v=${currentVideoId}`); |
| } |
| function openSharePanel(url) { |
| document.getElementById('share-url-input').value = url; |
| document.getElementById('share-panel').classList.add('open'); |
| } |
| function closeSharePanel() { document.getElementById('share-panel').classList.remove('open'); } |
| function copyShareUrl() { |
| const input = document.getElementById('share-url-input'); |
| navigator.clipboard.writeText(input.value).then(() => { |
| const btn = document.querySelector('.share-copy-btn'); |
| btn.textContent = 'コピー済み!'; btn.style.background = '#4caf50'; |
| setTimeout(() => { btn.textContent = 'コピー'; btn.style.background = ''; }, 2000); |
| }).catch(() => { input.select(); document.execCommand('copy'); }); |
| } |
| function shareShort(videoId) { openSharePanel(`${location.origin}/shorts/${videoId}`); } |
|
|
| // =================== 音声同期 =================== |
| function attachAudioVideoSync(gvV, gvA) { |
| const syncTime = () => { if (Math.abs(gvA.currentTime - gvV.currentTime) > 0.3) gvA.currentTime = gvV.currentTime; }; |
| gvV.onplay = () => { gvA.play().catch(()=>{}); syncTime(); }; |
| gvV.onpause = () => gvA.pause(); |
| gvV.onwaiting = () => gvA.pause(); |
| gvV.onplaying = () => { gvA.play().catch(()=>{}); syncTime(); }; |
| gvV.onseeked = () => { gvA.currentTime = gvV.currentTime; }; |
| gvV.ontimeupdate = () => { syncTime(); }; |
| gvV.onratechange = () => { gvA.playbackRate = gvV.playbackRate; }; |
| gvV.onvolumechange = () => { gvA.volume = gvV.volume; gvA.muted = gvV.muted; }; |
| } |
|
|
| // =================== ショートObserver =================== |
| const shortSrcMap = {}; |
| let shortObserver = null; |
| function initShortObserver() { |
| if (shortObserver) { shortObserver.disconnect(); shortObserver = null; } |
| const sfp = document.getElementById('shorts-full-page'); |
| if (!sfp) return; |
| shortObserver = new IntersectionObserver((entries) => { |
| entries.forEach(entry => { |
| const item = entry.target; |
| const videoId = item.dataset.id; |
| const iframe = item.querySelector('iframe'); |
| if (!iframe || !videoId) return; |
| if (entry.isIntersecting) { |
| const savedSrc = shortSrcMap[videoId]; |
| if (savedSrc && iframe.src !== savedSrc) iframe.src = savedSrc; |
| } else { |
| if (iframe.src && iframe.src !== 'about:blank' && iframe.src !== '') { |
| if (!iframe.src.includes('about:blank')) shortSrcMap[videoId] = iframe.src; |
| iframe.src = 'about:blank'; |
| } |
| } |
| }); |
| }, { root: sfp, threshold: 0.5 }); |
| } |
| function observeShortItem(el) { if (shortObserver) shortObserver.observe(el); } |
|
|
| // =================== おすすめ =================== |
| function getWatchHistory() { try { return JSON.parse(localStorage.getItem('history') || '[]'); } catch(e) { return []; } } |
| function getSearchHistory() { try { return JSON.parse(localStorage.getItem('search_history') || '[]'); } catch(e) { return []; } } |
| function saveSearchHistory(query) { |
| let sh = getSearchHistory(); |
| sh = sh.filter(q => q !== query); sh.unshift(query); |
| localStorage.setItem('search_history', JSON.stringify(sh.slice(0, 30))); |
| } |
| async function loadRecommendations() { |
| const history = getWatchHistory(), searches = getSearchHistory(); |
| const grid = document.getElementById('home-recommend-grid'), section = document.getElementById('home-recommend'); |
| if (!grid || !section) return; |
| if (history.length < 2 && searches.length < 2) return; |
| section.classList.remove('hidden'); |
| grid.innerHTML = '<div class="loader" style="grid-column:1/-1;">おすすめを生成中...</div>'; |
| const keywords = []; |
| history.slice(0, 5).forEach(v => { const words = v.title.replace(/[【】「」『』\[\]【】]/g,' ').split(/\s+/); words.slice(0,2).forEach(w => { if (w.length > 2) keywords.push(w); }); }); |
| searches.slice(0, 3).forEach(s => keywords.push(s)); |
| if (keywords.length === 0) { section.classList.add('hidden'); return; } |
| const pick = keywords.sort(() => Math.random() - 0.5).slice(0, 2); |
| const results = []; |
| for (const kw of pick) { |
| const data = await fetchFromInvidious(kw, 'trend', 1); |
| if (data) data.filter(v => v.type==='video' && v.videoId).slice(0,4).forEach(v => { |
| if (!history.some(h => h.id === v.videoId) && results.length < 8) { |
| results.push({ id: v.videoId, title: v.title, channel: v.author, isShort: v.lengthSeconds > 0 && v.lengthSeconds <= 61, authorThumb: v.authorThumbnails ? v.authorThumbnails[0].url : `https://i.pravatar.cc/150?u=${v.author}`, duration: v.lengthSeconds||0, published: v.publishedText||'' }); |
| } |
| }); |
| } |
| grid.innerHTML = ''; |
| if (results.length === 0) { section.classList.add('hidden'); return; } |
| results.forEach(v => { |
| const card = document.createElement('div'); card.className = 'video-card'; |
| const durStr = formatDuration(v.duration); |
| card.onclick = () => playVideo(v.id, v.title, v.channel, v.authorThumb); |
| card.innerHTML = `<div class="thumbnail-container"><img src="https://i.ytimg.com/vi/${v.id}/mqdefault.jpg" loading="lazy">${durStr ? `<span class="duration-badge">${durStr}</span>` : ''}</div><div class="video-info"><div class="channel-avatar" onclick="event.stopPropagation(); openChannel('${v.channel.replace(/'/g,"\\'")}', '${v.authorThumb}')"><img src="${v.authorThumb}" loading="lazy"></div><div class="video-details"><div class="video-title">${v.title}</div><div class="video-meta"><span>${v.channel}</span>${v.published ? `<span>${v.published}</span>` : ''}</div></div></div>`; |
| grid.appendChild(card); |
| }); |
| } |
|
|
| // =================== APIフェッチ =================== |
| async function fetchFromInvidious(query, context, page = 1) { |
| let q = query; if (context === 'shorts') q += ' #shorts'; |
| for (let instance of INVIDIOUS_INSTANCES) { |
| try { |
| const url = buildFetchUrl(`${instance}/api/v1/search?q=${encodeURIComponent(q)}&page=${page}`); |
| const res = await fetch(url, { signal: AbortSignal.timeout(8000) }); |
| if (res.ok) return await res.json(); |
| } catch(e) {} |
| } |
| return null; |
| } |
|
|
| async function fetchChannelInfoFromInvidious(channelName) { |
| for (let instance of INVIDIOUS_INSTANCES) { |
| try { |
| const searchUrl = buildFetchUrl(`${instance}/api/v1/search?q=${encodeURIComponent(channelName)}&type=channel&page=1`); |
| const sRes = await fetch(searchUrl, { signal: AbortSignal.timeout(8000) }); |
| if (!sRes.ok) continue; |
| const sData = await sRes.json(); |
| const channel = sData.find(c => c.type === 'channel'); |
| if (!channel || !channel.authorId) continue; |
| const detailUrl = buildFetchUrl(`${instance}/api/v1/channels/${channel.authorId}`); |
| try { |
| const dRes = await fetch(detailUrl, { signal: AbortSignal.timeout(8000) }); |
| if (dRes.ok) { const detail = await dRes.json(); return { ...channel, ...detail, authorId: channel.authorId }; } |
| } catch(e) {} |
| return channel; |
| } catch(e) {} |
| } |
| return null; |
| } |
|
|
| async function fetchChannelLiveVideos(channelName) { |
| let channelId = null, channelInfo = null; |
| for (let instance of INVIDIOUS_INSTANCES) { |
| try { |
| const searchUrl = buildFetchUrl(`${instance}/api/v1/search?q=${encodeURIComponent(channelName)}&type=channel&page=1`); |
| const sRes = await fetch(searchUrl, { signal: AbortSignal.timeout(8000) }); |
| if (!sRes.ok) continue; |
| const sData = await sRes.json(); |
| const channel = sData.find(c => c.type === 'channel'); |
| if (channel) { channelId = channel.authorId; channelInfo = channel; break; } |
| } catch(e) {} |
| } |
| if (!channelId) return { videos: [], channelInfo: null }; |
| const allVideos = []; |
| for (let instance of INVIDIOUS_INSTANCES) { |
| try { |
| const streamsUrl = buildFetchUrl(`${instance}/api/v1/channels/${channelId}/streams`); |
| const res = await fetch(streamsUrl, { signal: AbortSignal.timeout(8000) }); |
| if (!res.ok) continue; |
| const data = await res.json(); |
| const videos = data.videos || data || []; |
| if (Array.isArray(videos) && videos.length > 0) { videos.forEach(v => { if (v.videoId && !allVideos.find(x => x.videoId === v.videoId)) allVideos.push({ ...v, _sourceType: 'stream' }); }); break; } |
| } catch(e) {} |
| } |
| return { videos: allVideos, channelInfo }; |
| } |
| async function fetchChannelVideos(channelName, page = 1) { |
| for (let instance of INVIDIOUS_INSTANCES) { |
| try { |
| const searchUrl = buildFetchUrl(`${instance}/api/v1/search?q=${encodeURIComponent(channelName)}&type=channel&page=1`); |
| const sRes = await fetch(searchUrl, { signal: AbortSignal.timeout(8000) }); |
| if (!sRes.ok) continue; |
| const sData = await sRes.json(); |
| const channel = sData.find(c => c.type === 'channel'); |
| if (!channel) continue; |
| const videosUrl = buildFetchUrl(`${instance}/api/v1/channels/${channel.authorId}/videos?page=${page}`); |
| const vRes = await fetch(videosUrl, { signal: AbortSignal.timeout(8000) }); |
| if (!vRes.ok) continue; |
| const vData = await vRes.json(); |
| return { videos: vData.videos || [], channelInfo: channel }; |
| } catch(e) {} |
| } |
| return null; |
| } |
| async function fetchChannelShortsMultiPage(channelName, startPage = 1) { |
| let channelId = null; |
| for (let instance of INVIDIOUS_INSTANCES) { |
| try { |
| const searchUrl = buildFetchUrl(`${instance}/api/v1/search?q=${encodeURIComponent(channelName)}&type=channel&page=1`); |
| const sRes = await fetch(searchUrl, { signal: AbortSignal.timeout(8000) }); |
| if (!sRes.ok) continue; |
| const sData = await sRes.json(); |
| const channel = sData.find(c => c.type === 'channel'); |
| if (channel) { channelId = channel.authorId; break; } |
| } catch(e) {} |
| } |
| if (!channelId) return { shorts: [], hasMore: false }; |
| const pagesToFetch = [startPage, startPage+1, startPage+2]; |
| const allVideos = []; |
| for (const page of pagesToFetch) { |
| for (let instance of INVIDIOUS_INSTANCES) { |
| try { |
| const videosUrl = buildFetchUrl(`${instance}/api/v1/channels/${channelId}/videos?page=${page}`); |
| const vRes = await fetch(videosUrl, { signal: AbortSignal.timeout(8000) }); |
| if (!vRes.ok) continue; |
| const vData = await vRes.json(); |
| if (vData.videos && vData.videos.length > 0) { allVideos.push(...vData.videos); break; } |
| } catch(e) {} |
| } |
| } |
| const shorts = allVideos.filter(v => v.lengthSeconds > 0 && v.lengthSeconds <= 61).map(v => ({ id: v.videoId, title: v.title, channel: v.author || channelName, isShort: true, authorThumb: v.authorThumbnails ? v.authorThumbnails[0].url : `https://i.pravatar.cc/150?u=${encodeURIComponent(v.author||channelName)}`, duration: v.lengthSeconds, published: v.publishedText||'' })); |
| return { shorts, hasMore: allVideos.length >= 20 }; |
| } |
|
|
| // =================== CSE =================== |
| window.__gcse = { |
| parsetags: 'explicit', |
| initializationCallback: function() { |
| google.search.cse.element.render({ div: "hidden-cse-container", tag: 'searchresults-only', gname: 'studyCse' }); |
| initApp(); |
| const config = getAppConfig(); |
| if (!config.isFirstVisit && config.trend) loadTrend(); |
| else if (!config.isFirstVisit && !config.trend) document.getElementById('home-loader').classList.add('hidden'); |
| }, |
| searchCallbacks: { |
| web: { |
| ready: function(name, q, promos, results) { |
| isFetching = false; clearTimeout(captchaTimer); |
| document.getElementById('hidden-cse-container').style.display = 'none'; |
| let videos = []; |
| if (results && results.length > 0) { |
| results.forEach(r => { |
| const urlStr = r.unescapedUrl || r.url || "", titleStr = r.titleNoFormatting || r.title || ""; |
| const isShort = urlStr.includes('/shorts/') || titleStr.toLowerCase().includes('#shorts'); |
| const id = (urlStr.match(/(?:v=|vi\/|youtu\.be\/|embed\/|v%3D|video\/|shorts\/)([a-zA-Z0-9_-]{11})/) || [])[1]; |
| if (searchContext === 'shorts' && !isShort && !urlStr.includes('shorts')) return; |
| if (id && id.length === 11 && !seenVideoIds.has(id)) { |
| seenVideoIds.add(id); |
| const mockChannel = (r.visibleUrl||"YouTube Channel").split('/')[0]; |
| videos.push({ id, title: titleStr, isShort, channel: mockChannel, authorThumb: `https://i.pravatar.cc/150?u=${mockChannel}` }); |
| } |
| }); |
| } |
| renderResults(videos, currentPage > 1); |
| if (searchContext === 'trend' && currentPage <= 1) { fetchShortsForHome(); loadRecommendations(); } |
| return true; |
| } |
| } |
| } |
| }; |
|
|
| async function triggerSearch(query, context, append = false) { |
| if (isFetching) return; isFetching = true; searchContext = context; lastQuery = query; |
| if (!append) { seenVideoIds.clear(); currentPage = 1; } else { currentPage++; } |
|
|
| if (context === 'channel-home' || context === 'channel-videos') { |
| const result = await fetchChannelVideos(query, currentPage); |
| isFetching = false; |
| if (result && result.videos && result.videos.length > 0) { |
| let videos = result.videos.map(v => ({ id: v.videoId, title: v.title, channel: v.author || query, isShort: false, authorThumb: v.authorThumbnails ? v.authorThumbnails[0].url : `https://i.pravatar.cc/150?u=${encodeURIComponent(v.author||query)}`, duration: v.lengthSeconds||0, published: v.publishedText||'', isLive: v.liveNow||false, isArchived: !v.liveNow && v.lengthSeconds > 0 && (v.title && (v.title.includes('ライブ')||v.title.includes('配信')||v.title.includes('LIVE'))), publishedTimestamp: v.published||0 })); |
| renderResults(videos, append); |
| if (result.channelInfo && !append) updateChannelHeaderInfo(result.channelInfo); |
| return; |
| } |
| const invData = await fetchFromInvidious(query, context, currentPage); |
| if (invData && invData.length > 0) { |
| let videos = []; |
| invData.forEach(item => { |
| if (item.type === 'video' && item.videoId && !seenVideoIds.has(item.videoId)) { |
| const authorNorm = (item.author||'').toLowerCase().trim(), queryNorm = query.toLowerCase().trim(); |
| if (!authorNorm.includes(queryNorm) && !queryNorm.includes(authorNorm)) return; |
| seenVideoIds.add(item.videoId); |
| videos.push({ id: item.videoId, title: item.title, channel: item.author||query, isShort: false, isLive: item.liveNow||false, isArchived: !item.liveNow && (item.title && (item.title.includes('ライブ')||item.title.includes('配信')||item.title.includes('LIVE'))), authorThumb: item.authorThumbnails ? item.authorThumbnails[0].url : `https://i.pravatar.cc/150?u=${encodeURIComponent(item.author||query)}`, duration: item.lengthSeconds||0, published: item.publishedText||'', publishedTimestamp: item.published||0 }); |
| } |
| }); |
| renderResults(videos, append); return; |
| } |
| return; |
| } |
| if (context === 'channel-shorts') { |
| const cleanName = query.replace(/\s*shorts\s*/gi,'').replace(/#/g,'').trim(); |
| currentChannelShortsName = cleanName; isFetching = false; |
| const { shorts, hasMore } = await fetchChannelShortsMultiPage(cleanName, currentChannelShortsPage); |
| if (shorts.length > 0) { |
| renderChannelShorts(shorts, append); |
| const moreBtn = document.getElementById('channel-shorts-more-btn'); |
| if (moreBtn) moreBtn.style.display = hasMore ? 'block' : 'none'; |
| } else { |
| const invData = await fetchFromInvidious(cleanName + ' #shorts', 'shorts', currentPage); |
| if (invData && invData.length > 0) { |
| let videos = invData.filter(item => item.type==='video' && item.videoId && item.lengthSeconds > 0 && item.lengthSeconds <= 61).map(item => ({ id: item.videoId, title: item.title, channel: item.author||cleanName, isShort: true, authorThumb: item.authorThumbnails ? item.authorThumbnails[0].url : `https://i.pravatar.cc/150?u=${encodeURIComponent(item.author||cleanName)}`, duration: item.lengthSeconds, published: item.publishedText||'' })); |
| renderChannelShorts(videos, append); |
| } else { |
| document.getElementById('channel-shorts-loader')?.classList.add('hidden'); |
| const grid = document.getElementById('channel-shorts-grid'); |
| if (grid && !append) grid.innerHTML = '<div style="padding:24px;color:var(--text-secondary);">ショート動画が見つかりませんでした</div>'; |
| } |
| } |
| return; |
| } |
| if (context === 'channel-live') { |
| const chName = query.replace(/ ライブ$/, '').trim(); isFetching = false; |
| const { videos: liveVideos, channelInfo } = await fetchChannelLiveVideos(chName); |
| if (liveVideos && liveVideos.length > 0) { |
| const mapped = liveVideos.map(v => ({ id: v.videoId, title: v.title||'', channel: v.author||chName, isShort: false, isLive: !!(v.liveNow||v.isUpcoming), isUpcoming: !!v.isUpcoming, isArchived: !v.liveNow && !v.isUpcoming, authorThumb: v.authorThumbnails ? v.authorThumbnails[0].url : `https://i.pravatar.cc/150?u=${encodeURIComponent(v.author||chName)}`, duration: v.lengthSeconds||0, published: v.publishedText||'', publishedTimestamp: v.published||0 })); |
| renderChannelLive(mapped, append); |
| if (channelInfo && !append) updateChannelHeaderInfo(channelInfo); |
| } else { |
| const result = await fetchChannelVideos(chName, 1); |
| if (result && result.videos) { |
| const liveFiltered = result.videos.filter(v => v.liveNow || v.isUpcoming || (v.title && v.title.match(/ライブ|配信|LIVE|live|生放送|STREAM/i))); |
| const mapped = (liveFiltered.length > 0 ? liveFiltered : result.videos.slice(0,12)).map(v => ({ id: v.videoId, title: v.title, channel: v.author||chName, isShort: false, isLive: !!(v.liveNow||v.isUpcoming), isUpcoming: !!v.isUpcoming, isArchived: !v.liveNow && !v.isUpcoming && (v.title && !!v.title.match(/ライブ|配信|LIVE|live|生放送/i)), authorThumb: v.authorThumbnails ? v.authorThumbnails[0].url : `https://i.pravatar.cc/150?u=${encodeURIComponent(v.author||chName)}`, duration: v.lengthSeconds||0, published: v.publishedText||'', publishedTimestamp: v.published||0 })); |
| renderChannelLive(mapped, append); |
| } else { |
| document.getElementById('channel-live-loader')?.classList.add('hidden'); |
| document.getElementById('channel-live-grid').innerHTML = '<div class="subs-empty">ライブ・配信動画が見つかりません</div>'; |
| } |
| } |
| return; |
| } |
| const invData = await fetchFromInvidious(query, context, currentPage); |
| if (invData && invData.length > 0) { |
| isFetching = false; let videos = []; |
| invData.forEach(item => { |
| if (item.type === 'video' && item.videoId && !seenVideoIds.has(item.videoId)) { |
| if (context === 'shorts' && item.lengthSeconds > 61) return; |
| seenVideoIds.add(item.videoId); |
| videos.push({ id: item.videoId, title: item.title, channel: item.author, isShort: item.lengthSeconds > 0 && item.lengthSeconds <= 61, isLive: item.liveNow||false, authorThumb: item.authorThumbnails ? item.authorThumbnails[0].url : `https://i.pravatar.cc/150?u=${item.author}`, duration: item.lengthSeconds||0, published: item.publishedText||'' }); |
| } |
| }); |
| renderResults(videos, append); |
| if (context === 'search' && !append) fetchShortsForSearch(query); |
| if (context === 'trend' && !append) { fetchShortsForHome(); loadRecommendations(); } |
| return; |
| } |
| clearTimeout(captchaTimer); |
| captchaTimer = setTimeout(() => { |
| if (isFetching) { |
| const c = document.getElementById('hidden-cse-container'); |
| c.style.display = 'block'; c.style.position = 'fixed'; c.style.top = '100px'; c.style.left = '50%'; c.style.transform = 'translateX(-50%)'; c.style.zIndex = '10000'; |
| alert("ロボット確認が必要です。画面の指示に従ってください。"); |
| } |
| }, 3000); |
| const element = google.search.cse.element.getElement('studyCse'); |
| const decor = append ? ["", " pv", " hd"][Math.floor(Math.random()*3)] : ""; |
| element.execute(query + decor + " site:youtube.com"); |
| } |
|
|
| async function loadMoreChannelShorts() { |
| currentChannelShortsPage += 3; |
| document.getElementById('channel-shorts-more-btn').style.display = 'none'; |
| document.getElementById('channel-shorts-loader').classList.remove('hidden'); |
| const { shorts, hasMore } = await fetchChannelShortsMultiPage(currentChannelShortsName, currentChannelShortsPage); |
| renderChannelShorts(shorts, true); |
| const moreBtn = document.getElementById('channel-shorts-more-btn'); |
| if (moreBtn) moreBtn.style.display = hasMore && shorts.length > 0 ? 'block' : 'none'; |
| } |
|
|
| function updateChannelHeaderInfo(channelInfo) { |
| if (!channelInfo) return; |
| const nameEl = document.getElementById('channel-page-name'), handleEl = document.getElementById('channel-page-handle'), metaEl = document.getElementById('channel-page-meta'), iconEl = document.getElementById('channel-page-icon'); |
| if (channelInfo.author) nameEl.innerText = channelInfo.author; |
| if (channelInfo.authorId) { handleEl.innerText = `@${channelInfo.authorId}`; currentChannelId = channelInfo.authorId; try { history.replaceState({ view: 'channel', channelId: channelInfo.authorId }, '', `/channel/${channelInfo.authorId}`); } catch(e) {} } |
| if (channelInfo.subCount) metaEl.innerText = `登録者数 ${formatSubCount(channelInfo.subCount)} • 動画 ${channelInfo.videoCount||'--'}件`; |
| if (channelInfo.authorThumbnails && channelInfo.authorThumbnails.length > 0) iconEl.src = channelInfo.authorThumbnails[channelInfo.authorThumbnails.length-1].url; |
| if (channelInfo.authorBanners && channelInfo.authorBanners.length > 0) { |
| const bestBanner = channelInfo.authorBanners.reduce((best, b) => (!best || (b.width || 0) > (best.width || 0)) ? b : best, null); |
| if (bestBanner && bestBanner.url) drawChannelBanner(bestBanner.url); |
| } else { drawChannelBanner(null); } |
| updateChannelSubscribeUI(nameEl.innerText); |
| } |
| function formatSubCount(n) { |
| if (!n) return '--'; |
| if (n >= 1000000) return (n/1000000).toFixed(1)+'万人'; |
| if (n >= 10000) return Math.floor(n/10000)+'万人'; |
| if (n >= 1000) return (n/1000).toFixed(1)+'K人'; |
| return n+'人'; |
| } |
| function drawChannelBanner(bannerUrl) { |
| const img = document.getElementById('channel-banner-img'), canvas = document.getElementById('channel-banner-canvas'); |
| if (bannerUrl) { img.src = bannerUrl; img.style.display = 'block'; canvas.style.display = 'none'; } |
| else { img.style.display = 'none'; canvas.style.display = 'block'; drawFallbackBanner(canvas); } |
| } |
| function drawFallbackBanner(canvas) { |
| canvas.width = canvas.offsetWidth || 1280; canvas.height = 180; |
| const ctx = canvas.getContext('2d'); |
| const colors = [['#ff0000','#ff6b6b'],['#4285f4','#34a853'],['#ff6d00','#ffab00'],['#7c4dff','#e040fb'],['#00bcd4','#009688'],['#e91e63','#ff5722'],['#1a237e','#283593']]; |
| const c = colors[Math.floor(Math.random()*colors.length)]; |
| const grad = ctx.createLinearGradient(0,0,canvas.width,canvas.height); |
| grad.addColorStop(0,c[0]); grad.addColorStop(1,c[1]); |
| ctx.fillStyle = grad; ctx.fillRect(0,0,canvas.width,canvas.height); |
| ctx.globalAlpha = 0.15; ctx.fillStyle = '#ffffff'; |
| ctx.beginPath(); ctx.arc(canvas.width*0.8,canvas.height*0.3,120,0,Math.PI*2); ctx.fill(); |
| ctx.beginPath(); ctx.arc(canvas.width*0.2,canvas.height*0.9,80,0,Math.PI*2); ctx.fill(); |
| ctx.globalAlpha = 1; |
| } |
|
|
| async function fetchShortsForHome() { |
| const container = document.getElementById('home-shorts-container'), section = document.getElementById('home-shorts'); |
| if (!container || !section) return; |
| const queries = ["人気 #shorts","#shorts 人気","ショート 人気","shorts popular japan"]; |
| let shorts = []; |
| for (const q of queries) { |
| const data = await fetchFromInvidious(q,"shorts",1); |
| if (data && data.length > 0) { shorts = data.filter(v => v.videoId && v.lengthSeconds >= 0 && v.lengthSeconds <= 65); if (shorts.length === 0) shorts = data.filter(v => v.videoId && (v.lengthSeconds <= 65 || v.isShort)); if (shorts.length > 0) break; } |
| } |
| if (shorts.length === 0) return; |
| container.innerHTML = ''; |
| shorts.slice(0,10).forEach(v => { |
| const div = document.createElement('div'); div.className = 'home-short-card'; |
| div.onclick = () => navigateToShortPage(v.videoId, v.title, v.author, v.authorThumbnails?.[0]?.url); |
| div.innerHTML = `<div class="home-short-thumb"><img src="https://i.ytimg.com/vi/${v.videoId}/hqdefault.jpg" loading="lazy" onerror="this.src='https://i.ytimg.com/vi/${v.videoId}/mqdefault.jpg'"></div><div class="home-short-title">${v.title}</div>`; |
| container.appendChild(div); |
| }); |
| section.classList.remove('hidden'); |
| } |
| async function fetchShortsForSearch(query) { |
| const data = await fetchFromInvidious(query + ' #shorts',"shorts",1); |
| if (data && data.length > 0) { |
| const shorts = data.filter(v => v.videoId && v.lengthSeconds > 0 && v.lengthSeconds <= 61).slice(0,8); |
| if (shorts.length === 0) return; |
| const container = document.getElementById('search-shorts-container'); |
| container.innerHTML = ''; |
| shorts.forEach(v => { |
| const div = document.createElement('div'); div.className = 'home-short-card'; |
| div.onclick = () => navigateToShortPage(v.videoId, v.title, v.author, v.authorThumbnails?.[0]?.url); |
| div.innerHTML = `<div class="home-short-thumb"><img src="https://i.ytimg.com/vi/${v.videoId}/hqdefault.jpg" loading="lazy"></div><div class="home-short-title">${v.title}</div>`; |
| container.appendChild(div); |
| }); |
| document.getElementById('search-shorts-section').classList.remove('hidden'); |
| } |
| } |
|
|
| function navigateToShortPage(videoId, title, channel, thumb, noHistory = false) { |
| navigate('shorts', { videoId, title, channel, thumb, noHistory }); |
| setTimeout(() => { |
| const container = document.getElementById('shorts-container'); |
| container.innerHTML = ''; currentShortItems = []; |
| initShortObserver(); |
| const targetVideo = { id: videoId, title: title||'', channel: channel||'', authorThumb: thumb || `https://i.pravatar.cc/80?u=${videoId}`, isShort: true }; |
| renderShorts([targetVideo], false); |
| triggerSearch("#shorts 人気",'shorts',true); |
| }, 100); |
| } |
|
|
| // =================== ショートストリーム (3タイプ: NC/Edu/Manifest) =================== |
| function buildShortNocookieSrc(videoId) { |
| return `https://www.youtube-nocookie.com/embed/${videoId}?autoplay=1&mute=1&loop=1&playlist=${videoId}&rel=0`; |
| } |
| function buildShortEduSrc(videoId, key) { |
| if (!key) return buildShortNocookieSrc(videoId); |
| const cfg = encodeURIComponent(JSON.stringify({enc: key, hideTitle: true})); |
| return `https://www.youtubeeducation.com/embed/${videoId}?autoplay=1&origin=https%3A%2F%2Fcreate.kahoot.it&embed_config=${cfg}`; |
| } |
|
|
| function renderShorts(videos, append = false) { |
| const container = document.getElementById('shorts-container'); |
| if (!append) { container.innerHTML = ''; currentShortItems = []; initShortObserver(); } |
| currentShortItems = [...currentShortItems, ...videos]; |
| videos.forEach(v => { |
| const item = document.createElement('div'); |
| item.className = 'short-snap-item'; item.dataset.id = v.id; |
| const channelSafe = (v.channel||'').replace(/'/g,"\\'"); |
| const titleSafe = (v.title||'').replace(/</g,'<'); |
| const initialSrc = buildShortNocookieSrc(v.id); |
| shortSrcMap[v.id] = initialSrc; |
| const likeCount = Math.floor(Math.random()*50000)+100; |
| const likeStr = likeCount >= 10000 ? (likeCount/1000).toFixed(0)+'K' : likeCount.toLocaleString(); |
| const subbed = isSubscribed(v.channel||''); |
| item.innerHTML = ` |
| <div class="short-center"> |
| <div class="short-video-wrap" id="short-wrap-${v.id}"> |
| <iframe src="about:blank" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowfullscreen></iframe> |
| <div class="short-stream-selector"> |
| <button class="short-stream-btn ${shortStreamType===1?'active':''}" onclick="changeShortStream(1,'${v.id}')">NC</button> |
| <button class="short-stream-btn ${shortStreamType===2?'active':''}" onclick="changeShortStream(2,'${v.id}')">Edu</button> |
| <button class="short-stream-btn ${shortStreamType===3?'active':''}" onclick="changeShortStream(3,'${v.id}')">MF</button> |
| </div> |
| <div class="short-overlay-bottom"> |
| <div class="short-channel" onclick="openChannel('${channelSafe}','${v.authorThumb||''}')"> |
| <img src="${v.authorThumb||`https://i.pravatar.cc/80?u=${v.channel}`}" onerror="this.src='https://i.pravatar.cc/80?u=${encodeURIComponent(v.channel||'')}'"> |
| <span class="short-channel-name">${v.channel||''}</span> |
| <button class="short-sub-btn ${subbed?'subscribed':''}" id="short-sub-btn-${v.id}" onclick="event.stopPropagation();toggleSubscribeFromShort('${channelSafe}','${v.authorThumb||''}',this)">${subbed?'登録済み':'登録'}</button> |
| </div> |
| <div class="short-title">${titleSafe}</div> |
| </div> |
| </div> |
| <div class="short-side-actions"> |
| <div class="short-action-btn" onclick="openChannel('${channelSafe}','${v.authorThumb||''}')"> |
| <div class="icon short-avatar-btn"> |
| <img src="${v.authorThumb||`https://i.pravatar.cc/80?u=${v.channel}`}" style="width:48px;height:48px;border-radius:50%;object-fit:cover;" onerror="this.src='https://i.pravatar.cc/48?u=${encodeURIComponent(v.channel||'')}'"> |
| </div> |
| </div> |
| <div class="short-action-btn" onclick="toggleShortLike(this)"> |
| <div class="icon"><svg viewBox="0 0 24 24" style="fill:currentColor;stroke:none;"><path d="M1 21h4V9H1v12zm22-11c0-1.1-.9-2-2-2h-6.31l.95-4.57.03-.32c0-.41-.17-.79-.44-1.06L14.17 1 7.59 7.59C7.22 7.95 7 8.45 7 9v10c0 1.1.9 2 2 2h9c.83 0 1.54-.5 1.84-1.22l3.02-7.05c.09-.23.14-.47.14-.73v-2z"/></svg></div> |
| <span class="short-like-count">${likeStr}</span> |
| </div> |
| <div class="short-action-btn" onclick="toggleShortDislike(this)"> |
| <div class="icon"><svg viewBox="0 0 24 24" style="fill:currentColor;stroke:none;"><path d="M15 3H6c-.83 0-1.54.5-1.84 1.22l-3.02 7.05c-.09.23-.14.47-.14.73v2c0 1.1.9 2 2 2h6.31l-.95 4.57-.03.32c0 .41.17.79.44 1.06L9.83 23l6.59-6.59c.36-.36.58-.86.58-1.41V5c0-1.1-.9-2-2-2zm4 0v12h4V3h-4z"/></svg></div> |
| <span>低評価</span> |
| </div> |
| <div class="short-action-btn" onclick="openShortComments('${v.id}','${titleSafe}')"> |
| <div class="icon"><svg viewBox="0 0 24 24" style="fill:currentColor;stroke:none;"><path d="M21.99 4c0-1.1-.89-2-1.99-2H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h14l4 4-.01-18z"/></svg></div> |
| <span>コメント</span> |
| </div> |
| <div class="short-action-btn" onclick="shareShort('${v.id}')"> |
| <div class="icon"><svg viewBox="0 0 24 24" style="fill:currentColor;stroke:none;"><path d="M18 16.08c-.76 0-1.44.3-1.96.77L8.91 12.7c.05-.23.09-.46.09-.7s-.04-.47-.09-.7l7.05-4.11c.54.5 1.25.81 2.04.81 1.66 0 3-1.34 3-3s-1.34-3-3-3-3 1.34-3 3c0 .24.04.47.09.7L8.04 9.81C7.5 9.31 6.79 9 6 9c-1.66 0-3 1.34-3 3s1.34 3 3 3c.79 0 1.5-.31 2.04-.81l7.12 4.16c-.05.21-.08.43-.08.65 0 1.61 1.31 2.92 2.92 2.92 1.61 0 2.92-1.31 2.92-2.92s-1.31-2.92-2.92-2.92z"/></svg></div> |
| <span>共有</span> |
| </div> |
| <div class="short-action-btn" onclick="playVideo('${v.id}','${titleSafe}','${channelSafe}','${v.authorThumb||''}')"> |
| <div class="icon"><svg viewBox="0 0 24 24" style="fill:currentColor;stroke:none;"><path d="M19 3H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm-5 14H7v-2h7v2zm3-4H7v-2h10v2zm0-4H7V7h10v2z"/></svg></div> |
| <span>詳細</span> |
| </div> |
| <div class="short-action-btn" onclick="downloadShort('${v.id}')"> |
| <div class="icon"><svg viewBox="0 0 24 24" style="fill:currentColor;stroke:none;"><path d="M19 9h-4V3H9v6H5l7 7 7-7zM5 18v2h14v-2H5z"/></svg></div> |
| <span>DL</span> |
| </div> |
| </div> |
| </div> |
| <div class="short-comments-panel" id="short-comments-panel-${v.id}" style="display:none;position:absolute;bottom:0;left:0;right:0;height:65%;background:var(--bg-color);border-radius:16px 16px 0 0;z-index:20;overflow-y:auto;padding:16px;"> |
| <div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:12px;font-weight:bold;"> |
| <span>コメント</span> |
| <button onclick="closeShortComments('${v.id}')" style="background:none;border:none;font-size:20px;cursor:pointer;color:var(--text-color);">×</button> |
| </div> |
| <div id="short-comments-content-${v.id}" class="loader">読み込み中...</div> |
| </div> |
| `; |
| container.appendChild(item); |
| observeShortItem(item); |
| }); |
| document.getElementById('shorts-loader').classList.add('hidden'); |
| } |
|
|
| function downloadShort(videoId) { |
| window.open(`https://cobalt.tools/?url=https://www.youtube.com/shorts/${videoId}`, '_blank'); |
| } |
|
|
| // changeShortStream: 3タイプのみ (m3u8=type4 削除、3=Manifest Hunter) |
| async function changeShortStream(type, targetVideoId) { |
| shortStreamType = type; |
| const config = getAppConfig(); config.shortStream = type; |
| localStorage.setItem('study2525_config', JSON.stringify(config)); |
|
|
| for (const v of currentShortItems) { |
| const wrap = document.getElementById(`short-wrap-${v.id}`); |
| if (!wrap) continue; |
| wrap.querySelectorAll('.short-stream-btn').forEach((b, i) => b.classList.toggle('active', i+1 === type)); |
| } |
|
|
| if (type === 1) { |
| // Nocookie |
| for (const v of currentShortItems) { |
| const newSrc = buildShortNocookieSrc(v.id); |
| shortSrcMap[v.id] = newSrc; |
| const wrap = document.getElementById(`short-wrap-${v.id}`); |
| if (!wrap) continue; |
| wrap.querySelectorAll('video, audio, .short-gv-loading').forEach(el => el.remove()); |
| let iframe = wrap.querySelector('iframe'); |
| if (!iframe) { iframe = document.createElement('iframe'); iframe.allow = 'accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture'; iframe.allowFullscreen = true; wrap.insertBefore(iframe, wrap.firstChild); } |
| iframe.style.display = ''; iframe.src = newSrc; |
| } |
| } else if (type === 2) { |
| // Edu |
| if (!currentEduKey) currentEduKey = await fetchKahootKey(); |
| if (!currentEduKey) { alert("Edu準備中"); shortStreamType = 1; changeShortStream(1, targetVideoId); return; } |
| for (const v of currentShortItems) { |
| const newSrc = buildShortEduSrc(v.id, currentEduKey); |
| shortSrcMap[v.id] = newSrc; |
| const wrap = document.getElementById(`short-wrap-${v.id}`); |
| if (!wrap) continue; |
| wrap.querySelectorAll('video, audio, .short-gv-loading').forEach(el => el.remove()); |
| let iframe = wrap.querySelector('iframe'); |
| if (!iframe) { iframe = document.createElement('iframe'); iframe.allow = 'accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture'; iframe.allowFullscreen = true; wrap.insertBefore(iframe, wrap.firstChild); } |
| iframe.style.display = ''; |
| if (iframe.src && iframe.src !== 'about:blank') iframe.src = newSrc; |
| } |
| } else if (type === 3) { |
| // Manifest Hunter (siawaseok deepSearch) |
| for (const v of currentShortItems) { |
| const wrap = document.getElementById(`short-wrap-${v.id}`); |
| if (!wrap) continue; |
| const existingIframe = wrap.querySelector('iframe'); |
| if (existingIframe) { existingIframe.src = 'about:blank'; existingIframe.style.display = 'none'; } |
| wrap.querySelectorAll('video, audio, .short-gv-loading').forEach(el => el.remove()); |
| const loadingDiv = document.createElement('div'); |
| loadingDiv.className = 'short-gv-loading'; |
| loadingDiv.innerHTML = '<div class="spinner"></div><span style="color:#0f0;font-family:monospace;">🚨 Manifest探索中...</span>'; |
| wrap.appendChild(loadingDiv); |
| } |
| for (const v of currentShortItems) { |
| const wrap = document.getElementById(`short-wrap-${v.id}`); |
| if (!wrap) continue; |
| (async () => { |
| const loadingEl = wrap.querySelector('.short-gv-loading'); |
| // manifestHunt使用 |
| const huntResult = await manifestHunt(v.id); |
| let videoUrl = null, audioUrl = null, isManifest = false, manifestUrl = null; |
|
|
| if (huntResult) { |
| if (huntResult.type === 'manifest' && huntResult.streams.length > 0) { |
| manifestUrl = huntResult.streams[0].url; |
| isManifest = true; |
| } else if (huntResult.type === 'formats' && huntResult.streams.length > 0) { |
| const { mp4Pairs } = parseSiawaseokFormats(huntResult.streams); |
| const m4aFormats = huntResult.streams.filter(f => (f.ext==='m4a'||f.acodec==='mp4a.40.2') && f.url && (!f.vcodec||f.vcodec==='none')); |
| const target = mp4Pairs.find(p => p.label==='720p') || mp4Pairs.find(p => p.label==='480p') || mp4Pairs.find(p => p.label==='360p') || mp4Pairs[0]; |
| if (target) { videoUrl = target.videoFmt.url; audioUrl = target.audioFmt ? target.audioFmt.url : null; } |
| } |
| } |
|
|
| if (!videoUrl && !isManifest) { |
| const invFormats = await fetchGoogleVideoStreamsInvidious(v.id); |
| if (invFormats) { |
| const result = buildGoogleVideoPlayer(invFormats); |
| if (result && result.type === 'dual') { videoUrl = result.videoUrl; audioUrl = result.audioUrl; } |
| else if (result && result.type === 'single') videoUrl = result.url; |
| } |
| } |
|
|
| if (loadingEl) loadingEl.remove(); |
| const iframe = wrap.querySelector('iframe'); |
|
|
| if (isManifest && manifestUrl) { |
| if (iframe) iframe.style.display = 'none'; |
| const vid = document.createElement('video'); |
| vid.style.cssText = 'position:absolute;inset:0;width:100%;height:100%;object-fit:contain;background:#000;'; |
| vid.autoplay = true; vid.loop = true; vid.playsInline = true; |
| wrap.appendChild(vid); |
| if (typeof Hls !== 'undefined' && Hls.isSupported() && (manifestUrl.includes('.m3u8') || manifestUrl.includes('hls'))) { |
| const hls = new Hls(); hls.loadSource(manifestUrl); hls.attachMedia(vid); |
| hls.on(Hls.Events.MANIFEST_PARSED, () => { vid.play().catch(() => {}); }); |
| hls.on(Hls.Events.ERROR, () => { vid.remove(); if (iframe) { iframe.style.display=''; iframe.src=buildShortNocookieSrc(v.id); } }); |
| } else { |
| vid.src = manifestUrl; vid.play().catch(() => {}); |
| vid.onerror = () => { vid.remove(); if (iframe) { iframe.style.display=''; iframe.src=buildShortNocookieSrc(v.id); } }; |
| } |
| } else if (videoUrl) { |
| if (iframe) iframe.style.display = 'none'; |
| const vid = document.createElement('video'); |
| vid.style.cssText = 'position:absolute;inset:0;width:100%;height:100%;object-fit:contain;background:#000;'; |
| vid.autoplay = true; vid.loop = true; vid.muted = !audioUrl; vid.playsInline = true; vid.crossOrigin = 'anonymous'; |
| vid.innerHTML = `<source src="${videoUrl}" type="video/mp4">`; |
| wrap.appendChild(vid); |
| if (audioUrl) { |
| const aud = document.createElement('audio'); aud.style.display='none'; aud.loop=true; aud.crossOrigin='anonymous'; |
| aud.innerHTML=`<source src="${audioUrl}" type="audio/mp4">`; wrap.appendChild(aud); attachAudioVideoSync(vid, aud); |
| } |
| vid.play().catch(() => { vid.muted = true; vid.play(); }); |
| } else { |
| if (iframe) { iframe.style.display=''; iframe.src=buildShortNocookieSrc(v.id); } |
| } |
| })(); |
| } |
| } |
| } |
|
|
| // =================== レンダリング =================== |
| function formatDuration(sec) { |
| if (!sec || sec <= 0) return ''; |
| const h = Math.floor(sec/3600), m = Math.floor((sec%3600)/60), s = sec%60; |
| if (h > 0) return `${h}:${String(m).padStart(2,'0')}:${String(s).padStart(2,'0')}`; |
| return `${m}:${String(s).padStart(2,'0')}`; |
| } |
| function formatTimestamp(ts) { |
| if (!ts || ts === 0) return ''; |
| const d = new Date(ts*1000); |
| return `${d.getFullYear()}/${String(d.getMonth()+1).padStart(2,'0')}/${String(d.getDate()).padStart(2,'0')} ${String(d.getHours()).padStart(2,'0')}:${String(d.getMinutes()).padStart(2,'0')}`; |
| } |
| function renderResults(videos, append = false) { |
| if (searchContext === 'related') { renderRelatedVideos(videos, append); return; } |
| if (searchContext === 'shorts') { renderShorts(videos, append); return; } |
| if (searchContext === 'channel-shorts') { renderChannelShorts(videos, append); return; } |
| if (searchContext === 'channel-live') { renderChannelLive(videos, append); return; } |
| if (searchContext === 'search') { renderSearchResults(videos, append); return; } |
| let containerId = 'home-grid'; |
| if (searchContext === 'channel' || searchContext === 'channel-home') containerId = 'channel-home-grid'; |
| if (searchContext === 'channel-videos') containerId = 'channel-grid'; |
| const container = document.getElementById(containerId); |
| if (!container) return; |
| if (!append) container.innerHTML = ''; |
| videos.forEach(v => { |
| const card = document.createElement('div'); card.className = 'video-card'; |
| const isLive = v.isLive || (v.title && (v.title.includes('ライブ')||v.title.includes('LIVE')||v.title.includes('live')||v.title.includes('配信'))); |
| const isArchived = v.isArchived||false; |
| const durStr = formatDuration(v.duration), dateStr = v.publishedTimestamp ? formatTimestamp(v.publishedTimestamp) : ''; |
| card.onclick = () => playVideo(v.id, v.title, v.channel, v.authorThumb); |
| card.innerHTML = `<div class="thumbnail-container"><img src="https://i.ytimg.com/vi/${v.id}/mqdefault.jpg" loading="lazy">${v.isLive ? '<span class="live-thumb-badge">🔴 配信中</span>' : isArchived ? `<span class="archived-badge">📅 配信済み${dateStr ? ' '+dateStr : ''}</span>` : (durStr ? `<span class="duration-badge">${durStr}</span>` : '')}</div><div class="video-info"><div class="channel-avatar" onclick="event.stopPropagation(); openChannel('${v.channel.replace(/'/g,"\\'")}','${v.authorThumb}')"><img src="${v.authorThumb}" loading="lazy"></div><div class="video-details"><div class="video-title">${v.title}</div><div class="video-meta"><span style="cursor:pointer;" onclick="event.stopPropagation(); openChannel('${v.channel.replace(/'/g,"\\'")}','${v.authorThumb}')">${v.channel}</span>${v.isLive ? '<span style="color:#ff0000;font-weight:bold;">● ライブ配信中</span>' : isArchived && dateStr ? `<span>${dateStr} 配信済み</span>` : (v.published ? `<span>${v.published}</span>` : '')}</div></div></div>`; |
| container.appendChild(card); |
| }); |
| document.getElementById('home-loader')?.classList.add('hidden'); |
| document.getElementById('channel-loader')?.classList.add('hidden'); |
| if ((searchContext === 'channel' || searchContext === 'channel-home') && videos.length > 0 && !append) renderChannelFeatured(videos[0]); |
| } |
|
|
| function renderSearchResults(videos, append = false) { |
| const container = document.getElementById('search-results-list'); |
| if (!container) return; |
| if (!append) container.innerHTML = ''; |
| videos.forEach(v => { |
| const item = document.createElement('div'); item.className = 'search-result-item'; |
| const isLive = v.isLive || (v.title && (v.title.includes('ライブ')||v.title.includes('LIVE')||v.title.includes('live')||v.title.includes('配信'))); |
| const isArchived = v.isArchived||false, durStr = formatDuration(v.duration), dateStr = v.publishedTimestamp ? formatTimestamp(v.publishedTimestamp) : ''; |
| const channelSafe = v.channel.replace(/'/g,"\\'"); |
| item.onclick = () => playVideo(v.id, v.title, v.channel, v.authorThumb); |
| item.innerHTML = `<div class="search-result-thumb"><img src="https://i.ytimg.com/vi/${v.id}/mqdefault.jpg" loading="lazy">${v.isLive ? '<span class="live-thumb-badge">🔴 配信中</span>' : isArchived ? '<span class="archived-badge">📅 配信済み</span>' : (durStr ? `<span class="duration-badge">${durStr}</span>` : '')}</div><div class="search-result-info"><div class="search-result-title">${v.title}</div><div class="search-result-meta">${v.isLive ? '<span style="color:#ff0000;font-weight:bold;">● ライブ配信中</span>' : isArchived && dateStr ? `<span>${dateStr} 配信済み</span>` : (durStr ? `<span>${durStr}</span>` : '')}${!v.isLive && v.published ? `<span>• ${v.published}</span>` : ''}</div><div class="search-result-channel" onclick="event.stopPropagation(); openChannel('${channelSafe}','${v.authorThumb}')"><div class="search-result-channel-icon"><img src="${v.authorThumb}" loading="lazy"></div><span class="search-result-channel-name">${v.channel}</span></div></div>`; |
| container.appendChild(item); |
| }); |
| document.getElementById('search-loader')?.classList.add('hidden'); |
| } |
| function renderChannelFeatured(v) { |
| const el = document.getElementById('channel-featured-video'); |
| if (!el || !v) return; |
| el.innerHTML = `<div class="channel-featured-video" onclick="playVideo('${v.id}','${v.title.replace(/'/g,"\\'")}','${v.channel.replace(/'/g,"\\'")}','${v.authorThumb}')"><div class="channel-featured-thumb"><img src="https://i.ytimg.com/vi/${v.id}/hqdefault.jpg"></div><div class="channel-featured-info"><div class="channel-featured-title">${v.title}</div><div style="font-size:13px;color:var(--text-secondary);margin-top:8px;">${v.channel}</div>${v.published ? `<div style="font-size:12px;color:var(--text-secondary);">${v.published}</div>` : ''}</div></div>`; |
| } |
| function renderChannelShorts(videos, append = false) { |
| const grid = document.getElementById('channel-shorts-grid'); |
| if (!grid) return; |
| if (!append) grid.innerHTML = ''; |
| if (videos.length === 0 && !append) { grid.innerHTML = '<div style="padding:24px;color:var(--text-secondary);">ショート動画が見つかりませんでした</div>'; document.getElementById('channel-shorts-loader')?.classList.add('hidden'); return; } |
| const seen = new Set(Array.from(grid.querySelectorAll('[data-vid]')).map(el => el.dataset.vid)); |
| videos.forEach(v => { |
| if (seen.has(v.id)) return; seen.add(v.id); |
| const card = document.createElement('div'); card.className = 'channel-short-card'; card.dataset.vid = v.id; |
| card.onclick = () => navigateToShortPage(v.id, v.title, v.channel, v.authorThumb); |
| card.innerHTML = `<div class="channel-short-thumb"><img src="https://i.ytimg.com/vi/${v.id}/hqdefault.jpg" loading="lazy">${v.duration > 0 ? `<span class="duration-badge">${formatDuration(v.duration)}</span>` : ''}</div><div class="channel-short-title">${v.title}</div>`; |
| grid.appendChild(card); |
| }); |
| document.getElementById('channel-shorts-loader')?.classList.add('hidden'); |
| } |
| function renderChannelLive(videos, append = false) { |
| const container = document.getElementById('channel-live-grid'); |
| if (!container) return; |
| if (!append) container.innerHTML = ''; |
| if (videos.length === 0 && !append) { container.innerHTML = '<div class="subs-empty">ライブ・配信動画が見つかりません</div>'; document.getElementById('channel-live-loader')?.classList.add('hidden'); return; } |
| const sorted = [...videos].sort((a,b) => { const sA=a.isLive&&!a.isUpcoming?2:a.isUpcoming?1:0, sB=b.isLive&&!b.isUpcoming?2:b.isUpcoming?1:0; return sB-sA; }); |
| sorted.forEach(v => { |
| const card = document.createElement('div'); card.className = 'video-card'; |
| const dateStr = v.publishedTimestamp ? formatTimestamp(v.publishedTimestamp) : ''; |
| card.onclick = () => playVideo(v.id, v.title, v.channel, v.authorThumb); |
| card.innerHTML = `<div class="thumbnail-container"><img src="https://i.ytimg.com/vi/${v.id}/mqdefault.jpg">${v.isLive&&!v.isUpcoming ? '<span class="live-thumb-badge">🔴 配信中</span>' : v.isUpcoming ? '<span class="live-thumb-badge" style="background:#1a73e8;">🕐 待機中</span>' : `<span class="archived-badge">📅 配信済み${dateStr?' '+dateStr:''}</span>`}</div><div class="video-info"><div class="channel-avatar"><img src="${v.authorThumb||''}" onerror="this.src='https://i.pravatar.cc/40?u=${encodeURIComponent(v.channel)}'"></div><div class="video-details"><div class="video-title">${v.title}</div><div class="video-meta">${v.isLive&&!v.isUpcoming ? '<span style="color:#ff0000;font-weight:bold;">● ライブ配信中</span>' : v.isUpcoming ? '<span style="color:#1a73e8;font-weight:bold;">⏰ 配信予定</span>' : (dateStr ? `<span>${dateStr} 配信済み</span>` : `<span>${v.published||''}</span>`)}</div></div></div>`; |
| container.appendChild(card); |
| }); |
| document.getElementById('channel-live-loader')?.classList.add('hidden'); |
| } |
| let channelFilterMode = 'latest'; |
| function setChannelFilter(btn, mode) { |
| document.querySelectorAll('.channel-filter-chip').forEach(c => c.classList.remove('active')); btn.classList.add('active'); channelFilterMode = mode; |
| const grid = document.getElementById('channel-grid'), cards = Array.from(grid.querySelectorAll('.video-card')); |
| if (mode === 'popular') cards.sort(() => Math.random()-0.5); else if (mode === 'oldest') cards.reverse(); |
| grid.innerHTML = ''; cards.forEach(c => grid.appendChild(c)); |
| } |
|
|
| window.addEventListener('scroll', () => { |
| if (window.innerHeight + window.scrollY >= document.body.offsetHeight - 1000 && !isFetching) { |
| if (currentView === 'home' && getAppConfig().trend) { document.getElementById('home-loader').classList.remove('hidden'); triggerSearch(lastQuery||"人気",'trend',true); } |
| else if (currentView === 'search') { document.getElementById('search-loader').classList.remove('hidden'); triggerSearch(lastQuery,'search',true); } |
| else if (currentView === 'watch') { document.getElementById('related-loader').classList.remove('hidden'); triggerSearch(currentVideoTitle.substring(0,20),'related',true); } |
| else if (currentView === 'channel') { if (currentChannelTab === 'videos') { document.getElementById('channel-loader').classList.remove('hidden'); triggerSearch(lastQuery,'channel-videos',true); } } |
| } |
| }); |
|
|
| document.addEventListener('DOMContentLoaded', () => { |
| const sfp = document.getElementById('shorts-full-page'); |
| if (sfp) { |
| sfp.addEventListener('scroll', () => { |
| if (sfp.scrollTop + sfp.clientHeight >= sfp.scrollHeight - 400 && !isFetching) { |
| document.getElementById('shorts-loader').classList.remove('hidden'); |
| triggerSearch("#shorts",'shorts',true); |
| } |
| }); |
| } |
| }); |
|
|
| function loadTrend() { triggerSearch("人気 日本",'trend'); } |
| function handleWelcomeSearch(e) { |
| e.preventDefault(); const q = document.getElementById('welcome-search-input').value; |
| if(!q) return; document.getElementById('search-input').value = q; saveSettings(); handleSearch(e, q); |
| } |
| function handleCategorySearch(cat, btn) { |
| document.querySelectorAll('.category-chip').forEach(c => c.classList.remove('active')); btn.classList.add('active'); |
| let query = cat === 'すべて' ? '人気' : cat; |
| document.getElementById('search-input').value = cat === 'すべて' ? '' : cat; |
| navigate('search'); |
| document.getElementById('search-shorts-section').classList.add('hidden'); |
| document.getElementById('search-shorts-container').innerHTML = ''; |
| document.getElementById('search-results-list').innerHTML = ''; |
| triggerSearch(query,'search'); |
| if (cat !== 'すべて') fetchShortsForSearch(query); |
| } |
| function handleSearch(e, externalQuery = null) { |
| if(e) e.preventDefault(); |
| const q = externalQuery || document.getElementById('search-input').value; |
| if(!q) return; |
| saveSearchHistory(q); |
| lastQuery = q; |
| document.getElementById('search-shorts-section').classList.add('hidden'); |
| document.getElementById('search-shorts-container').innerHTML = ''; |
| document.getElementById('search-results-list').innerHTML = ''; |
| try { history.pushState({ view: 'search', query: q }, '', `/search?q=${encodeURIComponent(q)}`); } catch(e) {} |
| document.querySelectorAll('.view').forEach(v => v.classList.remove('active')); |
| document.getElementById('view-search')?.classList.add('active'); |
| document.querySelectorAll('.sidebar-item').forEach(i => i.classList.remove('active')); |
| document.getElementById('categories-bar').style.display = 'flex'; |
| document.getElementById('main-content').style.padding = '24px'; |
| document.getElementById('main-content').style.marginTop = '112px'; |
| currentView = 'search'; |
| document.getElementById('sidebar').classList.remove('open'); |
| document.getElementById('main-content').classList.remove('sidebar-open'); |
| triggerSearch(q,'search'); |
| } |
|
|
| let currentChannelData = { name: '', thumb: '' }; |
|
|
| function openChannel(channelName, thumb = null) { |
| currentChannelData = { name: channelName, thumb: thumb || `https://i.pravatar.cc/150?u=${channelName}` }; |
| currentChannelId = null; |
| navigate('channel'); |
| document.getElementById('channel-page-name').innerText = channelName; |
| document.getElementById('channel-page-handle').innerText = `@${channelName.toLowerCase().replace(/\s/g,'')}`; |
| document.getElementById('channel-page-meta').innerText = `登録者数 -- • 動画 --件`; |
| const iconEl = document.getElementById('channel-page-icon'); |
| iconEl.src = currentChannelData.thumb; |
| iconEl.onerror = () => { iconEl.src = `https://i.pravatar.cc/150?u=${encodeURIComponent(channelName)}`; }; |
| const canvas = document.getElementById('channel-banner-canvas'), bannerImg = document.getElementById('channel-banner-img'); |
| bannerImg.style.display = 'none'; canvas.style.display = 'block'; |
| setTimeout(() => drawFallbackBanner(canvas), 50); |
| updateChannelSubscribeUI(channelName); |
| currentChannelShortsPage = 1; |
|
|
| fetchChannelInfoFromInvidious(channelName).then(info => { |
| if (!info) return; |
| if (info.authorId) { |
| currentChannelId = info.authorId; |
| document.getElementById('channel-page-handle').innerText = `@${info.authorId}`; |
| try { history.replaceState({ view: 'channel', channelId: info.authorId, channelName }, '', `/channel/${info.authorId}`); } catch(e) {} |
| } |
| if (info.subCount) document.getElementById('channel-page-meta').innerText = `登録者数 ${formatSubCount(info.subCount)} • 動画 ${info.videoCount||'--'}件`; |
| if (info.authorThumbnails && info.authorThumbnails.length > 0) { |
| const bestThumb = info.authorThumbnails[info.authorThumbnails.length-1]; |
| if (bestThumb) iconEl.src = bestThumb.url; |
| } |
| if (info.authorBanners && info.authorBanners.length > 0) { |
| const bestBanner = info.authorBanners.reduce((best, b) => (!best || (b.width||0) > (best.width||0)) ? b : best, null); |
| if (bestBanner && bestBanner.url) drawChannelBanner(bestBanner.url); |
| } |
| }).catch(() => {}); |
|
|
| switchChannelTab('home'); |
| } |
|
|
| function switchChannelTab(tab) { |
| currentChannelTab = tab; |
| ['home','shorts','videos','live'].forEach(t => { |
| const tabBtn = document.getElementById(`ch-tab-${t}`), tabContent = document.getElementById(`channel-tab-${t}`); |
| if (tabBtn) tabBtn.classList.toggle('active', t === tab); |
| if (tabContent) tabContent.style.display = t === tab ? 'block' : 'none'; |
| }); |
| const name = currentChannelData.name; lastQuery = name; |
| if (tab === 'home') { document.getElementById('channel-home-grid').innerHTML = ''; document.getElementById('channel-featured-video').innerHTML = ''; triggerSearch(name,'channel-home'); } |
| else if (tab === 'shorts') { document.getElementById('channel-shorts-grid').innerHTML = ''; document.getElementById('channel-shorts-loader').classList.remove('hidden'); currentChannelShortsPage = 1; triggerSearch(name,'channel-shorts'); } |
| else if (tab === 'videos') { document.getElementById('channel-grid').innerHTML = ''; document.getElementById('channel-loader').classList.remove('hidden'); triggerSearch(name,'channel-videos'); } |
| else if (tab === 'live') { document.getElementById('channel-live-grid').innerHTML = ''; document.getElementById('channel-live-loader').classList.remove('hidden'); triggerSearch(name,'channel-live'); } |
| } |
|
|
| // =================== 動画再生 =================== |
| async function playVideo(id, title, channel = "投稿者", thumb = null) { |
| currentVideoId = id; currentVideoTitle = title; |
| currentChannelName = channel; currentChannelThumb = thumb || `https://i.pravatar.cc/150?u=${channel}`; |
| currentGVFormats = null; currentGVAllFormats = null; selectedQuality = null; |
| navigate('watch', { videoId: id, title, channel, thumb: thumb||null }); |
| document.getElementById('watch-title-text').innerText = title; |
| document.getElementById('watch-channel-name').innerText = channel; |
| const wci = document.getElementById('watch-channel-icon'); |
| wci.src = currentChannelThumb; wci.onerror = () => wci.src = `https://i.pravatar.cc/150?u=${encodeURIComponent(channel)}`; |
| document.getElementById('watch-channel-trigger').onclick = () => openChannel(channel, currentChannelThumb); |
| document.getElementById('related-videos').innerHTML = ''; |
| document.getElementById('api-views').innerText = "---"; document.getElementById('api-likes').innerText = "---"; |
| document.getElementById('api-date').innerText = ""; document.getElementById('api-desc').innerHTML = "概要欄を読み込んでいます..."; |
| document.getElementById('quality-wrap').style.display = 'none'; |
| updateWatchSubscribeUI(channel); |
| setTimeout(() => switchStream(getAppConfig().stream), 50); |
| saveToLocal('history',{id,title}); |
| triggerSearch(title.substring(0,20),'related'); fetchComments(id); fetchVideoApiDetails(id); |
| } |
| function toggleDescription() { |
| const desc = document.getElementById('api-desc'); desc.classList.toggle('expanded'); |
| document.getElementById('desc-toggle-text').innerText = desc.classList.contains('expanded') ? "一部を表示" : "続きを読む"; |
| } |
| async function fetchVideoApiDetails(videoId) { |
| try { |
| const res = await fetch(buildFetchUrl(`https://api.aijimy.com/get?code=get-youtube-videodata&text=${videoId}`)); |
| const t = await res.text(); |
| const views = (t.match(/再生回数:\s*(\d+)/)||[])[1], likes = (t.match(/高評価数:\s*(\d+)/)||[])[1]; |
| const date = (t.match(/公開日:\s*(.*?)\s*再生回数:/)||[])[1], des = (t.match(/概要欄:\s*([\s\S]*?)\s*公開日:/)||[])[1]; |
| document.getElementById('api-views').innerText = views ? parseInt(views).toLocaleString() : "---"; |
| document.getElementById('api-likes').innerText = likes ? parseInt(likes).toLocaleString() : "---"; |
| if(date) document.getElementById('api-date').innerText = ` • ${date.trim()}`; |
| document.getElementById('api-desc').innerHTML = des ? des.trim().replace(/\n/g,'<br>') : "概要はありません。"; |
| } catch(e) { document.getElementById('api-desc').innerText = "取得失敗"; } |
| } |
| async function fetchKahootKey() { |
| try { const res = await fetch(buildFetchUrl(KAHOOT_KEY_URL)); if(res.ok) { const data = await res.json(); return data.key||data.enc||data; } } catch(e) {} |
| if(getAppConfig().proxy) for (const proxy of CORS_PROXIES) try { const res = await fetch(proxy + encodeURIComponent(KAHOOT_KEY_URL)); if(res.ok) { const data = await res.json(); return data.key||data.enc||data; } } catch(e) {} |
| return null; |
| } |
| function buildEduUrl(videoId, enc) { |
| const cfg = encodeURIComponent(JSON.stringify({enc: enc, hideTitle: true})); |
| return `https://www.youtubeeducation.com/embed/${videoId}?autoplay=1&origin=https%3A%2F%2Fcreate.kahoot.it&embed_config=${cfg}`; |
| } |
|
|
| // switchStream: 3タイプのみ (type4=m3u8削除) |
| async function switchStream(type) { |
| [1,2,3].forEach(i => { const btn = document.getElementById(`btn-stream-${i}`); if (btn) btn.classList.toggle('active', type===i); }); |
| document.getElementById('quality-wrap').style.display = 'none'; |
| currentGVFormats = null; currentGVAllFormats = null; |
| const wrapper = document.getElementById('player-wrapper'); |
| if (type === 1) { |
| wrapper.innerHTML = '<iframe id="yt-player" allow="autoplay; fullscreen" allowfullscreen></iframe>'; |
| await new Promise(r => setTimeout(r, 80)); |
| const iframe = document.getElementById('yt-player'); |
| if (iframe) iframe.src = `https://www.youtube-nocookie.com/embed/${currentVideoId}?autoplay=1&rel=0&start=0`; |
| } else if (type === 2) { |
| wrapper.innerHTML = '<iframe id="yt-player" allow="autoplay; fullscreen" allowfullscreen></iframe>'; |
| const key = await fetchKahootKey(); |
| if(key) { |
| await new Promise(r => setTimeout(r, 80)); |
| const iframe = document.getElementById('yt-player'); |
| if (iframe) iframe.src = buildEduUrl(currentVideoId, key); |
| } else { alert("Edu準備中"); switchStream(1); } |
| } else if (type === 3) { |
| // Manifest Hunter |
| await setupWatchManifest(currentVideoId); |
| } |
| } |
|
|
| function renderRelatedVideos(videos, append = false) { |
| const container = document.getElementById('related-videos'); |
| const html = videos.map(v => { |
| const isLive = v.isLive || (v.title && (v.title.includes('ライブ')||v.title.includes('LIVE'))); |
| const isArchived = v.isArchived||false; |
| return `<div class="related-video" onclick="playVideo('${v.id}','${v.title.replace(/'/g,"\\'")}','${v.channel.replace(/'/g,"\\'")}')"><div class="related-thumb"><img src="https://i.ytimg.com/vi/${v.id}/mqdefault.jpg">${isLive ? '<span class="live-thumb-badge">🔴</span>' : isArchived ? '<span class="archived-badge">配信済</span>' : ''}</div><div class="related-info"><div class="related-title">${v.title}</div><div style="font-size:12px;color:var(--text-secondary);">${v.channel}</div></div></div>`; |
| }).join(''); |
| if (append) container.insertAdjacentHTML('beforeend', html); else container.innerHTML = html; |
| document.getElementById('related-loader').classList.add('hidden'); |
| } |
|
|
| // =================== ナビゲーション =================== |
| function navigate(viewName, opts = {}) { |
| document.querySelectorAll('.view').forEach(v => v.classList.remove('active')); |
| document.getElementById(`view-${viewName}`)?.classList.add('active'); |
| document.querySelectorAll('.sidebar-item').forEach(i => i.classList.remove('active')); |
| if(document.getElementById(`nav-${viewName}`)) document.getElementById(`nav-${viewName}`).classList.add('active'); |
| const showCats = ['home','search','history','subscriptions'].includes(viewName); |
| document.getElementById('categories-bar').style.display = showCats ? 'flex' : 'none'; |
| if (viewName === 'shorts') { |
| document.getElementById('main-content').style.padding = '0'; |
| document.getElementById('main-content').style.marginTop = '56px'; |
| } else { |
| document.getElementById('main-content').style.padding = '24px'; |
| document.getElementById('main-content').style.marginTop = showCats ? '112px' : '56px'; |
| } |
| currentView = viewName; |
| if (viewName !== 'watch') { |
| const wrapper = document.getElementById('player-wrapper'); |
| if(wrapper) wrapper.innerHTML = '<iframe id="yt-player" allow="autoplay; fullscreen" allowfullscreen></iframe>'; |
| currentGVFormats = null; currentGVAllFormats = null; |
| } |
| if (viewName !== 'shorts') { |
| document.querySelectorAll('#shorts-container .short-snap-item').forEach(item => { |
| const iframe = item.querySelector('iframe'); if (iframe) iframe.src = 'about:blank'; |
| item.querySelectorAll('video, audio').forEach(el => { el.pause(); el.src = ''; }); |
| }); |
| if (shortObserver) { shortObserver.disconnect(); shortObserver = null; } |
| } |
| if (viewName === 'shorts' && document.getElementById('shorts-container').children.length === 0) { |
| document.getElementById('shorts-loader').classList.remove('hidden'); |
| Object.keys(shortSrcMap).forEach(k => delete shortSrcMap[k]); |
| shortStreamType = getAppConfig().shortStream || 1; |
| triggerSearch("#shorts 人気",'shorts'); |
| } |
| if (viewName === 'history') renderLocalList('history'); |
| if (viewName === 'settings') { renderSettingsSubsList(); syncSettings(getAppConfig()); } |
| if (viewName === 'subscriptions') renderSubscriptionsPage(); |
| window.scrollTo(0, 0); |
| document.getElementById('sidebar').classList.remove('open'); |
| document.getElementById('main-content').classList.remove('sidebar-open'); |
|
|
| if (!opts.noHistory) { |
| let url = '/'; |
| if (viewName === 'home') url = '/home'; |
| else if (viewName === 'history') url = '/feed/history'; |
| else if (viewName === 'settings') url = '/setting'; |
| else if (viewName === 'subscriptions') url = '/feed/subscriptions'; |
| else if (viewName === 'search') url = lastQuery ? `/search?q=${encodeURIComponent(lastQuery)}` : '/search'; |
| else if (viewName === 'channel') url = currentChannelId ? `/channel/${currentChannelId}` : '/channel'; |
| else if (viewName === 'welcome') url = '/'; |
| else if (viewName === 'watch' && opts.videoId) url = `/watch?v=${opts.videoId}`; |
| else if (viewName === 'shorts' && opts.videoId) url = `/shorts/${opts.videoId}`; |
| else if (viewName === 'shorts') url = '/shorts'; |
| try { history.pushState({ view: viewName, ...opts }, '', url); } catch(e) {} |
| } |
| } |
|
|
| window.addEventListener('popstate', (e) => { |
| if (!e.state) { navigate('home', { noHistory: true }); return; } |
| const { view, videoId, title, channel, thumb, query, channelId, channelName } = e.state; |
| if (view === 'watch' && videoId) playVideo(videoId, title||'', channel||'', thumb||null, true); |
| else if (view === 'shorts' && videoId) navigateToShortPage(videoId, title, channel, thumb, true); |
| else if (view === 'channel') { |
| if (channelName) openChannel(channelName, null); |
| else navigate('channel', { noHistory: true }); |
| } |
| else if (view === 'search') { |
| const searchQ = query || new URLSearchParams(location.search).get('q') || ''; |
| if (searchQ) { |
| document.getElementById('search-input').value = searchQ; |
| navigate('search', { noHistory: true }); |
| triggerSearch(searchQ, 'search'); |
| } else navigate('search', { noHistory: true }); |
| } |
| else if (view) navigate(view, { noHistory: true }); |
| }); |
|
|
| function parseInitialUrl() { |
| const path = location.pathname + location.search; |
| const watchMatch = path.match(/\/watch\?v=([a-zA-Z0-9_-]{11})/); |
| const shortsMatch = path.match(/\/shorts\/([a-zA-Z0-9_-]{11})/); |
| const searchMatch = path.match(/\/search\?q=(.+)/); |
| const channelMatch = path.match(/\/channel\/([a-zA-Z0-9_@-]+)/); |
| if (watchMatch) return { view: 'watch', videoId: watchMatch[1] }; |
| if (shortsMatch) return { view: 'shorts', videoId: shortsMatch[1] }; |
| if (searchMatch) return { view: 'search', query: decodeURIComponent(searchMatch[1]) }; |
| if (channelMatch) return { view: 'channel', channelId: channelMatch[1] }; |
| if (path === '/feed/history') return { view: 'history' }; |
| if (path === '/setting' || path === '/settings') return { view: 'settings' }; |
| if (path === '/feed/subscriptions') return { view: 'subscriptions' }; |
| if (path === '/home' || path === '/') return { view: 'home' }; |
| return null; |
| } |
|
|
| function toggleSidebar() { |
| document.getElementById('sidebar').classList.toggle('open'); |
| document.getElementById('main-content').classList.toggle('sidebar-open'); |
| } |
| function saveToLocal(key, video) { |
| let list = JSON.parse(localStorage.getItem(key) || '[]'); |
| list = list.filter(v => v.id !== video.id); list.unshift(video); |
| localStorage.setItem(key, JSON.stringify(list.slice(0, 50))); |
| } |
| function renderLocalList(type) { |
| document.getElementById(`${type}-grid`).innerHTML = JSON.parse(localStorage.getItem(type)||'[]').map(v => `<div class="video-card" onclick="playVideo('${v.id}','${v.title.replace(/'/g,"\\'")}','YouTube Channel')"><div class="thumbnail-container"><img src="https://i.ytimg.com/vi/${v.id}/mqdefault.jpg"></div><div class="video-details" style="padding:10px;"><div class="video-title">${v.title}</div></div></div>`).join(''); |
| } |
|
|
| async function fetchComments(videoId) { |
| const container = document.getElementById('comments-container'); |
| container.innerHTML = '<div class="loader">コメントを読み込み中...</div>'; |
| const commentApis = [ |
| async (id) => { |
| const res = await fetch(`https://inv.nadeko.net/api/v1/comments/${id}`, { signal: AbortSignal.timeout(8000) }); |
| if (!res.ok) throw new Error('failed'); |
| const data = await res.json(); |
| if (!data.comments || data.comments.length === 0) throw new Error('no comments'); |
| return data.comments.map(c => ({ author: c.author||'名無し', content: c.content||c.contentHtml?.replace(/<[^>]+>/g,'')||'', avatar: c.authorThumbnails?.[c.authorThumbnails.length-1]?.url||'', likes: c.likeCount||0 })); |
| }, |
| async (id) => { |
| for (const instance of INVIDIOUS_INSTANCES.slice(0,6)) { |
| try { |
| const res = await fetch(`${instance}/api/v1/comments/${id}`, { signal: AbortSignal.timeout(6000) }); |
| if (!res.ok) continue; |
| const data = await res.json(); |
| if (!data.comments || data.comments.length === 0) continue; |
| return data.comments.map(c => ({ author: c.author||'名無し', content: c.content||c.contentHtml?.replace(/<[^>]+>/g,'')||'', avatar: c.authorThumbnails?.[c.authorThumbnails.length-1]?.url||'', likes: c.likeCount||0 })); |
| } catch(e) {} |
| } |
| throw new Error('all failed'); |
| } |
| ]; |
| let comments = null; |
| for (const api of commentApis) { try { comments = await api(videoId); if (comments && comments.length > 0) break; } catch(e) {} } |
| if (!comments || comments.length === 0) { |
| container.innerHTML = '<div style="color:var(--text-secondary);padding:8px;">コメントを読み込めませんでした。</div>'; |
| document.getElementById('comment-count').innerText = '0'; return; |
| } |
| document.getElementById('comment-count').innerText = comments.length; |
| container.innerHTML = comments.slice(0,20).map(c => `<div class="comment"><img class="comment-avatar" src="${c.avatar}" onerror="this.src='https://i.pravatar.cc/40?u=${encodeURIComponent(c.author)}'"><div class="comment-body"><div style="display:flex;align-items:center;gap:8px;margin-bottom:4px;"><span style="font-weight:bold;font-size:13px;">${c.author}</span>${c.likes > 0 ? `<span style="font-size:12px;color:var(--text-secondary);">👍 ${c.likes.toLocaleString()}</span>` : ''}</div><div style="font-size:14px;line-height:1.5;">${(c.content||'').replace(/\n/g,'<br>')}</div></div></div>`).join(''); |
| } |
|
|
| // =================== ショートUIヘルパー =================== |
| function toggleShortLike(btn) { |
| const icon = btn.querySelector('.icon'), span = btn.querySelector('.short-like-count'); |
| const isLiked = btn.dataset.liked === '1'; btn.dataset.liked = isLiked ? '0' : '1'; |
| icon.style.color = isLiked ? '' : '#ff0000'; |
| if (span) { |
| const cur = parseInt(span.textContent.replace(/[^0-9]/g,''))||0, newVal = isLiked ? Math.max(0,cur-1) : cur+1; |
| span.textContent = newVal >= 10000 ? (newVal/1000).toFixed(0)+'K' : newVal.toLocaleString(); |
| } |
| } |
| function toggleShortDislike(btn) { |
| const icon = btn.querySelector('.icon'), isDisliked = btn.dataset.disliked === '1'; |
| btn.dataset.disliked = isDisliked ? '0' : '1'; icon.style.color = isDisliked ? '' : '#aaa'; |
| } |
|
|
| async function openShortComments(videoId, title) { |
| const panel = document.getElementById(`short-comments-panel-${videoId}`); |
| if (!panel) return; |
| panel.style.display = 'block'; |
| const content = document.getElementById(`short-comments-content-${videoId}`); |
| if (content.dataset.loaded === '1') return; |
| content.innerHTML = '<div class="loader">コメントを読み込み中...</div>'; content.dataset.loaded = '1'; |
| const apis = [`https://inv.nadeko.net/api/v1/comments/${videoId}`, ...INVIDIOUS_INSTANCES.slice(0,4).map(i => `${i}/api/v1/comments/${videoId}`)]; |
| let comments = []; |
| for (const url of apis) { |
| try { const res = await fetch(url, { signal: AbortSignal.timeout(6000) }); if (!res.ok) continue; const data = await res.json(); if (data.comments && data.comments.length > 0) { comments = data.comments; break; } } catch(e) {} |
| } |
| if (comments.length === 0) { content.innerHTML = '<div style="color:var(--text-secondary);padding:8px;">コメントを読み込めませんでした。</div>'; return; } |
| content.innerHTML = comments.slice(0,15).map(c => `<div style="display:flex;gap:10px;margin-bottom:14px;"><img src="${c.authorThumbnails?.[0]?.url||''}" style="width:36px;height:36px;border-radius:50%;flex-shrink:0;background:#eee;" onerror="this.src='https://i.pravatar.cc/36?u=${encodeURIComponent(c.author||'')}'"><div><div style="font-weight:bold;font-size:12px;margin-bottom:3px;">${c.author||'名無し'}</div><div style="font-size:13px;line-height:1.4;">${(c.content||'').replace(/\n/g,'<br>')}</div></div></div>`).join(''); |
| } |
| function closeShortComments(videoId) { |
| const panel = document.getElementById(`short-comments-panel-${videoId}`); if (panel) panel.style.display = 'none'; |
| } |
| function toggleSubscribeFromShort(channelName, thumb, btn) { |
| let subs = getSubscriptions(); |
| if (isSubscribed(channelName)) { subs = subs.filter(s => s.name !== channelName); btn.textContent='登録'; btn.classList.remove('subscribed'); } |
| else { subs.unshift({ name: channelName, thumb, isLive: false }); btn.textContent='登録済み'; btn.classList.add('subscribed'); } |
| saveSubscriptions(subs); renderSidebarSubscriptions(); |
| } |
| </script> |
|
|
| <script async src="https://cse.google.com/cse.js?cx=c04ec9d20c4264b0d"></script> |
| <script defer data-domain="html.cafe" src="https://milkymouse.com/js/script.js"></script> |
| <script defer src="https://static.cloudflareinsights.com/beacon.min.js/v8c78df7c7c0f484497ecbca7046644da1771523124516" integrity="sha512-8DS7rgIrAmghBFwoOTujcf6D9rXvH8xm8JQ1Ja01h9QX8EzXldiszufYa4IFfKdLUKTTrnSFXLDkUEOTrZQ8Qg==" data-cf-beacon='{"version":"2024.11.0","token":"94c79d4d951745d085049dd7f2bbd8f8","r":1,"server_timing":{"name":{"cfCacheStatus":true,"cfEdge":true,"cfExtPri":true,"cfL4":true,"cfOrigin":true,"cfSpeedBrain":true},"location_startswith":null}}' crossorigin="anonymous"></script> |
| </body> |
| </html> |
|
|
| <script defer data-domain="html.cafe" src="https://milkymouse.com/js/script.js"></script> |
|
|
| |