diff --git a/CHANGELOG.md b/CHANGELOG.md index 62331d0a..347bb47f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed - Merge behaviors of user/channel types and add support for `forHandle` (#339, fix for #338) +- Update layout of `Videos` tab in `zimui` to display videos from all playlists in the ZIM (#337) ## [3.1.0] - 2024-09-05 diff --git a/scraper/src/youtube2zim/schemas.py b/scraper/src/youtube2zim/schemas.py index 6b106d72..f7d390a7 100644 --- a/scraper/src/youtube2zim/schemas.py +++ b/scraper/src/youtube2zim/schemas.py @@ -61,6 +61,7 @@ class Playlist(CamelModel): """Class to serialize data about a YouTube playlist.""" id: str + slug: str author: Author title: str description: str @@ -87,6 +88,12 @@ class Playlists(CamelModel): playlists: list[PlaylistPreview] +class HomePlaylists(CamelModel): + """Class to serialize data about a list of YouTube playlists.""" + + playlists: list[Playlist] + + class Channel(CamelModel): """Class to serialize data about a YouTube channel.""" diff --git a/scraper/src/youtube2zim/scraper.py b/scraper/src/youtube2zim/scraper.py index d9c874ae..d0a29b63 100644 --- a/scraper/src/youtube2zim/scraper.py +++ b/scraper/src/youtube2zim/scraper.py @@ -57,6 +57,7 @@ Author, Channel, Config, + HomePlaylists, Playlist, PlaylistPreview, Playlists, @@ -1122,6 +1123,7 @@ def generate_playlist_object(playlist) -> Playlist: return Playlist( id=playlist.playlist_id, + slug=get_playlist_slug(playlist), title=playlist.title, description=playlist.description, videos=playlist_videos, @@ -1177,6 +1179,7 @@ def get_playlist_slug(playlist) -> str: # write playlists JSON files playlist_list = [] + home_playlist_list = [] main_playlist_slug = None if len(self.playlists) > 0: @@ -1188,13 +1191,6 @@ def get_playlist_slug(playlist) -> str: playlist_slug = get_playlist_slug(playlist) playlist_path = f"playlists/{playlist_slug}.json" - if playlist.playlist_id != self.uploads_playlist_id: - playlist_list.append(generate_playlist_preview_object(playlist)) - else: - main_playlist_slug = ( - playlist_slug # set uploads playlist as main playlist - ) - playlist_obj = generate_playlist_object(playlist) self.zim_file.add_item_for( path=playlist_path, @@ -1212,6 +1208,20 @@ def get_playlist_slug(playlist) -> str: f"playlist/{playlist_slug}", ) + # modify playlist object for preview on homepage + playlist_obj.videos = playlist_obj.videos[:12] + + if playlist.playlist_id == self.uploads_playlist_id: + main_playlist_slug = ( + playlist_slug # set uploads playlist as main playlist + ) + # insert uploads playlist at the beginning of the list + playlist_list.insert(0, generate_playlist_preview_object(playlist)) + home_playlist_list.insert(0, playlist_obj) + else: + playlist_list.append(generate_playlist_preview_object(playlist)) + home_playlist_list.append(playlist_obj) + # write playlists.json file self.zim_file.add_item_for( path="playlists.json", @@ -1223,6 +1233,17 @@ def get_playlist_slug(playlist) -> str: is_front=False, ) + # write home_playlists.json file + self.zim_file.add_item_for( + path="home_playlists.json", + title="Home Playlists", + content=HomePlaylists(playlists=home_playlist_list).model_dump_json( + by_alias=True, indent=2 + ), + mimetype="application/json", + is_front=False, + ) + # write channel.json file channel_data = get_channel_json(self.main_channel_id) self.zim_file.add_item_for( diff --git a/zimui/cypress/e2e/channel_home_page.cy.ts b/zimui/cypress/e2e/channel_home_page.cy.ts index 332e9b11..463c77c6 100644 --- a/zimui/cypress/e2e/channel_home_page.cy.ts +++ b/zimui/cypress/e2e/channel_home_page.cy.ts @@ -1,18 +1,19 @@ describe('home page for a channel', () => { beforeEach(() => { cy.intercept('GET', '/channel.json', { fixture: 'channel/channel.json' }).as('getChannel') - cy.intercept('GET', '/playlists/uploads_from_openzim_testing-917Q.json', { - fixture: 'channel/playlists/uploads_from_openzim_testing-917Q.json' - }).as('getUploads') + cy.intercept('GET', '/home_playlists.json', { + fixture: 'channel/home_playlists.json' + }).as('getHomePlaylists') cy.visit('/') cy.wait('@getChannel') - cy.wait('@getUploads') + cy.wait('@getHomePlaylists') }) it('loads the videos tab', () => { - cy.contains('2 videos').should('be.visible') - cy.contains('Coffee Machine').should('be.visible') - cy.contains('Timelapse').should('be.visible') + cy.contains('Uploads from openZIM_testing').should('be.visible') + cy.contains('Trailers').should('be.visible') + cy.contains('Timelapses').should('be.visible') + cy.contains('Coffee').should('be.visible') }) it('loads the playlist tab', () => { diff --git a/zimui/cypress/e2e/video_player_page.cy.ts b/zimui/cypress/e2e/video_player_page.cy.ts index b99856cd..252de79c 100644 --- a/zimui/cypress/e2e/video_player_page.cy.ts +++ b/zimui/cypress/e2e/video_player_page.cy.ts @@ -3,21 +3,25 @@ describe('video player page', () => { cy.intercept('GET', '/channel.json', { fixture: 'channel/channel.json' }).as('getChannel') cy.intercept('GET', '/playlists/uploads_from_openzim_testing-917Q.json', { fixture: 'channel/playlists/uploads_from_openzim_testing-917Q.json' - }).as('getUploads') + }).as('getPlaylist') + cy.intercept('GET', '/home_playlists.json', { + fixture: 'channel/home_playlists.json' + }).as('getHomePlaylists') cy.intercept('GET', '/videos/sample/video.webm', { fixture: 'channel/videos/sample/video.webm,null' }).as('getVideoFile') cy.visit('/') cy.wait('@getChannel') - cy.wait('@getUploads') + cy.wait('@getHomePlaylists') }) it('loads the video and related information', () => { cy.intercept('GET', '/videos/timelapse-9Tgo.json', { - fixture: 'channel//videos/timelapse-9Tgo.json' + fixture: 'channel/videos/timelapse-9Tgo.json' }).as('getVideo') cy.contains('.v-card-title ', 'Timelapse').click() cy.wait('@getVideo') + cy.wait('@getPlaylist') cy.wait('@getVideoFile') cy.url().should('include', '/watch') diff --git a/zimui/cypress/fixtures/channel/home_playlists.json b/zimui/cypress/fixtures/channel/home_playlists.json new file mode 100644 index 00000000..7a3808b2 --- /dev/null +++ b/zimui/cypress/fixtures/channel/home_playlists.json @@ -0,0 +1,122 @@ +{ + "playlists": [ + { + "id": "UU8elThf5TGMpQfQc_VE917Q", + "slug": "uploads_from_openzim_testing-917Q", + "author": { + "channelId": "UC8elThf5TGMpQfQc_VE917Q", + "channelTitle": "openZIM_testing", + "channelDescription": "", + "channelJoinedDate": "2024-06-04T13:30:16.232286Z", + "profilePath": "channels/UC8elThf5TGMpQfQc_VE917Q/profile.jpg", + "bannerPath": "channels/UC8elThf5TGMpQfQc_VE917Q/banner.jpg" + }, + "title": "Uploads from openZIM_testing", + "description": "", + "publicationDate": "2024-06-04T14:57:45Z", + "thumbnailPath": "videos/DYvYGQHYScc/video.webp", + "videos": [ + { + "slug": "coffee_machine-DYvY", + "id": "DYvYGQHYScc", + "title": "Coffee Machine", + "thumbnailPath": "videos/DYvYGQHYScc/video.webp", + "duration": "PT9S" + }, + { + "slug": "timelapse-9Tgo", + "id": "9TgosbGRsTk", + "title": "Timelapse", + "thumbnailPath": "videos/9TgosbGRsTk/video.webp", + "duration": "PT11S" + } + ], + "videosCount": 2 + }, + { + "id": "PLMK6hZr9PcshJpNSRVaKReGlwhVlh5Gph", + "slug": "trailers-5Gph", + "author": { + "channelId": "UC8elThf5TGMpQfQc_VE917Q", + "channelTitle": "openZIM_testing", + "channelDescription": "", + "channelJoinedDate": "2024-06-04T13:30:16.232286Z", + "profilePath": "channels/UC8elThf5TGMpQfQc_VE917Q/profile.jpg", + "bannerPath": "channels/UC8elThf5TGMpQfQc_VE917Q/banner.jpg" + }, + "title": "Trailers", + "description": "", + "publicationDate": "2024-06-04T15:15:48Z", + "thumbnailPath": "videos/TcMBFSGVi1c/video.webp", + "videos": [ + { + "slug": "marvel_studios_avengers_endgame_official_trailer-TcMB", + "id": "TcMBFSGVi1c", + "title": "Marvel Studios' Avengers: Endgame - Official Trailer", + "thumbnailPath": "videos/TcMBFSGVi1c/video.webp", + "duration": "PT2M27S" + } + ], + "videosCount": 1 + }, + { + "id": "PLMK6hZr9PcsjcF5mnaQk8Fi-xnb0AQgGI", + "slug": "timelapses-QgGI", + "author": { + "channelId": "UC8elThf5TGMpQfQc_VE917Q", + "channelTitle": "openZIM_testing", + "channelDescription": "", + "channelJoinedDate": "2024-06-04T13:30:16.232286Z", + "profilePath": "channels/UC8elThf5TGMpQfQc_VE917Q/profile.jpg", + "bannerPath": "channels/UC8elThf5TGMpQfQc_VE917Q/banner.jpg" + }, + "title": "Timelapses", + "description": "", + "publicationDate": "2024-06-04T15:04:46Z", + "thumbnailPath": "videos/9TgosbGRsTk/video.webp", + "videos": [ + { + "slug": "timelapse-9Tgo", + "id": "9TgosbGRsTk", + "title": "Timelapse", + "thumbnailPath": "videos/9TgosbGRsTk/video.webp", + "duration": "PT11S" + }, + { + "slug": "cloudy_sky_time_lapse_4k_free_footage_video_gopro_11-k02q", + "id": "k02qXOcCrbo", + "title": "Cloudy Sky ☀️ Time Lapse 4K Free Footage Video | GoPro 11", + "thumbnailPath": "videos/k02qXOcCrbo/video.webp", + "duration": "PT36S" + } + ], + "videosCount": 2 + }, + { + "id": "PLMK6hZr9PcsjTJ5u2z-khzvDNXlqdO2wS", + "slug": "coffee-O2wS", + "author": { + "channelId": "UC8elThf5TGMpQfQc_VE917Q", + "channelTitle": "openZIM_testing", + "channelDescription": "", + "channelJoinedDate": "2024-06-04T13:30:16.232286Z", + "profilePath": "channels/UC8elThf5TGMpQfQc_VE917Q/profile.jpg", + "bannerPath": "channels/UC8elThf5TGMpQfQc_VE917Q/banner.jpg" + }, + "title": "Coffee", + "description": "", + "publicationDate": "2024-06-04T15:04:25Z", + "thumbnailPath": "videos/DYvYGQHYScc/video.webp", + "videos": [ + { + "slug": "coffee_machine-DYvY", + "id": "DYvYGQHYScc", + "title": "Coffee Machine", + "thumbnailPath": "videos/DYvYGQHYScc/video.webp", + "duration": "PT9S" + } + ], + "videosCount": 1 + } + ] +} diff --git a/zimui/package.json b/zimui/package.json index 1a253906..18be4a1a 100644 --- a/zimui/package.json +++ b/zimui/package.json @@ -24,6 +24,7 @@ "vite-plugin-vuetify": "^2.0.4", "vue": "^3.5.4", "vue-router": "^4.4.4", + "vue3-carousel": "^0.3.4", "vuetify": "^3.7.1", "webp-hero": "^0.0.2" }, diff --git a/zimui/src/assets/main.css b/zimui/src/assets/main.css index b0da8c9a..251eb294 100644 --- a/zimui/src/assets/main.css +++ b/zimui/src/assets/main.css @@ -8,6 +8,10 @@ html { font-family: 'Roboto', sans-serif; } +body { + background: rgba(var(--v-theme-background)) !important; +} + a { text-decoration: none; } @@ -124,3 +128,31 @@ a { .v-btn__content > .v-icon--start { margin-right: 0.5rem; } + +.carousel__icon { + fill: black !important; +} + +.carousel__next, +.carousel__prev { + background: rgba(var(--v-theme-background)) !important; + border-radius: 100% !important; + box-shadow: + 0 4px 4px rgba(0, 0, 0, 0.3), + 0 0 4px rgba(0, 0, 0, 0.2); + width: 40px !important; + height: 40px !important; +} + +.carousel__track { + align-items: start !important; +} + +.carousel__prev--disabled, +.carousel__next--disabled { + display: none !important; +} + +.video-list .v-infinite-scroll__side { + padding: 0 !important; +} diff --git a/zimui/src/components/channel/tabs/VideosGridTab.vue b/zimui/src/components/channel/tabs/VideosGridTab.vue new file mode 100644 index 00000000..87421603 --- /dev/null +++ b/zimui/src/components/channel/tabs/VideosGridTab.vue @@ -0,0 +1,59 @@ + + + diff --git a/zimui/src/components/channel/tabs/VideosListTab.vue b/zimui/src/components/channel/tabs/VideosListTab.vue new file mode 100644 index 00000000..b6078cf0 --- /dev/null +++ b/zimui/src/components/channel/tabs/VideosListTab.vue @@ -0,0 +1,49 @@ + + + diff --git a/zimui/src/components/channel/tabs/VideosTab.vue b/zimui/src/components/channel/tabs/VideosTab.vue index 87421603..e3fc8c2e 100644 --- a/zimui/src/components/channel/tabs/VideosTab.vue +++ b/zimui/src/components/channel/tabs/VideosTab.vue @@ -1,59 +1,19 @@ diff --git a/zimui/src/components/common/ViewInfo.vue b/zimui/src/components/common/ViewInfo.vue index 43001cdc..592d1c3c 100644 --- a/zimui/src/components/common/ViewInfo.vue +++ b/zimui/src/components/common/ViewInfo.vue @@ -27,7 +27,9 @@ const props = defineProps({ -

{{ props.title }}

+

+ {{ props.title }} +

diff --git a/zimui/src/components/video/VideoCard.vue b/zimui/src/components/video/VideoCard.vue index efbf2465..d90ad680 100644 --- a/zimui/src/components/video/VideoCard.vue +++ b/zimui/src/components/video/VideoCard.vue @@ -13,6 +13,7 @@ const { smAndDown } = useDisplay() const props = defineProps<{ video: VideoPreview playlistSlug?: string + carouselMode?: boolean }>() // Set the maximum length of the title based on the screen size @@ -53,7 +54,7 @@ onMounted(async () => { > - +

{
{{ truncatedTitle }} diff --git a/zimui/src/components/video/VideoList.vue b/zimui/src/components/video/VideoList.vue new file mode 100644 index 00000000..4ae19075 --- /dev/null +++ b/zimui/src/components/video/VideoList.vue @@ -0,0 +1,58 @@ + + + diff --git a/zimui/src/components/video/carousel/VideoCarousel.vue b/zimui/src/components/video/carousel/VideoCarousel.vue new file mode 100644 index 00000000..3aace683 --- /dev/null +++ b/zimui/src/components/video/carousel/VideoCarousel.vue @@ -0,0 +1,79 @@ + + + diff --git a/zimui/src/components/video/carousel/VideoCarouselInfo.vue b/zimui/src/components/video/carousel/VideoCarouselInfo.vue new file mode 100644 index 00000000..9b8b1b2d --- /dev/null +++ b/zimui/src/components/video/carousel/VideoCarouselInfo.vue @@ -0,0 +1,80 @@ + + + + + diff --git a/zimui/src/stores/main.ts b/zimui/src/stores/main.ts index ffa396b3..375c7556 100644 --- a/zimui/src/stores/main.ts +++ b/zimui/src/stores/main.ts @@ -1,7 +1,7 @@ import { defineStore } from 'pinia' import axios, { AxiosError } from 'axios' import type { Channel } from '@/types/Channel' -import type { LoopOptions, Playlist, Playlists } from '@/types/Playlists' +import type { HomePlaylists, LoopOptions, Playlist, Playlists } from '@/types/Playlists' import type { Video } from '@/types/Videos' export type RootState = { @@ -65,6 +65,25 @@ export const useMainStore = defineStore('main', { } ) }, + async fetchHomePlaylists() { + this.isLoading = true + this.errorMessage = '' + this.errorDetails = '' + + return axios.get('./home_playlists.json').then( + (response) => { + this.isLoading = false + return response.data as HomePlaylists + }, + (error) => { + this.isLoading = false + this.errorMessage = 'Failed to load home playlists.' + if (error instanceof AxiosError) { + this.handleAxiosError(error) + } + } + ) + }, async fetchPlaylists() { this.isLoading = true this.errorMessage = '' diff --git a/zimui/src/types/Playlists.ts b/zimui/src/types/Playlists.ts index 2f19217c..3a0d0411 100644 --- a/zimui/src/types/Playlists.ts +++ b/zimui/src/types/Playlists.ts @@ -3,6 +3,7 @@ import type { VideoPreview } from './Videos' export interface Playlist { id: string + slug: string author: Author title: string description: string @@ -24,6 +25,10 @@ export interface Playlists { playlists: PlaylistPreview[] } +export interface HomePlaylists { + playlists: Playlist[] +} + export enum LoopOptions { off = 'off', loopVideo = 'loop-video', diff --git a/zimui/yarn.lock b/zimui/yarn.lock index 04152254..ce120737 100644 --- a/zimui/yarn.lock +++ b/zimui/yarn.lock @@ -4722,6 +4722,11 @@ vue-tsc@^2.1.6: "@vue/language-core" "2.1.6" semver "^7.5.4" +vue3-carousel@^0.3.4: + version "0.3.4" + resolved "https://registry.yarnpkg.com/vue3-carousel/-/vue3-carousel-0.3.4.tgz#8ef6d6b592385b7f8e97fcd508a3f4db29a2391e" + integrity sha512-jImUDbQa/9pELxUQdkflUPXL94V+iQZaOPUxWDBKSffCuxhYcV3sDM40pxoiYxUxXoNCDLUF4u9Ug6Xjdt4nkA== + vue@^3.5.4: version "3.5.4" resolved "https://registry.yarnpkg.com/vue/-/vue-3.5.4.tgz#0e5935e8b1e5505d484aee732b72c6e77c7567fd"