diff --git a/index.html b/index.html index 524f823..0710bf1 100644 --- a/index.html +++ b/index.html @@ -7,9 +7,10 @@ has - + diff --git a/package.json b/package.json index 994a7d2..26e46ce 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "has", "private": true, - "version": "0.0.10", + "version": "0.0.11", "type": "module", "scripts": { "dev": "vite", diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 849b54c..5637eee 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -1581,7 +1581,7 @@ dependencies = [ [[package]] name = "has" -version = "0.0.10" +version = "0.0.11" dependencies = [ "base64 0.22.1", "bcrypt", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index b97233e..8c21c35 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "has" -version = "0.0.10" +version = "0.0.11" description = "has" authors = ["autli"] edition = "2024" diff --git a/src-tauri/src/cinema.rs b/src-tauri/src/cinema.rs index e46fd8a..229f56d 100644 --- a/src-tauri/src/cinema.rs +++ b/src-tauri/src/cinema.rs @@ -252,6 +252,53 @@ pub async fn update_cinema_progress( Ok(state) } +#[tauri::command] +pub async fn remove_cinema_history_item( + app: AppHandle, + title_id: String, + season: u32, + episode: u32, + user_email: Option, +) -> Result { + let mut state = read_local_cinema_state(&app); + state.history.retain(|h| !(h.title_id == title_id && h.season == season && h.episode == episode)); + let _ = write_local_cinema_state(&app, &state); + + if let Some(email) = user_email { + let email_clean = email.trim().to_lowercase(); + if !email_clean.is_empty() { + let state_to_save = state.clone(); + tokio::spawn(async move { + let _ = save_remote_cinema_state(&email_clean, &state_to_save).await; + }); + } + } + + Ok(state) +} + +#[tauri::command] +pub async fn clear_cinema_history( + app: AppHandle, + user_email: Option, +) -> Result { + let mut state = read_local_cinema_state(&app); + state.history.clear(); + let _ = write_local_cinema_state(&app, &state); + + if let Some(email) = user_email { + let email_clean = email.trim().to_lowercase(); + if !email_clean.is_empty() { + let state_to_save = state.clone(); + tokio::spawn(async move { + let _ = save_remote_cinema_state(&email_clean, &state_to_save).await; + }); + } + } + + Ok(state) +} + #[tauri::command] pub async fn toggle_cinema_bookmark( app: AppHandle, diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 43b9529..cc61d43 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -249,6 +249,8 @@ pub fn run() { cinema::get_cinema_state, cinema::save_cinema_state, cinema::update_cinema_progress, + cinema::remove_cinema_history_item, + cinema::clear_cinema_history, cinema::toggle_cinema_bookmark, cinema::tmdb_query, cinema::get_russian_media_info, diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index f3ea30a..e69f2fc 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "has", - "version": "0.0.10", + "version": "0.0.11", "identifier": "com.has.tauri-app", "build": { "beforeDevCommand": "npm run dev", diff --git a/src/components/UpdaterBanner.tsx b/src/components/UpdaterBanner.tsx index 097cbdd..6ad18e3 100644 --- a/src/components/UpdaterBanner.tsx +++ b/src/components/UpdaterBanner.tsx @@ -91,7 +91,7 @@ export function UpdaterBanner({ isTvEnvironment }: UpdaterBannerProps = {}) { const resp = await fetch("https://github.com/autli5/has/releases/latest/download/latest.json"); if (resp.ok) { const data = await resp.json(); - if (!cancelled && data?.version && data.version !== "0.0.10") { + if (!cancelled && data?.version && data.version !== "0.0.11") { setUpdateAvailable(true); setUpdateInfo({ version: data.version, diff --git a/src/pages/cinema/Cinema.css b/src/pages/cinema/Cinema.css index 00182ab..29e478e 100644 --- a/src/pages/cinema/Cinema.css +++ b/src/pages/cinema/Cinema.css @@ -800,6 +800,28 @@ transform: scale(1.05); } +/* Quick Ad / Intro Skip Button */ +.has-cinema-player-hud-btn.ad-skip-pill { + font-size: 12px; + padding: 7px 13px; + border-radius: 9999px; + background: rgba(245, 158, 11, 0.15); + border: 1px solid rgba(245, 158, 11, 0.4); + color: #fde68a; + transition: all 0.2s cubic-bezier(0.16, 1, 0.3, 1); +} + +.has-cinema-player-hud-btn.ad-skip-pill:hover, +.has-cinema-player-hud-btn.ad-skip-pill:focus-visible, +.has-cinema-player-hud-btn.ad-skip-pill[data-tv-focused="true"] { + background: rgba(245, 158, 11, 0.35) !important; + border-color: #f59e0b !important; + color: #ffffff !important; + outline: 3px solid #f59e0b !important; + box-shadow: 0 0 20px rgba(245, 158, 11, 0.8) !important; + transform: scale(1.05); +} + /* Server selector pills in top HUD */ .has-cinema-player-hud-servers { display: flex; @@ -3119,6 +3141,177 @@ } } +/* ======================================================== + WATCH HISTORY (ИСТОРИЯ ПРОСМОТРОВ) STYLES + ======================================================== */ +.has-cinema-history-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 20px; + flex-wrap: wrap; + gap: 12px; +} + +.has-cinema-history-subtitle { + color: #94a3b8; + font-size: 13.5px; + font-weight: 400; + margin-top: 4px; + display: block; +} + +.has-cinema-clear-history-btn { + background: rgba(239, 68, 68, 0.12); + border: 1px solid rgba(239, 68, 68, 0.28); + color: #fca5a5; + font-size: 13px; + font-weight: 500; + padding: 8px 16px; + border-radius: 8px; + cursor: pointer; + transition: all 0.2s ease; + display: inline-flex; + align-items: center; + gap: 6px; +} + +.has-cinema-clear-history-btn:hover, +.has-cinema-clear-history-btn:focus-visible, +.has-tv-mode .has-cinema-clear-history-btn:focus { + background: rgba(239, 68, 68, 0.25) !important; + border-color: #ef4444 !important; + color: #fff !important; + outline: 2px solid #ef4444 !important; + outline-offset: 2px !important; + transform: translateY(-1px); +} + +.has-cinema-row-more-btn { + background: rgba(255, 255, 255, 0.06); + border: 1px solid rgba(255, 255, 255, 0.14); + color: #cbd5e1; + font-size: 12.5px; + font-weight: 500; + padding: 6px 14px; + border-radius: 8px; + cursor: pointer; + transition: all 0.2s ease; +} + +.has-cinema-row-more-btn:hover, +.has-cinema-row-more-btn:focus-visible, +.has-tv-mode .has-cinema-row-more-btn:focus { + background: rgba(99, 102, 241, 0.25) !important; + border-color: #818cf8 !important; + color: #fff !important; + outline: 2px solid #818cf8 !important; + outline-offset: 2px !important; +} + +/* History Card Enhancements */ +.has-cinema-history-card { + position: relative; +} + +.has-cinema-history-item-del-btn { + position: absolute; + top: 8px; + right: 8px; + width: 26px; + height: 26px; + border-radius: 50%; + background: rgba(15, 23, 42, 0.75); + backdrop-filter: blur(6px); + border: 1px solid rgba(255, 255, 255, 0.25); + color: #cbd5e1; + display: flex; + align-items: center; + justify-content: center; + font-size: 11px; + font-weight: 700; + cursor: pointer; + opacity: 0; + transition: all 0.2s cubic-bezier(0.16, 1, 0.3, 1); + z-index: 25; +} + +.has-cinema-media-card:hover .has-cinema-history-item-del-btn, +.has-cinema-grid-card:hover .has-cinema-history-item-del-btn, +.has-cinema-media-card:focus-within .has-cinema-history-item-del-btn, +.has-cinema-grid-card:focus-within .has-cinema-history-item-del-btn, +.has-tv-mode .has-cinema-media-card:focus .has-cinema-history-item-del-btn, +.has-tv-mode .has-cinema-grid-card:focus .has-cinema-history-item-del-btn { + opacity: 1; +} + +.has-cinema-history-item-del-btn:hover, +.has-cinema-history-item-del-btn:focus-visible { + background: #ef4444; + color: #fff; + border-color: #ef4444; + transform: scale(1.15); + outline: none; +} + +.has-cinema-history-status-badge { + position: absolute; + top: 8px; + left: 8px; + padding: 3px 8px; + border-radius: 6px; + background: rgba(15, 23, 42, 0.85); + backdrop-filter: blur(6px); + border: 1px solid rgba(129, 140, 248, 0.35); + color: #c7d2fe; + font-size: 11px; + font-weight: 600; + z-index: 10; +} + +.has-cinema-history-status-badge.completed { + background: rgba(6, 78, 59, 0.85); + border-color: rgba(52, 211, 153, 0.4); + color: #6ee7b7; +} + +.has-cinema-history-percent-badge { + position: absolute; + bottom: 8px; + right: 8px; + padding: 2px 6px; + border-radius: 4px; + background: rgba(0, 0, 0, 0.75); + backdrop-filter: blur(4px); + color: #e2e8f0; + font-size: 10.5px; + font-weight: 600; + z-index: 10; +} + +.has-cinema-history-card-sub { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + font-size: 12px; + color: #94a3b8; + margin-top: 2px; +} + +.has-cinema-history-ep-info { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.has-cinema-history-date { + color: #64748b; + font-size: 11px; + white-space: nowrap; +} + + diff --git a/src/pages/cinema/Cinema.tsx b/src/pages/cinema/Cinema.tsx index e840088..d3f41a7 100644 --- a/src/pages/cinema/Cinema.tsx +++ b/src/pages/cinema/Cinema.tsx @@ -577,7 +577,9 @@ export default function Cinema({ const [, setConfig] = useState({ tmdb_api_key: null }); // Navigation Filter - const [activeTab, setActiveTab] = useState<"home" | "movies" | "series" | "anime" | "bookmarks" | "search">("home"); + const [activeTab, setActiveTab] = useState< + "home" | "movies" | "series" | "anime" | "bookmarks" | "history" | "search" + >("home"); const [searchQuery, setSearchQuery] = useState(""); const [searchResults, setSearchResults] = useState([]); const [isSearching, setIsSearching] = useState(false); @@ -1097,6 +1099,122 @@ export default function Cinema({ } }; + // Remove individual item from watch history + const handleRemoveHistoryItem = async (e: React.MouseEvent, h: CinemaPlayback) => { + e.stopPropagation(); + if (isTauri) { + try { + const nextState = await invoke("remove_cinema_history_item", { + titleId: h.title_id, + season: h.season, + episode: h.episode, + userEmail: currentUser?.email, + }); + setCinemaState(nextState); + } catch (err) { + console.error("Error removing history item:", err); + } + } else { + setCinemaState((prev) => ({ + ...prev, + history: prev.history.filter( + (item) => !(item.title_id === h.title_id && item.season === h.season && item.episode === h.episode) + ), + })); + } + showTvToast("Удалено из истории", "🗑", 1200); + playTvBackBeep(); + }; + + // Clear all watch history + const handleClearHistory = async () => { + if (isTauri) { + try { + const nextState = await invoke("clear_cinema_history", { + userEmail: currentUser?.email, + }); + setCinemaState(nextState); + } catch (err) { + console.error("Error clearing history:", err); + } + } else { + setCinemaState((prev) => ({ ...prev, history: [] })); + } + showTvToast("История просмотров очищена", "🗑", 1500); + playTvBackBeep(); + }; + + // Format date for history view + const formatHistoryDate = (isoString?: string): string => { + if (!isoString) return ""; + try { + const date = new Date(isoString); + const now = new Date(); + const diffMs = now.getTime() - date.getTime(); + const diffMins = Math.floor(diffMs / 60000); + const diffHours = Math.floor(diffMins / 60); + const diffDays = Math.floor(diffHours / 24); + + if (diffMins < 5) return "Только что"; + if (diffMins < 60) return `${diffMins} мин назад`; + if (diffHours < 24 && date.getDate() === now.getDate()) { + return `Сегодня, ${date.toLocaleTimeString("ru-RU", { hour: "2-digit", minute: "2-digit" })}`; + } + if (diffDays === 1 || (diffHours < 48 && date.getDate() === now.getDate() - 1)) { + return `Вчера, ${date.toLocaleTimeString("ru-RU", { hour: "2-digit", minute: "2-digit" })}`; + } + return date.toLocaleDateString("ru-RU", { day: "numeric", month: "short" }); + } catch { + return ""; + } + }; + + // Play an item directly from history + const handlePlayHistoryItem = (h: CinemaPlayback) => { + const found = [ + ...allTrending, + ...allMoviesList, + ...allSeriesList, + ...allAnimeList, + ].find((item) => item.id === h.title_id); + + if (found) { + handleMediaCardClick(found); + } else { + const tmdbMatch = h.title_id.match(/tmdb-(movie|tv)-(\d+)/); + const isTv = tmdbMatch ? tmdbMatch[1] === "tv" : h.season > 0 && h.episode > 0; + const tmdbId = tmdbMatch ? parseInt(tmdbMatch[2], 10) : undefined; + const fallbackItem: CinemaMediaItem = { + id: h.title_id, + tmdb_id: tmdbId, + media_type: isTv ? "tv" : "movie", + title: h.title_name, + poster_url: h.poster_path || INITIAL_LIBRARY[0].poster_url, + backdrop_url: h.backdrop_path || INITIAL_LIBRARY[0].backdrop_url, + genre: isTv ? "Сериал" : "Фильм", + synopsis: `Продолжение просмотра: ${h.title_name}`, + release_year: "2024", + rating: 8.5, + is_original: false, + seasons: [ + { + season_number: h.season || 1, + episodes: [ + { + id: `ep-${h.title_id}-${h.season}-${h.episode}`, + number: h.episode || 1, + title: `Серия ${h.episode || 1}`, + thumbnail: h.poster_path || INITIAL_LIBRARY[0].poster_url, + duration_minutes: Math.round((h.duration_seconds || 3600) / 60), + }, + ], + }, + ], + }; + handleMediaCardClick(fallbackItem); + } + }; + // Get progress for a specific episode const getEpisodeProgress = (seasonNum: number, episodeNum: number): number => { const found = cinemaState.history.find( @@ -1406,13 +1524,11 @@ export default function Cinema({ if (info.imdb_id) setCurrentImdbId(info.imdb_id); if (info.iframe_url) { setCurrentAllohaUrl(info.iframe_url); - if (!isAnime) { - setSelectedMirror("alloha_ru"); - } + // Do not automatically switch to Alloha to avoid Riobet ads and low-bitrate audio hiss } if (!isAnime) { setRussianDubNotice( - `🇷🇺 Русская озвучка подключена: «${info.russian_title || media.title}» воспроизводится с полным русским дубляжем.` + `🇷🇺 Доступно: «${info.russian_title || media.title}» в чистом качестве.` ); } } @@ -1428,9 +1544,6 @@ export default function Cinema({ if (d?.id_imdb) setCurrentImdbId(d.id_imdb); if (d?.iframe) { setCurrentAllohaUrl(d.iframe); - if (!isAnime) { - setSelectedMirror("alloha_ru"); - } } } }) @@ -2514,6 +2627,24 @@ export default function Cinema({ } const finalDubbings = Array.from(uniqueStudios.values()); + // Prioritize official studio dubbings and clean audio tracks over amateur mic overdubs + finalDubbings.sort((a, b) => { + const titleA = (a.translation?.title || "").toLowerCase(); + const titleB = (b.translation?.title || "").toLowerCase(); + + const getQualityScore = (t: string) => { + if (t.includes("полный дубляж") || t.includes("дубляж") || t.includes("чистый звук")) return 100; + if (t.includes("пифагор") || t.includes("мосфильм") || t.includes("невафильм") || t.includes("кириллица")) return 95; + if (t.includes("red head sound") || t.includes("rhs") || t.includes("flarrow films")) return 90; + if (t.includes("lostfilm") || t.includes("hdrezka") || t.includes("кубик в кубе") || t.includes("newstudio")) return 80; + if (t.includes("tvshows") || t.includes("кураж-бамбей") || t.includes("alexfilm")) return 70; + if (t.includes("coldfilm") || t.includes("baibako")) return 15; // Low-bitrate noisy voiceover + return 50; + }; + + return getQualityScore(titleB) - getQualityScore(titleA); + }); + if (finalDubbings.length > 0) { setKodikResults(finalDubbings); setSelectedKodikLink(finalDubbings[0].link); @@ -2758,6 +2889,25 @@ export default function Cinema({ + + {onNavigateTab && ( +
@@ -3193,7 +3355,20 @@ export default function Cinema({ {/* Row 1: Continue Watching */} {cinemaState.history.length > 0 && activeTab === "home" && (
-

Продолжить просмотр

+
+

Продолжить просмотр

+ +
{cinemaState.history.map((h, i) => { const percent = @@ -3203,97 +3378,15 @@ export default function Cinema({ return (
{ - const found = [ - ...allTrending, - ...allMoviesList, - ...allSeriesList, - ...allAnimeList, - ].find((item) => item.id === h.title_id); - if (found) { - handleMediaCardClick(found); - } else { - const tmdbMatch = h.title_id.match(/tmdb-(movie|tv)-(\d+)/); - const isTv = tmdbMatch ? tmdbMatch[1] === "tv" : h.season > 0 && h.episode > 0; - const tmdbId = tmdbMatch ? parseInt(tmdbMatch[2], 10) : undefined; - const fallbackItem: CinemaMediaItem = { - id: h.title_id, - tmdb_id: tmdbId, - media_type: isTv ? "tv" : "movie", - title: h.title_name, - poster_url: h.poster_path || INITIAL_LIBRARY[0].poster_url, - backdrop_url: h.backdrop_path || INITIAL_LIBRARY[0].backdrop_url, - genre: isTv ? "Сериал" : "Фильм", - synopsis: `Продолжение просмотра: ${h.title_name}`, - release_year: "2024", - rating: 8.5, - is_original: false, - seasons: [ - { - season_number: h.season || 1, - episodes: [ - { - id: `ep-${h.title_id}-${h.season}-${h.episode}`, - number: h.episode || 1, - title: `Серия ${h.episode || 1}`, - thumbnail: h.poster_path || INITIAL_LIBRARY[0].poster_url, - duration_minutes: Math.round((h.duration_seconds || 3600) / 60), - }, - ], - }, - ], - }; - handleMediaCardClick(fallbackItem); - } - }} + className="has-cinema-media-card has-cinema-history-card" + onClick={() => handlePlayHistoryItem(h)} data-tv-focusable="true" tabIndex={0} role="button" onKeyDown={(e) => { if (e.key === "Enter" || e.key === " " || e.keyCode === 23 || e.keyCode === 66) { e.preventDefault(); - const found = [ - ...allTrending, - ...allMoviesList, - ...allSeriesList, - ...allAnimeList, - ].find((item) => item.id === h.title_id); - if (found) { - handleMediaCardClick(found); - } else { - const tmdbMatch = h.title_id.match(/tmdb-(movie|tv)-(\d+)/); - const isTv = tmdbMatch ? tmdbMatch[1] === "tv" : h.season > 0 && h.episode > 0; - const tmdbId = tmdbMatch ? parseInt(tmdbMatch[2], 10) : undefined; - const fallbackItem: CinemaMediaItem = { - id: h.title_id, - tmdb_id: tmdbId, - media_type: isTv ? "tv" : "movie", - title: h.title_name, - poster_url: h.poster_path || INITIAL_LIBRARY[0].poster_url, - backdrop_url: h.backdrop_path || INITIAL_LIBRARY[0].backdrop_url, - genre: isTv ? "Сериал" : "Фильм", - synopsis: `Продолжение просмотра: ${h.title_name}`, - release_year: "2024", - rating: 8.5, - is_original: false, - seasons: [ - { - season_number: h.season || 1, - episodes: [ - { - id: `ep-${h.title_id}-${h.season}-${h.episode}`, - number: h.episode || 1, - title: `Серия ${h.episode || 1}`, - thumbnail: h.poster_path || INITIAL_LIBRARY[0].poster_url, - duration_minutes: Math.round((h.duration_seconds || 3600) / 60), - }, - ], - }, - ], - }; - handleMediaCardClick(fallbackItem); - } + handlePlayHistoryItem(h); } }} > @@ -3304,6 +3397,15 @@ export default function Cinema({ className="has-cinema-media-thumb" loading="lazy" /> +
▶
@@ -3313,6 +3415,7 @@ export default function Cinema({ style={{ width: `${percent}%` }} />
+ {percent}%
{h.title_name} @@ -3536,6 +3639,134 @@ export default function Cinema({ )}
)} + + {/* Row 7: Watch History List */} + {activeTab === "history" && ( +
+
+
+

История просмотров

+ + {cinemaState.history.length === 0 + ? "Прогресс не зафиксирован" + : `${cinemaState.history.length} ${ + cinemaState.history.length === 1 + ? "запись" + : cinemaState.history.length < 5 + ? "записи" + : "записей" + } • Синхронизировано на всех устройствах`} + +
+ {cinemaState.history.length > 0 && ( + + )} +
+ + {cinemaState.history.length === 0 ? ( +
+ 🕒 + В истории пока ничего нет + + Фильмы и серии, которые вы начнёте смотреть, будут автоматически сохраняться здесь с точным прогрессом. + + +
+ ) : ( +
+ {cinemaState.history.map((h, i) => { + const percent = + h.duration_seconds > 0 + ? Math.min(100, Math.round((h.progress_seconds / h.duration_seconds) * 100)) + : 0; + const remainingSecs = Math.max(0, h.duration_seconds - h.progress_seconds); + const remainingMins = Math.round(remainingSecs / 60); + + return ( +
handlePlayHistoryItem(h)} + data-tv-focusable="true" + tabIndex={0} + role="button" + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " " || e.keyCode === 23 || e.keyCode === 66) { + e.preventDefault(); + handlePlayHistoryItem(h); + } + }} + > +
+ {h.title_name} + +
= 90 ? "completed" : ""}`}> + {percent >= 90 ? "✓ Просмотрено" : `~${remainingMins} мин`} +
+ + + +
+
▶
+
+ +
+
+
+ {percent}% +
+ +
+ {h.title_name} +
+ + {h.season > 0 ? `Сезон ${h.season}, Серия ${h.episode}` : "Фильм"} + + {h.last_watched && ( + + {formatHistoryDate(h.last_watched)} + + )} +
+
+
+ ); + })} +
+ )} +
+ )}
)} @@ -4122,6 +4353,27 @@ export default function Cinema({ 🛡️ {isAdBlockEnabled ? "Без рекламы" : "Реклама: Вкл"} + {/* Quick Skip Ad / Intro Pill */} + + {/* Single Settings button (Contains voiceover studios & mirrors) */}