Watching Video
- 修改 views 改成可以查看 videos
- 建立一個新的 controller for watching video
- 修改 router for new routes
- 增加 JavaScript 以使用 YouTube API
修改 /web/templates/layout/app.html.eex 的 header
<div class="header">
<ol class="breadcrumb text-right">
<%= if @current_user do %>
<li><%= @current_user.username %></li>
<li><%= link "My Videos", to: video_path(@conn, :index) %></li>
<li>
<%= link "Log out", to: session_path(@conn, :delete, @current_user),
method: "delete" %>
</li>
<% else %>
<li><%= link "Register", to: user_path(@conn, :new) %></li>
<li><%= link "Log in", to: session_path(@conn, :new) %></li>
<% end %>
</ol>
<span class="logo"></span>
</div>
登入後,點擊 header 上面的 "My Videos" 進入 http://localhost:4000/manage/videos
新增 /web/controllers/watch_controller.ex
defmodule Rumbl.WatchController do
use Rumbl.Web, :controller
alias Rumbl.Video
def show(conn, %{"id" => id}) do
video = Repo.get!(Video, id)
render conn, "show.html", video: video
end
end
/web/templates/watch/show.html.eex
<h2><%= @video.title %></h2>
<div class="row">
<div class="col-sm-7">
<%= content_tag :div, id: "video",
data: [id: @video.id, player_id: player_id(@video)] do %>
<% end %>
</div>
<div class="col-sm-5">
<div class="panel panel-default">
<div class="panel-heading">
<h3 class="panel-title">Annotations</h3>
</div>
<div id="msg-container" class="panel-body annotations">
</div>
<div class="panel-footer">
<textarea id="msg-input"
rows="3"
class="form-control"
placeholder="Comment..."></textarea>
<button id="msg-submit" class="btn btn-primary form-control"
type="submit">
Post
</button>
</div>
</div>
</div>
</div>
/web/views/watch_view.ex
defmodule Rumbl.WatchView do
use Rumbl.Web, :view
def player_id(video) do
~r{^.*(?:youtu\.be/|\w+/|v=)(?<id>[^#&?]*)}
|> Regex.named_captures(video.url)
|> get_in(["id"])
end
end
修改 /web/router.ex
scope "/", Rumbl do
pipe_through :browser # Use the default browser stack
get "/", PageController, :index
resources "/users", UserController, only: [:index, :show, :new, :create]
resources "/sessions", SessionController, only: [:new, :create, :delete]
get "/watch/:id", WatchController, :show
end
修改 /web/templates/video/index.html.eex
<h2>Listing videos</h2>
<table class="table">
<thead>
<tr>
<th>User</th>
<th>Url</th>
<th>Title</th>
<th>Description</th>
<th></th>
</tr>
</thead>
<tbody>
<%= for video <- @videos do %>
<tr>
<td><%= video.user_id %></td>
<td><%= video.url %></td>
<td><%= video.title %></td>
<td><%= video.description %></td>
<td class="text-right">
<%= link "Watch", to: watch_path(@conn, :show, video),
class: "btn btn-default btn-xs" %>
<%= link "Edit", to: video_path(@conn, :edit, video),
class: "btn btn-default btn-xs" %>
<%= link "Delete", to: video_path(@conn, :delete, video),
method: :delete,
data: [confirm: "Are you sure?"],
class: "btn btn-danger btn-xs" %>
</td>
</tr>
<% end %>
</tbody>
</table>
<%= link "New video", to: video_path(@conn, :new) %>
Adding JavaScript
Brunch 是用 Node.js 撰寫的 build tool,Phoenix 使用 Brunch 去 build, transform, minify JS code,也能處理 css 及 assets。
Brunch 資料夾結構
web/static
- assets
- css
- js
- vendor
放在 assets 目錄是不需要 Brunch 轉換的資源,只會被複製到 priv/static,這個目錄是由 Phoenix.Static 作為 endpoint。
vendor 目錄是放 3rd party tools 例如 jQuery,external dependencies 不需要 import。
Brunch 是使用 ECMAScript6 (ES6) version,有支援 import 功能。每個 file 是一個 function,除非 import 到 app.js,否則不會自動被 browser 執行。
/web/static/js/app.js 裡面有一行
import "phoenix_html"
這就是 /web/templates/layout/app.html.eex 最後面 include 的 js
<script src="<%= static_path(@conn, "/js/app.js") %>"></script>
可在 brunch-config.js 填寫 Brunch 的設定
brunch 有三個指令
brunch build
build 所有 static files,compiling & copy 結果到 /priv/static
brunch build --production
build & minifies
brunch watch
開發時使用,brunch 會自動 recompile files。通常不需要執行,因為 Phoenix 已經有啟動了。
/config/dev.exs 裡面有一行 watchers 設定
watchers: [node: ["node_modules/brunch/bin/brunch", "watch", "--stdin", cd: Path.expand("../", __DIR__)]]
新增 /web/static/js/player.js
let Player = {
player: null,
init(domId, playerId, onReady){
window.onYouTubeIframeAPIReady = () => {
this.onIframeReady(domId, playerId, onReady)
}
let youtubeScriptTag = document.createElement("script")
youtubeScriptTag.src = "//www.youtube.com/iframe_api"
document.head.appendChild(youtubeScriptTag)
},
onIframeReady(domId, playerId, onReady){
this.player = new YT.Player(domId, {
height: "360",
width: "420",
videoId: playerId,
events: {
"onReady": (event => onReady(event) ),
"onStateChange": (event => this.onPlayerStateChange(event) )
}
})
},
onPlayerStateChange(event){ },
getCurrentTime(){ return Math.floor(this.player.getCurrentTime() * 1000) },
seekTo(millsec){ return this.player.seekTo(millsec / 1000) }
}
export default Player
修改 /web/static/js/app.js,這樣才會編譯 player.js
import "phoenix_html"
import Player from "./player"
let video = document.getElementById("video")
if(video) {
Player.init(video.id, video.getAttribute("data-player-id"), () => {
console.log("player ready!")
})
}
新增 /web/static/css/video.css
#msg-container {
min-height: 190px;
}
Creating Slugs
如希望 videos 有一個唯一的 URL-friendly identified,稱為 slug,就需要一個 table 欄位,記錄給 search engine 使用的 unique URL。ex: 1-elixir
add a slug column to table videos
mix ecto.gen.migration add_slug_to_video
修改 migration
defmodule Rumbl.Repo.Migrations.AddSlugToVideo do
use Ecto.Migration
def change do
alter table(:videos) do
add :slug, :string
end
end
end
升級 DB
$ mix ecto.migrate
[info] == Running Rumbl.Repo.Migrations.AddSlugToVideo.change/0 forward
[info] alter table videos
[info] == Migrated in 0.0s
修改 /web/models/video.ex,增加 slug 欄位,並在 changeset 加上 slugify_title()
defmodule Rumbl.Video do
use Rumbl.Web, :model
schema "videos" do
field :url, :string
field :title, :string
field :description, :string
field :slug, :string
belongs_to :user, Rumbl.User
belongs_to :category, Rumbl.Category
timestamps
end
@required_fields ~w(url title description)
@optional_fields ~w(category_id)
def changeset(model, params \\ %{}) do
model
|> cast(params, @required_fields, @optional_fields)
|> slugify_title()
|> assoc_constraint(:category)
end
defp slugify_title(changeset) do
if title = get_change(changeset, :title) do
put_change(changeset, :slug, slugify(title))
else
changeset
end
end
defp slugify(str) do
str
|> String.downcase()
|> String.replace(~r/[^\w-]+/u, "-")
end
end
因 Ecto 區隔了 changeset 及 record 定義,可將 change policy 分開,也能在 create video 的 JSON API 加上 slug
changeset 會 filter and cast 新資料,確保一些敏感資料不會從系統外面進來
changeset 可以 validate 資料
changeset 讓程式碼更容易閱讀及實作
Extending Phoenix with Protocols
查看 /web/templates/video/index.html.eex 產生 link 的部分
<%= link "Watch", to: watch_path(@conn, :show, video),
class: "btn btn-default btn-xs" %>
為了改用 slug,就修改為
watch_path(@conn, :show, "#{video.id}-#{video.slug}")
Phoenix.Param 是 Elixir Protocol,可謂任意一個 data type 自訂此參數。
修改 /web/models/video.ex 增加 defimpl Phoenix.Param, for: Rumbl.Video
defimpl Phoenix.Param, for: Rumbl.Video do
def to_param(%{slug: slug, id: id}) do
"#{id}-#{slug}"
end
end
IEx 測試
iex(1)> video = %Rumbl.Video{id: 1, slug: "hello"}
%Rumbl.Video{__meta__: #Ecto.Schema.Metadata<:built, "videos">,
category: #Ecto.Association.NotLoaded<association :category is not loaded>,
category_id: nil, description: nil, id: 1, inserted_at: nil, slug: "hello",
title: nil, updated_at: nil, url: nil,
user: #Ecto.Association.NotLoaded<association :user is not loaded>,
user_id: nil}
iex(2)> Rumbl.Router.Helpers.watch_path(%URI{}, :show, video)
"/watch/1-hello"
iex(4)> url = URI.parse("http://example.com/prefix")
%URI{authority: "example.com", fragment: nil, host: "example.com",
path: "/prefix", port: 80, query: nil, scheme: "http", userinfo: nil}
iex(5)> Rumbl.Router.Helpers.watch_path(url, :show, video)
"/prefix/watch/1-hello"
iex(6)> Rumbl.Router.Helpers.watch_url(url, :show, video)
"http://example.com/prefix/watch/1-hello"
可使用 Rumbl.Endpoint.struct_url
iex(8)> url = Rumbl.Endpoint.struct_url
%URI{authority: nil, fragment: nil, host: "localhost", path: nil, port: 4000,
query: nil, scheme: "http", userinfo: nil}
iex(9)> Rumbl.Router.Helpers.watch_url(url, :show, video)
"http://localhost:4000/watch/1-hello"
Extending Schemas with Ecto Types
新增 /lib/rumbl/permalink.ex
defmodule Rumbl.Permalink do
@behaviour Ecto.Type
def type, do: :id
def cast(binary) when is_binary(binary) do
case Integer.parse(binary) do
{int, _} when int > 0 -> {:ok, int}
_ -> :error
end
end
def cast(integer) when is_integer(integer) do
{:ok, integer}
end
def cast(_) do
:error
end
def dump(integer) when is_integer(integer) do
{:ok, integer}
end
def load(integer) when is_integer(integer) do
{:ok, integer}
end
end
Rumbl.Permalink 是根據 Ecto.Type behavior 定義的 custom type,需要定義四個 functions
type
回傳 underlying Ecto type,目前是以 :id 來建構
cast
當 external data 傳入 Ecto 時會呼叫,在 values in queries 被 interpolated 或是在 changeset 的 cast 被呼叫
dump
當 data 發送給 database 時被呼叫
load
由 DB 載入資料時被呼叫
iex(1)> alias Rumbl.Permalink, as: P
Rumbl.Permalink
iex(2)> P.cast "1"
{:ok, 1}
iex(3)> P.cast 1
{:ok, 1}
iex(4)> P.cast "13-hello-world"
{:ok, 13}
iex(5)> P.cast "hello-world-13"
:error
web/models/video.ex 增加 @primary_key
@primary_key {:id, Rumbl.Permalink, autogenerate: true}
schema "videos" do
就可以使用 http://localhost:4000/watch/2-elixir 這樣的 URL
沒有留言:
張貼留言