修复跳屏问题
This commit is contained in:
parent
728ba79eb6
commit
90c8df4ceb
@ -146,7 +146,7 @@ func MovieList(c *gin.Context) {
|
||||
sortBy := c.Query("sort")
|
||||
order := c.Query("order")
|
||||
if sortBy == "" {
|
||||
sortBy = "created_time" // 默认按创建时间排序
|
||||
sortBy = "duration" // 默认按创建时间排序
|
||||
}
|
||||
|
||||
// 应用排序
|
||||
|
@ -13,6 +13,7 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Movie 电影信息结构
|
||||
@ -74,7 +75,7 @@ var Movies []*Movie // 存储所有电影信息的全局切
|
||||
var MovieDict = make(map[string]*Movie) // 存储需要处理缩略图的视频字典
|
||||
var MovieDictLock sync.Mutex // 保护MovieDict的并发访问
|
||||
|
||||
var IsRemakePNG = false // 是否重新生成所有PNG缩略图
|
||||
var IsRemakePNG = true // 是否重新生成所有PNG缩略图
|
||||
var Categories = []string{ // 分类
|
||||
"15min", "30min", "60min", "大于60min", "最新添加"}
|
||||
|
||||
@ -223,38 +224,55 @@ func generateThumbnail(movie *Movie) {
|
||||
baseName := strings.TrimSuffix(movie.FileName, filepath.Ext(movie.FileName))
|
||||
outputPath := filepath.Join("movie", baseName+".png")
|
||||
|
||||
// 根据时长选择不同的采样策略
|
||||
var filter string
|
||||
switch {
|
||||
case movie.Duration <= 2:
|
||||
filter = "select='isnan(prev_selected_t)+gte(t-prev_selected_t\\,5)',scale=320:180,tile=3x3"
|
||||
case movie.Duration <= 5:
|
||||
filter = "select='isnan(prev_selected_t)+gte(t-prev_selected_t\\,10)',scale=320:180,tile=3x3"
|
||||
case movie.Duration <= 30:
|
||||
filter = "select='isnan(prev_selected_t)+gte(t-prev_selected_t\\,20)',scale=320:180,tile=3x3"
|
||||
case movie.Duration <= 60:
|
||||
filter = "select='isnan(prev_selected_t)+gte(t-prev_selected_t\\,35)',scale=320:180,tile=3x3"
|
||||
default:
|
||||
filter = "select='isnan(prev_selected_t)+gte(t-prev_selected_t\\,60)',scale=320:180,tile=3x3"
|
||||
}
|
||||
// --- Start of improved logic ---
|
||||
|
||||
// 执行ffmpeg命令生成缩略图
|
||||
// Fixed skip for the beginning of the video, as per original code's ffmpeg args
|
||||
const skipStartSeconds = 10.0
|
||||
// Number of frames we want in the tile (for a 3x3 grid)
|
||||
const numFramesInTile = 9.0 // Use float for calculations
|
||||
|
||||
durationAfterSkip := float64(movie.Duration) - skipStartSeconds
|
||||
|
||||
var intervalSeconds float64
|
||||
|
||||
if durationAfterSkip <= 0.1 { // Use a small threshold like 0.1s
|
||||
intervalSeconds = 0.05 // A very small interval to grab whatever is there.
|
||||
} else {
|
||||
if numFramesInTile > 1 {
|
||||
intervalSeconds = durationAfterSkip / (numFramesInTile - 1.0)
|
||||
} else {
|
||||
intervalSeconds = durationAfterSkip
|
||||
}
|
||||
if intervalSeconds <= 0 {
|
||||
intervalSeconds = 0.05 // Fallback to a tiny interval
|
||||
}
|
||||
}
|
||||
filter := fmt.Sprintf("select='isnan(prev_selected_t)+gte(t-prev_selected_t\\,%.3f)',scale=320:180,tile=3x3", intervalSeconds)
|
||||
|
||||
log.Printf("Movie: %s, OriginalDuration: %ds, SkipStart: %.1fs, DurationForSampling: %.2fs, CalculatedInterval: %.3fs",
|
||||
movie.FileName, movie.Duration, skipStartSeconds, durationAfterSkip, intervalSeconds)
|
||||
|
||||
// Execute ffmpeg command
|
||||
cmd := exec.Command("ffmpeg",
|
||||
"-ss", fmt.Sprintf("%.0f", skipStartSeconds), // Using the defined skipStartSeconds
|
||||
"-i", movie.VideoPath,
|
||||
"-vf", filter,
|
||||
"-frames:v", "1",
|
||||
"-q:v", "3",
|
||||
"-y", // 覆盖已存在文件
|
||||
"-frames:v", "1", // Crucial for outputting a single tiled image
|
||||
"-q:v", "3", // Quality for the frames before tiling/PNG compression
|
||||
"-y", // Overwrite output file if it exists
|
||||
outputPath,
|
||||
)
|
||||
|
||||
var stderr bytes.Buffer
|
||||
cmd.Stderr = &stderr
|
||||
|
||||
startTime := time.Now()
|
||||
if err := cmd.Run(); err != nil {
|
||||
log.Printf("生成缩略图失败: %s (%d min): %v\n错误输出: %s",
|
||||
// Assuming movie.Duration is in seconds, adjusted log from (%d min) to (%d s)
|
||||
log.Printf("生成缩略图失败: %s (%d s): %v\n错误输出: %s",
|
||||
movie.VideoPath, movie.Duration, err, stderr.String())
|
||||
} else {
|
||||
log.Printf("成功生成缩略图: %s", outputPath)
|
||||
log.Printf("成功生成缩略图: %s (耗时: %v)", outputPath, time.Since(startTime))
|
||||
}
|
||||
}
|
||||
|
||||
|
432
src/Main.jsx
432
src/Main.jsx
@ -23,11 +23,11 @@ const categories = [
|
||||
{ label: '最新添加', value: '最新添加' },
|
||||
];
|
||||
|
||||
// 新增搜索类别常量
|
||||
const SEARCH_CATEGORY = 'search';
|
||||
const LIMIT = 20;
|
||||
const DEFAULT_CATEGORY = categories[0].value;
|
||||
|
||||
|
||||
const usePersistedState = (key, defaultValue) => {
|
||||
const [state, setState] = useState(() => {
|
||||
const stored = localStorage.getItem(key);
|
||||
@ -46,281 +46,307 @@ const Main = () => {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const isFirstLoad = useRef(true);
|
||||
const scrollListenerActive = useRef(false);
|
||||
const skipScrollRestore = useRef(false);
|
||||
const isPaginating = useRef(false); // New ref for pagination
|
||||
|
||||
|
||||
|
||||
|
||||
// 初始化状态 - 添加搜索相关状态
|
||||
const [params, setParams] = usePersistedState('params', {
|
||||
lastCategory: DEFAULT_CATEGORY,
|
||||
history: Object.fromEntries([
|
||||
...categories.map(cat => [
|
||||
cat.value,
|
||||
{ lastPage: 1, scrollPos: 0 }
|
||||
]),
|
||||
// 为搜索添加单独的历史记录
|
||||
[SEARCH_CATEGORY, { lastPage: 1, scrollPos: 0 }]
|
||||
...categories.map(cat => [cat.value, { lastPage: 1, scrollPos: 0 }]),
|
||||
[SEARCH_CATEGORY, { lastPage: 1, scrollPos: 0, searchQuery: '' }] // Ensure searchQuery is part of history for search
|
||||
]),
|
||||
// 新增搜索查询状态
|
||||
searchQuery: ''
|
||||
searchQuery: '' // Global search query, might be redundant if using history.searchQuery
|
||||
});
|
||||
|
||||
const [movies, setMovies] = useState([]);
|
||||
const [pagination, setPagination] = useState({ page: 1, total: 0, pages: 1 });
|
||||
const currentCategory = params.lastCategory || DEFAULT_CATEGORY;
|
||||
|
||||
// 新增搜索输入状态
|
||||
const [searchInput, setSearchInput] = useState(params.searchQuery || '');
|
||||
|
||||
// 滚动位置处理
|
||||
const saveScrollPosition = useCallback((category = currentCategory) => {
|
||||
const currentScrollPos = window.scrollY;
|
||||
const currentCategory = params.lastCategory || DEFAULT_CATEGORY;
|
||||
const currentCategoryHistory = params.history[currentCategory] || { lastPage: 1, scrollPos: 0 };
|
||||
|
||||
setParams(prev => {
|
||||
const updatedHistory = {
|
||||
...prev.history,
|
||||
[category]: {
|
||||
...(prev.history[category] || { lastPage: 1, scrollPos: 0 }),
|
||||
scrollPos: currentScrollPos
|
||||
|
||||
const handleScrollPosition = useCallback((action, category = currentCategory) => {
|
||||
if (action === 'save') {
|
||||
const currentScrollPos = window.scrollY;
|
||||
setParams(prev => ({
|
||||
...prev,
|
||||
history: {
|
||||
...prev.history,
|
||||
[category]: {
|
||||
...(prev.history[category] || { lastPage: 1 }),
|
||||
scrollPos: currentScrollPos
|
||||
}
|
||||
}
|
||||
};
|
||||
}));
|
||||
} else if (action === 'restore') {
|
||||
const scrollPos = params.history?.[category]?.scrollPos || 0;
|
||||
// console.log(`Attempting to restore scroll for ${category} to ${scrollPos}`);
|
||||
requestAnimationFrame(() => {
|
||||
// Increased timeout slightly for better rendering chance
|
||||
setTimeout(() => {
|
||||
window.scrollTo(0, scrollPos);
|
||||
// console.log(`Scrolled to ${scrollPos} for ${category}`);
|
||||
}, 150);
|
||||
});
|
||||
}
|
||||
}, [setParams, params.history, currentCategory]);
|
||||
|
||||
return { ...prev, history: updatedHistory };
|
||||
});
|
||||
}, [setParams, currentCategory]);
|
||||
|
||||
const setupScrollListener = useCallback(() => {
|
||||
if (scrollListenerActive.current) return;
|
||||
|
||||
const handleScroll = () => {
|
||||
const manageScrollListener = useCallback((action) => {
|
||||
const scrollHandler = () => {
|
||||
clearTimeout(window.scrollTimer);
|
||||
window.scrollTimer = setTimeout(() => saveScrollPosition(), 100);
|
||||
window.scrollTimer = setTimeout(() => handleScrollPosition('save'), 200); // Debounce save
|
||||
};
|
||||
window.scrollHandler = scrollHandler; // Store to remove the correct one
|
||||
|
||||
window.addEventListener('scroll', handleScroll);
|
||||
scrollListenerActive.current = true;
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('scroll', handleScroll);
|
||||
clearTimeout(window.scrollTimer);
|
||||
scrollListenerActive.current = false;
|
||||
};
|
||||
}, [saveScrollPosition]);
|
||||
|
||||
const removeScrollListener = useCallback(() => {
|
||||
if (scrollListenerActive.current) {
|
||||
if (action === 'setup' && !scrollListenerActive.current) {
|
||||
window.addEventListener('scroll', window.scrollHandler);
|
||||
scrollListenerActive.current = true;
|
||||
} else if (action === 'remove' && scrollListenerActive.current) {
|
||||
window.removeEventListener('scroll', window.scrollHandler);
|
||||
scrollListenerActive.current = false;
|
||||
clearTimeout(window.scrollTimer);
|
||||
}
|
||||
}, [handleScrollPosition]);
|
||||
|
||||
|
||||
const fetchMovies = useCallback(async (category, page, search = '', options = {}) => {
|
||||
if (options.skipRestore) {
|
||||
skipScrollRestore.current = true;
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 修改fetchMovies以支持搜索
|
||||
const fetchMovies = useCallback(async (category, page, search = '') => {
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const isSearch = category === SEARCH_CATEGORY;
|
||||
const isLatest = category === '最新添加';
|
||||
|
||||
let apiUrl = `/movie/list?page=${page}&limit=${LIMIT}`;
|
||||
|
||||
if (isSearch) {
|
||||
// 搜索请求
|
||||
apiUrl += `&search=${encodeURIComponent(search)}`;
|
||||
} else if (isLatest) {
|
||||
// 最新添加请求
|
||||
apiUrl += `&sort=created_time&order=desc`;
|
||||
} else {
|
||||
// 分类请求
|
||||
apiUrl += `&category=${encodeURIComponent(category)}`;
|
||||
}
|
||||
if (isSearch) apiUrl += `&search=${encodeURIComponent(search)}`;
|
||||
else if (isLatest) apiUrl += `&sort=created_time&order=desc`;
|
||||
else apiUrl += `&category=${encodeURIComponent(category)}`;
|
||||
|
||||
const response = await axios.get(apiUrl);
|
||||
if (response.status !== 200) return;
|
||||
if (response.status !== 200) {
|
||||
console.error("API Error:", response);
|
||||
setMovies([]);
|
||||
setPagination({ page, total: 0, pages: 1 });
|
||||
return;
|
||||
}
|
||||
|
||||
const { items, total } = response.data.data;
|
||||
const totalPages = Math.ceil(total / LIMIT);
|
||||
|
||||
if (items.length === 0 && page > 1) {
|
||||
if (items.length === 0 && page > 1 && !isSearch) { // Don't auto-decrement page for search
|
||||
setParams(prev => ({
|
||||
...prev,
|
||||
history: {
|
||||
...prev.history,
|
||||
[category]: { ...prev.history[category], lastPage: page - 1 }
|
||||
[category]: { ...prev.history[category], lastPage: Math.max(1, page - 1) }
|
||||
}
|
||||
}));
|
||||
// Optionally, fetch page - 1 here, or let user do it.
|
||||
// For now, just update the state and show no movies.
|
||||
setMovies([]);
|
||||
setPagination({ page: Math.max(1, page - 1), total, pages: totalPages });
|
||||
return;
|
||||
}
|
||||
|
||||
setMovies(items);
|
||||
setPagination({ page, total, pages: totalPages });
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error fetching movies:', error);
|
||||
setMovies([]);
|
||||
setPagination({ page, total: 0, pages: 1 });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setLoading(false); // This will trigger the useEffect[loading]
|
||||
}
|
||||
}, [setParams]);
|
||||
}, [setParams]); // Removed currentCategory from deps as fetchMovies doesn't directly use it for scroll
|
||||
|
||||
// 恢复滚动位置
|
||||
const restoreScrollPosition = useCallback((category) => {
|
||||
const scrollPos = params.history?.[category]?.scrollPos || 0;
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
setTimeout(() => window.scrollTo(0, scrollPos), 100);
|
||||
});
|
||||
}, [params.history]);
|
||||
const handleTransition = useCallback((actionCallback) => {
|
||||
// console.log(`Transition: Saving scroll for ${currentCategory}`);
|
||||
manageScrollListener('remove');
|
||||
handleScrollPosition('save'); // Save scroll for the category we are leaving
|
||||
actionCallback();
|
||||
// manageScrollListener will be set up again in useEffect[loading]
|
||||
}, [manageScrollListener, handleScrollPosition, currentCategory]);
|
||||
|
||||
// 初始加载
|
||||
|
||||
// Initial Load
|
||||
useEffect(() => {
|
||||
if (!isFirstLoad.current) return;
|
||||
|
||||
const category = currentCategory;
|
||||
const categoryHistory = params.history[category] || { lastPage: 1 };
|
||||
const categoryToLoad = params.lastCategory || DEFAULT_CATEGORY;
|
||||
const historyForCategory = params.history[categoryToLoad] || { lastPage: 1, scrollPos: 0 };
|
||||
const pageToLoad = historyForCategory.lastPage;
|
||||
let searchQueryToLoad = '';
|
||||
if (categoryToLoad === SEARCH_CATEGORY) {
|
||||
searchQueryToLoad = params.searchQuery || (historyForCategory.searchQuery || '');
|
||||
setSearchInput(searchQueryToLoad); // Sync search input
|
||||
}
|
||||
// console.log(`Initial Load: Fetching ${categoryToLoad}, page ${pageToLoad}, search: "${searchQueryToLoad}"`);
|
||||
fetchMovies(categoryToLoad, pageToLoad, searchQueryToLoad);
|
||||
isFirstLoad.current = false;
|
||||
}, []); // Runs only once on mount
|
||||
|
||||
// 如果是搜索类别,传递搜索查询
|
||||
if (category === SEARCH_CATEGORY) {
|
||||
fetchMovies(category, categoryHistory.lastPage, params.searchQuery);
|
||||
|
||||
// Scroll Management after data loading
|
||||
useEffect(() => {
|
||||
if (loading || isFirstLoad.current) return; // Don't run if loading or still initial phase
|
||||
|
||||
manageScrollListener('remove'); // Ensure no listener interference
|
||||
|
||||
if (isPaginating.current) {
|
||||
// console.log("Pagination: Scrolling to top");
|
||||
window.scrollTo(0, 0);
|
||||
isPaginating.current = false;
|
||||
} else if (skipScrollRestore.current) {
|
||||
// console.log(`Skipping scroll restore for ${currentCategory} (e.g., after rename)`);
|
||||
skipScrollRestore.current = false;
|
||||
} else {
|
||||
fetchMovies(category, categoryHistory.lastPage);
|
||||
// console.log(`Restoring scroll for ${currentCategory} (normal flow)`);
|
||||
handleScrollPosition('restore', currentCategory);
|
||||
}
|
||||
|
||||
isFirstLoad.current = false;
|
||||
}, [fetchMovies, currentCategory, params.history, params.searchQuery]);
|
||||
manageScrollListener('setup'); // Setup listener after scroll decision
|
||||
|
||||
}, [loading, currentCategory, handleScrollPosition, manageScrollListener]);
|
||||
|
||||
// 数据加载完成后处理
|
||||
useEffect(() => {
|
||||
if (loading || isFirstLoad.current) return;
|
||||
restoreScrollPosition(currentCategory);
|
||||
setupScrollListener();
|
||||
}, [loading, restoreScrollPosition, currentCategory, setupScrollListener]);
|
||||
|
||||
// 类别切换处理 - 修改以支持搜索类别
|
||||
const handleCategoryChange = useCallback((newCategory) => {
|
||||
removeScrollListener();
|
||||
saveScrollPosition();
|
||||
|
||||
setParams(prev => {
|
||||
const categoryHistory = prev.history[newCategory] || { lastPage: 1 };
|
||||
|
||||
// 如果是搜索类别,使用保存的搜索查询
|
||||
handleTransition(() => {
|
||||
const newCategoryHistory = params.history[newCategory] || { lastPage: 1, scrollPos: 0 };
|
||||
let searchQueryForNewCategory = '';
|
||||
if (newCategory === SEARCH_CATEGORY) {
|
||||
fetchMovies(newCategory, categoryHistory.lastPage, prev.searchQuery);
|
||||
searchQueryForNewCategory = params.searchQuery || (newCategoryHistory.searchQuery || '');
|
||||
setSearchInput(searchQueryForNewCategory); // Sync search input
|
||||
} else {
|
||||
fetchMovies(newCategory, categoryHistory.lastPage);
|
||||
setSearchInput(''); // Clear search input if not search category
|
||||
}
|
||||
|
||||
return {
|
||||
...prev,
|
||||
lastCategory: newCategory
|
||||
};
|
||||
setParams(prev => ({ ...prev, lastCategory: newCategory }));
|
||||
// console.log(`Category Change: Fetching ${newCategory}, page ${newCategoryHistory.lastPage}`);
|
||||
fetchMovies(newCategory, newCategoryHistory.lastPage, searchQueryForNewCategory);
|
||||
});
|
||||
}, [removeScrollListener, saveScrollPosition, setParams, fetchMovies]);
|
||||
}, [handleTransition, setParams, fetchMovies, params.history, params.searchQuery]);
|
||||
|
||||
|
||||
// 分页处理 - 修改以支持搜索
|
||||
const handlePageChange = useCallback((_, page) => {
|
||||
removeScrollListener();
|
||||
saveScrollPosition();
|
||||
// Save scroll position for the current page of the current category BEFORE fetching new page data
|
||||
handleScrollPosition('save', currentCategory);
|
||||
isPaginating.current = true; // Signal that this is a pagination action
|
||||
|
||||
setParams(prev => ({
|
||||
...prev,
|
||||
history: {
|
||||
...prev.history,
|
||||
[currentCategory]: {
|
||||
...prev.history[currentCategory],
|
||||
lastPage: page,
|
||||
scrollPos: 0
|
||||
}
|
||||
[currentCategory]: { ...currentCategoryHistory, lastPage: page }
|
||||
}
|
||||
}));
|
||||
|
||||
// 如果是搜索类别,传递搜索查询
|
||||
if (currentCategory === SEARCH_CATEGORY) {
|
||||
fetchMovies(currentCategory, page, params.searchQuery);
|
||||
} else {
|
||||
fetchMovies(currentCategory, page);
|
||||
}
|
||||
const searchQueryForPageChange = currentCategory === SEARCH_CATEGORY ? (params.searchQuery || currentCategoryHistory.searchQuery || '') : '';
|
||||
// console.log(`Page Change: Fetching ${currentCategory}, page ${page}, search: "${searchQueryForPageChange}"`);
|
||||
fetchMovies(currentCategory, page, searchQueryForPageChange);
|
||||
// Scrolling to top will be handled by useEffect[loading] due to isPaginating.current
|
||||
}, [setParams, fetchMovies, currentCategory, params.searchQuery, currentCategoryHistory, handleScrollPosition]);
|
||||
|
||||
window.scrollTo(0, 0);
|
||||
}, [removeScrollListener, saveScrollPosition, setParams, fetchMovies, currentCategory, params.searchQuery]);
|
||||
|
||||
// 电影卡片点击处理
|
||||
const handleMovieCardClick = useCallback(() => {
|
||||
removeScrollListener();
|
||||
saveScrollPosition();
|
||||
}, [removeScrollListener, saveScrollPosition]);
|
||||
|
||||
// 新增:处理搜索提交
|
||||
const handleSearchSubmit = useCallback(() => {
|
||||
if (!searchInput.trim()) return;
|
||||
const trimmedSearch = searchInput.trim();
|
||||
if (!trimmedSearch) return;
|
||||
|
||||
removeScrollListener();
|
||||
saveScrollPosition();
|
||||
|
||||
setParams(prev => {
|
||||
// 更新搜索查询并切换到搜索类别
|
||||
const updatedParams = {
|
||||
handleTransition(() => {
|
||||
setParams(prev => ({
|
||||
...prev,
|
||||
lastCategory: SEARCH_CATEGORY,
|
||||
searchQuery: searchInput.trim(),
|
||||
searchQuery: trimmedSearch, // Update global search query
|
||||
history: {
|
||||
...prev.history,
|
||||
[SEARCH_CATEGORY]: {
|
||||
...prev.history[SEARCH_CATEGORY],
|
||||
lastPage: 1 // 新搜索总是从第一页开始
|
||||
[SEARCH_CATEGORY]: { // Reset search history to page 1 for new search
|
||||
lastPage: 1,
|
||||
scrollPos: 0, // New search starts at top
|
||||
searchQuery: trimmedSearch
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 执行搜索请求
|
||||
fetchMovies(SEARCH_CATEGORY, 1, searchInput.trim());
|
||||
|
||||
return updatedParams;
|
||||
}));
|
||||
// console.log(`Search Submit: Fetching ${SEARCH_CATEGORY}, page 1, search: "${trimmedSearch}"`);
|
||||
fetchMovies(SEARCH_CATEGORY, 1, trimmedSearch);
|
||||
});
|
||||
}, [removeScrollListener, saveScrollPosition, setParams, fetchMovies, searchInput]);
|
||||
}, [handleTransition, setParams, fetchMovies, searchInput]);
|
||||
|
||||
|
||||
// 新增:清除搜索
|
||||
const handleClearSearch = useCallback(() => {
|
||||
if (!searchInput) return;
|
||||
if (!searchInput && currentCategory !== SEARCH_CATEGORY) return;
|
||||
|
||||
setSearchInput('');
|
||||
|
||||
// 如果当前在搜索类别,则清除后切换到默认类别
|
||||
// If we are currently in search results, transition to default category
|
||||
if (currentCategory === SEARCH_CATEGORY) {
|
||||
setParams(prev => ({
|
||||
...prev,
|
||||
lastCategory: DEFAULT_CATEGORY,
|
||||
searchQuery: ''
|
||||
}));
|
||||
|
||||
// 获取默认类别的数据
|
||||
const categoryHistory = params.history[DEFAULT_CATEGORY] || { lastPage: 1 };
|
||||
fetchMovies(DEFAULT_CATEGORY, categoryHistory.lastPage);
|
||||
handleTransition(() => {
|
||||
const defaultCategoryHistory = params.history[DEFAULT_CATEGORY] || { lastPage: 1, scrollPos: 0 };
|
||||
setParams(prev => ({
|
||||
...prev,
|
||||
lastCategory: DEFAULT_CATEGORY,
|
||||
searchQuery: '', // Clear global search query
|
||||
// We can choose to preserve or reset params.history[SEARCH_CATEGORY].searchQuery
|
||||
// For now, let's clear it in history as well if we are clearing and navigating away
|
||||
history: {
|
||||
...prev.history,
|
||||
[SEARCH_CATEGORY]: { ...params.history[SEARCH_CATEGORY], searchQuery: '' }
|
||||
}
|
||||
}));
|
||||
// console.log(`Clear Search (was search category): Fetching ${DEFAULT_CATEGORY}, page ${defaultCategoryHistory.lastPage}`);
|
||||
fetchMovies(DEFAULT_CATEGORY, defaultCategoryHistory.lastPage);
|
||||
});
|
||||
} else {
|
||||
// 不在搜索类别,只清除输入框
|
||||
setParams(prev => ({
|
||||
...prev,
|
||||
searchQuery: ''
|
||||
}));
|
||||
// If not in search results, just clear the persisted search query
|
||||
setParams(prev => ({ ...prev, searchQuery: '' }));
|
||||
}
|
||||
}, [currentCategory, fetchMovies, params.history, searchInput, setParams]);
|
||||
}, [currentCategory, fetchMovies, params.history, searchInput, setParams, handleTransition]);
|
||||
|
||||
// 新增:处理搜索输入变化
|
||||
const handleSearchChange = useCallback((e) => {
|
||||
setSearchInput(e.target.value);
|
||||
}, []);
|
||||
|
||||
// 新增:处理搜索输入键盘事件
|
||||
const handleRename = useCallback(async (oldName, newName) => {
|
||||
try {
|
||||
const response = await axios.post('/movie/rename', { old_name: oldName, new_name: newName });
|
||||
if (response.data.code === 200) {
|
||||
// Refresh current view data, but skip scroll restoration
|
||||
const pageToRefresh = currentCategoryHistory.lastPage;
|
||||
const searchQueryForRefresh = currentCategory === SEARCH_CATEGORY ? (params.searchQuery || currentCategoryHistory.searchQuery || '') : '';
|
||||
// console.log(`Rename: Refreshing ${currentCategory}, page ${pageToRefresh}, search: "${searchQueryForRefresh}" with skipRestore`);
|
||||
await fetchMovies(currentCategory, pageToRefresh, searchQueryForRefresh, { skipRestore: true });
|
||||
} else {
|
||||
throw new Error(response.data.message || '重命名失败');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('重命名错误:', error);
|
||||
throw error; // Re-throw to allow MovieCard to handle it if needed
|
||||
}
|
||||
}, [fetchMovies, currentCategory, currentCategoryHistory, params.searchQuery]);
|
||||
|
||||
|
||||
const handleMovieCardClick = useCallback(() => {
|
||||
// This is a navigation, so save scroll state
|
||||
// console.log(`Movie Card Click: Saving scroll for ${currentCategory} before navigating away.`);
|
||||
handleScrollPosition('save', currentCategory);
|
||||
// No need for manageScrollListener('remove') here as the component might unmount
|
||||
// or if it's an in-app navigation, the next component will manage its own scroll.
|
||||
}, [handleScrollPosition, currentCategory]);
|
||||
|
||||
const handleSearchChange = useCallback((e) => setSearchInput(e.target.value), []);
|
||||
const handleSearchKeyDown = useCallback((e) => {
|
||||
if (e.key === 'Enter') {
|
||||
handleSearchSubmit();
|
||||
}
|
||||
if (e.key === 'Enter') handleSearchSubmit();
|
||||
}, [handleSearchSubmit]);
|
||||
|
||||
// 组件卸载清理
|
||||
useEffect(() => removeScrollListener, [removeScrollListener]);
|
||||
// Cleanup scroll listener on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
manageScrollListener('remove');
|
||||
};
|
||||
}, [manageScrollListener]);
|
||||
|
||||
// 分页组件复用
|
||||
const paginationComponent = (
|
||||
|
||||
const paginationComponent = movies.length > 0 && pagination.pages > 1 && (
|
||||
<Pagination
|
||||
count={pagination.pages}
|
||||
page={pagination.page}
|
||||
@ -329,35 +355,8 @@ const Main = () => {
|
||||
/>
|
||||
);
|
||||
|
||||
const handleRename = useCallback(async (oldName, newName) => {
|
||||
try {
|
||||
const response = await axios.post('/movie/rename', {
|
||||
old_name: oldName,
|
||||
new_name: newName
|
||||
});
|
||||
|
||||
if (response.data.code === 200) {
|
||||
// 重命名成功后重新加载当前页面数据
|
||||
const category = params.lastCategory;
|
||||
const categoryHistory = params.history[category] || { lastPage: 1 };
|
||||
|
||||
if (category === SEARCH_CATEGORY) {
|
||||
await fetchMovies(category, categoryHistory.lastPage, params.searchQuery);
|
||||
} else {
|
||||
await fetchMovies(category, categoryHistory.lastPage);
|
||||
}
|
||||
} else {
|
||||
throw new Error(response.data.message || '重命名失败');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('重命名错误:', error);
|
||||
throw error;
|
||||
}
|
||||
}, [fetchMovies, params.lastCategory, params.history, params.searchQuery]);
|
||||
|
||||
return (
|
||||
<Container style={{ marginTop: 20 }}>
|
||||
{/* 添加搜索框 */}
|
||||
<TextField
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
@ -366,16 +365,10 @@ const Main = () => {
|
||||
onChange={handleSearchChange}
|
||||
onKeyDown={handleSearchKeyDown}
|
||||
InputProps={{
|
||||
startAdornment: (
|
||||
<InputAdornment position="start">
|
||||
<SearchIcon />
|
||||
</InputAdornment>
|
||||
),
|
||||
startAdornment: <InputAdornment position="start"><SearchIcon /></InputAdornment>,
|
||||
endAdornment: searchInput && (
|
||||
<InputAdornment position="end">
|
||||
<IconButton onClick={handleClearSearch}>
|
||||
<ClearIcon />
|
||||
</IconButton>
|
||||
<IconButton onClick={handleClearSearch} edge="end"><ClearIcon /></IconButton>
|
||||
</InputAdornment>
|
||||
)
|
||||
}}
|
||||
@ -384,28 +377,33 @@ const Main = () => {
|
||||
|
||||
<CategoryNav
|
||||
categories={categories}
|
||||
currentCategory={currentCategory === SEARCH_CATEGORY ? '' : currentCategory}
|
||||
currentCategory={currentCategory === SEARCH_CATEGORY ? SEARCH_CATEGORY : currentCategory} // Pass SEARCH_CATEGORY special value
|
||||
onCategoryChange={handleCategoryChange}
|
||||
/>
|
||||
|
||||
{/* 显示当前搜索状态 */}
|
||||
{currentCategory === SEARCH_CATEGORY && (
|
||||
<div style={{ textAlign: 'center', margin: '10px 0' }}>
|
||||
搜索: "{params.searchQuery}" ({pagination.total} 个结果)
|
||||
搜索: "{params.searchQuery || currentCategoryHistory.searchQuery}" ({pagination.total} 个结果)
|
||||
</div>
|
||||
)}
|
||||
|
||||
{paginationComponent}
|
||||
|
||||
{loading ? (
|
||||
<CircularProgress sx={{ display: 'block', margin: '20px auto' }} />
|
||||
) : (
|
||||
{loading && <CircularProgress sx={{ display: 'block', margin: '20px auto' }} />}
|
||||
|
||||
{!loading && movies.length === 0 && (
|
||||
<div style={{ textAlign: 'center', margin: '20px 0', color: 'gray' }}>
|
||||
没有找到结果。
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && movies.length > 0 && (
|
||||
<Grid container spacing={2} sx={{ mt: 0.5 }}>
|
||||
{movies.map(item => (
|
||||
<Grid item xs={6} sm={4} md={3} lg={2} key={item.filename}>
|
||||
<Link
|
||||
to={`/res/${item.filename}`}
|
||||
style={{ textDecoration: 'none', paddingBottom: 10 }}
|
||||
style={{ textDecoration: 'none' }} // Removed paddingBottom, handle in Card
|
||||
onClick={handleMovieCardClick}
|
||||
>
|
||||
<MovieCard movie={item} config={config} onRename={handleRename} />
|
||||
|
@ -1,11 +1,27 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Card, CardContent, CardMedia, Typography, IconButton, TextField, Dialog, DialogActions, DialogContent, DialogTitle, Button } from '@mui/material';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardMedia,
|
||||
Typography,
|
||||
IconButton,
|
||||
TextField,
|
||||
Dialog,
|
||||
DialogActions,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
Button,
|
||||
Snackbar,
|
||||
Alert
|
||||
} from '@mui/material';
|
||||
import EditIcon from '@mui/icons-material/Edit';
|
||||
import { styled } from '@mui/system';
|
||||
|
||||
const MovieCard = ({ movie, config, onRename }) => {
|
||||
const [newName, setNewName] = useState('');
|
||||
const [openDialog, setOpenDialog] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
const [openSnackbar, setOpenSnackbar] = useState(false);
|
||||
|
||||
const truncateFilename = (filename, maxLength) => {
|
||||
return filename.length > maxLength
|
||||
@ -15,7 +31,7 @@ const MovieCard = ({ movie, config, onRename }) => {
|
||||
|
||||
const handleRenameClick = (e) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault(); // 添加阻止默认行为
|
||||
e.preventDefault();
|
||||
const lastDotIndex = movie.filename.lastIndexOf('.');
|
||||
const nameWithoutExt = lastDotIndex === -1
|
||||
? movie.filename
|
||||
@ -35,7 +51,7 @@ const MovieCard = ({ movie, config, onRename }) => {
|
||||
const handleRenameSubmit = async (e) => {
|
||||
if (e) {
|
||||
e.stopPropagation();
|
||||
e.preventDefault(); // 添加阻止默认行为
|
||||
e.preventDefault();
|
||||
}
|
||||
|
||||
if (!newName.trim()) return;
|
||||
@ -55,11 +71,21 @@ const MovieCard = ({ movie, config, onRename }) => {
|
||||
await onRename(movie.filename, fullNewName);
|
||||
handleDialogClose();
|
||||
} catch (error) {
|
||||
console.error('重命名失败:', error);
|
||||
|
||||
// 统一错误处理,优先显示后端返回的 message,其次显示 error.message,最后显示默认错误
|
||||
setError(
|
||||
error.response?.data?.message ||
|
||||
error.message ||
|
||||
'重命名失败,请稍后重试'
|
||||
);
|
||||
setOpenSnackbar(true);
|
||||
}
|
||||
};
|
||||
|
||||
// 处理输入框回车键提交
|
||||
const handleCloseSnackbar = () => {
|
||||
setOpenSnackbar(false);
|
||||
};
|
||||
|
||||
const handleKeyPress = (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.stopPropagation();
|
||||
@ -68,7 +94,6 @@ const MovieCard = ({ movie, config, onRename }) => {
|
||||
}
|
||||
};
|
||||
|
||||
// 阻止对话框内容区域的点击事件冒泡
|
||||
const handleDialogContentClick = (e) => {
|
||||
e.stopPropagation();
|
||||
};
|
||||
@ -123,7 +148,7 @@ const MovieCard = ({ movie, config, onRename }) => {
|
||||
<Dialog
|
||||
open={openDialog}
|
||||
onClose={handleDialogClose}
|
||||
onClick={handleDialogContentClick} // 阻止对话框点击事件冒泡
|
||||
onClick={handleDialogContentClick}
|
||||
maxWidth="sm"
|
||||
fullWidth
|
||||
>
|
||||
@ -138,29 +163,42 @@ const MovieCard = ({ movie, config, onRename }) => {
|
||||
variant="standard"
|
||||
value={newName}
|
||||
onChange={(e) => setNewName(e.target.value)}
|
||||
onKeyPress={handleKeyPress} // 支持回车键提交
|
||||
onKeyPress={handleKeyPress}
|
||||
helperText="请不要修改文件后缀"
|
||||
onClick={(e) => e.stopPropagation()} // 防止输入框点击冒泡
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
</DialogContent>
|
||||
<DialogActions onClick={handleDialogContentClick}>
|
||||
<Button
|
||||
onClick={handleDialogClose}
|
||||
onMouseDown={(e) => e.stopPropagation()} // 防止鼠标按下事件冒泡
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleRenameSubmit}
|
||||
onMouseDown={(e) => e.stopPropagation()} // 防止鼠标按下事件冒泡
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
variant="contained"
|
||||
>
|
||||
确定
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
<Snackbar
|
||||
open={openSnackbar}
|
||||
autoHideDuration={6000}
|
||||
onClose={handleCloseSnackbar}
|
||||
anchorOrigin={{ vertical: 'top', horizontal: 'center' }}
|
||||
>
|
||||
<Alert onClose={handleCloseSnackbar} severity="error" sx={{ width: '100%' }}>
|
||||
{error}
|
||||
</Alert>
|
||||
</Snackbar>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default MovieCard;
|
||||
export default MovieCard;
|
||||
|
||||
|
||||
|
@ -1,43 +1,153 @@
|
||||
import {React, useContext, useState} from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import React, { useContext, useState, useRef, useEffect } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import Container from '@mui/material/Container';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import Box from '@mui/material/Box';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
|
||||
import ConfigContext from '../Config';
|
||||
import ReactPlayer from 'react-player';
|
||||
|
||||
|
||||
|
||||
|
||||
const VideoPlayer = () => {
|
||||
const config = useContext(ConfigContext);
|
||||
const { filename } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const playerRef = useRef(null);
|
||||
|
||||
const [isFullScreen, setIsFullScreen] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
const [isMobile, setIsMobile] = useState(false);
|
||||
|
||||
const handleFullScreenChange = (player) => {
|
||||
setIsFullScreen(!isFullScreen);
|
||||
if (isFullScreen) {
|
||||
player.exitFullscreen();
|
||||
} else {
|
||||
player.requestFullscreen();
|
||||
}
|
||||
// 检测移动设备
|
||||
useEffect(() => {
|
||||
const checkMobile = () => {
|
||||
setIsMobile(window.innerWidth <= 768);
|
||||
};
|
||||
|
||||
checkMobile();
|
||||
window.addEventListener('resize', checkMobile);
|
||||
return () => window.removeEventListener('resize', checkMobile);
|
||||
}, []);
|
||||
|
||||
// 解码文件名
|
||||
const decodedFilename = decodeURIComponent(filename);
|
||||
const videoUrl = `${window.location.origin}/res/${filename}`;
|
||||
|
||||
const handleReady = () => {
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const handleError = (error) => {
|
||||
console.error('Video error:', error);
|
||||
setError('视频加载失败');
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
// 错误页面
|
||||
if (error) {
|
||||
return (
|
||||
<Container sx={{ mt: 2 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>
|
||||
<IconButton onClick={() => navigate(-1)} sx={{ mr: 1 }}>
|
||||
<ArrowBackIcon />
|
||||
</IconButton>
|
||||
<Typography variant="h6" noWrap>
|
||||
{decodedFilename}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{ textAlign: 'center', p: 4 }}>
|
||||
<Typography color="error">{error}</Typography>
|
||||
</Box>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
{filename}
|
||||
</Typography>
|
||||
<ReactPlayer
|
||||
url={`${window.location.origin}/res/${filename}`}
|
||||
controls
|
||||
width="100%"
|
||||
height="auto"
|
||||
playing={isFullScreen}
|
||||
onReady={handleFullScreenChange}
|
||||
/>
|
||||
</Container>
|
||||
<Box sx={{
|
||||
bgcolor: 'black',
|
||||
minHeight: '100vh',
|
||||
display: 'flex',
|
||||
flexDirection: 'column'
|
||||
}}>
|
||||
{/* 标题栏 */}
|
||||
<Box sx={{
|
||||
p: isMobile ? 1 : 2,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
bgcolor: 'rgba(0,0,0,0.8)',
|
||||
color: 'white',
|
||||
position: 'sticky',
|
||||
top: 0,
|
||||
zIndex: 1
|
||||
}}>
|
||||
<IconButton
|
||||
onClick={() => navigate(-1)}
|
||||
sx={{ color: 'white', mr: 1 }}
|
||||
size={isMobile ? 'small' : 'medium'}
|
||||
>
|
||||
<ArrowBackIcon />
|
||||
</IconButton>
|
||||
<Typography
|
||||
variant={isMobile ? 'subtitle1' : 'h6'}
|
||||
noWrap
|
||||
sx={{ flexGrow: 1 }}
|
||||
>
|
||||
{decodedFilename}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{/* 视频播放器容器 */}
|
||||
<Box sx={{
|
||||
flexGrow: 1,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
position: 'relative'
|
||||
}}>
|
||||
{loading && (
|
||||
<Typography sx={{ color: 'white', position: 'absolute' }}>
|
||||
加载中...
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
<ReactPlayer
|
||||
ref={playerRef}
|
||||
url={videoUrl}
|
||||
controls
|
||||
width="100%"
|
||||
height={isMobile ? "50vh" : "70vh"}
|
||||
onReady={handleReady}
|
||||
onError={handleError}
|
||||
config={{
|
||||
file: {
|
||||
attributes: {
|
||||
playsInline: true, // 重要:移动端内联播放
|
||||
preload: 'metadata'
|
||||
}
|
||||
}
|
||||
}}
|
||||
style={{
|
||||
maxWidth: '100%',
|
||||
maxHeight: '100%'
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* 底部信息(仅桌面端显示) */}
|
||||
{!isMobile && (
|
||||
<Box sx={{
|
||||
p: 2,
|
||||
color: 'rgba(255,255,255,0.7)',
|
||||
textAlign: 'center',
|
||||
fontSize: '0.8rem'
|
||||
}}>
|
||||
<Typography variant="caption">
|
||||
提示:双击视频可全屏播放
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default VideoPlayer;
|
Loading…
x
Reference in New Issue
Block a user